Merge branch 'feat/p4-iced-components-inputs-textfield-sear'

This commit is contained in:
AskaEth committed 2026-11-02 07:45:42 +08:00
commit ef3212021a
17 files changed
+3518 -38

No files matched your search

@@ -1,22 +1,341 @@
//! gallery 里的输入类小节(**桩**):后续 mission 只改本文件。
//! gallery 里的输入类小节(D30 的状态矩阵登记):TextField / Search / Slider / Menu。
//!
//! ## 为什么分成四个 `part`
//!
//! 一整段(四组件 × 五态 × 两式)比 760 高的窗口还高,`--screen=gallery` 的截图只能看到顶部。
//! 所以本文件把证据拆成四个自足的 `part`([`text_field_part`] / [`search_part`] /
//! [`slider_part`] / [`menu_part`]),[`section`] 按 D30 的顺序把它们竖排——
//! 出图时用副本工程把 `section()` 换成单个 `part`(只调段序、不改组件代码,报告里有 diff 与说明)。
//!
//! ## 矩阵形状
//!
//! ```text
//! TextField(两行 × 五态) 列 = enabled / hovered / pressed / focused / disabled
//! filled | [字段] | ...
//! outlined | [字段] | ...
//! 另加三格:空 / 有值 / 错误(9.7 的判据要单独一眼)
//!
//! Search (五行 + 一个展开面板)
//! Slider (五态一行 + 0% / 50% / 100% 三行 + 离散 4 段一行)
//! Menu (五态各一项的一只菜单 + 一只「展开态」菜单:选中 / 分隔线 / 禁用)
//! ```
//!
//! ## 每个格子都强制状态
//!
//! 与按钮家族同一个理由:截图时没法让 20 个控件同时 hover。`state(..)` 只是取证开关,
//! 不动任何状态机——图上的五态与运行期五态同源。
use iced::Element;
use iced::widget::{column, container, row, text};
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 super::super::inputs;
use crate::theme::Ui;
use super::{heading, DemoMessage};
use super::super::inputs::{
self, Field, FieldVariant, Menu, MenuItem, SearchBar, SearchView, Slider,
};
use super::super::interaction::Interaction;
use super::{cell_width, heading, row_label, state_cells, DemoMessage};
/// 输入类家族的小节。
/// 演示消息(组件只认泛型,gallery 的映射在这里落地)。
fn pressed(id: &'static str) -> DemoMessage {
DemoMessage::Pressed(id)
}
/// 输入类家族整段(四个 part 竖排;见模块文档的「为什么分成四个 part」)。
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
iced::widget::column![
column![
heading(
ui,
"inputs · text field / search / slider / menu",
"12b §8/§9/§11 + 15 §20 —— pending(后续 mission 填充本文件)"
"12b §9/§11/§8 + 15 §20:TextField 高 56(无标签 40)· Search 条 56 药丸 + 图标可视边 16 \
(= pad 12 + offset 4)· Slider 轨 16 + 竖条 4×44 · Menu 项 48 + L2 阴影"
),
inputs::demo::<DemoMessage>(ui),
text_field_part(ui),
search_part(ui),
slider_part(ui),
menu_part(ui),
]
.spacing(ui.space(unlucky_design::spacing::Space::X2))
.spacing(ui.space(Space::X8))
.into()
}
/// 一行小字(图例/说明)。
fn note<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::BodySmall);
text(value)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant))
.into()
}
/// 固定宽的矩阵格。
fn cell<'a>(ui: &'a Ui, content: impl Into<Element<'a, DemoMessage>>) -> Element<'a, DemoMessage> {
container(content)
.width(Length::Fixed(cell_width(ui)))
.into()
}
/// 五态列头。
fn state_header<'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(
container(
text(name)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
)
.width(Length::Fixed(width)),
);
}
head.into()
}
/// 一行矩阵(左侧标签 + 五格)。
fn state_row<'a>(
ui: &'a Ui,
label: &'a str,
cells: Vec<Element<'a, DemoMessage>>,
) -> Element<'a, DemoMessage> {
row![
row_label(ui, label),
row(cells).spacing(ui.space(Space::X2))
]
.spacing(ui.space(Space::X2))
.into()
}
/// TextField:两式 × 五态 + 空 / 有值 / 错误 三格。
fn text_field_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let mut matrix = column![state_header(ui)].spacing(ui.space(Space::X2));
for variant in FieldVariant::ALL {
let cells = Interaction::ALL
.iter()
.map(|state| {
cell(
ui,
inputs::text_field(
ui,
Field::new("实例一")
.variant(variant)
.label("名称")
.state(*state),
|_| pressed("field"),
),
)
})
.collect::<Vec<_>>();
matrix = matrix.push(state_row(ui, variant.name(), cells));
}
// 三格用 **320** 宽(= 2 个矩阵格):9.2 的最小宽 280 只在这里可验(矩阵格 160 更窄)
let wide = Length::Fixed(cell_width(ui) * 2.0);
let extras = row![
inputs::text_field(
ui,
Field::new("")
.label("名称")
.placeholder("请输入名称")
.width(wide),
|_| pressed("field"),
),
inputs::text_field(
ui,
Field::new("1.21.4")
.label("名称")
.leading(IconKind::Folder)
.width(wide),
|_| pressed("field"),
),
inputs::text_field(
ui,
Field::new("")
.variant(FieldVariant::Outlined)
.label("名称")
.placeholder("必填")
.trailing(IconKind::Error)
.supporting("该名称已被占用")
.error()
.width(wide),
|_| pressed("field"),
),
]
.spacing(ui.space(Space::X2));
column![
note(
ui,
"列 = 五态 · 行 = 两式。静息指示线/描边 1px、聚焦与错误 2px(A1 FocusOutlineWidth);\
错误态线/标签/辅助文本全 Error"
),
matrix,
note(
ui,
"另加:空(占位 OnSurfaceVariant)/ 有值(带前置图标 24)/ 错误(2px + 辅助文本)"
),
extras,
]
.spacing(ui.space(Space::X2))
.into()
}
/// Search:五态 bar 竖排 + 一个展开的 docked 面板。
fn search_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let bar_width = ui.space(Space::X8) * 12.0;
let mut bars = column![].spacing(ui.space(Space::X2));
for state in Interaction::ALL {
let bar = inputs::search_bar(
ui,
SearchBar::new("")
.placeholder("搜索实例 / 版本 / mod")
.state(state)
.width(Length::Fixed(bar_width)),
|_| pressed("search"),
);
bars = bars.push(
row![row_label(ui, state.name()), bar]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center),
);
}
let results: &[&str] = &[
"实例:原版 1.21.4",
"版本:Fabric 0.16.9",
"mod:Sodium 0.6.5",
];
let view = inputs::search_view(
ui,
SearchView::new("1.21", results, "没有匹配的实例").placeholder("搜索实例 / 版本 / mod"),
|_| pressed("search"),
);
column![
note(
ui,
"bar:高 56 · 圆角 full(28) · SurfaceContainerHigh · 图标可视边 16 = pad 12 + offset 4(M69 不变量)· \
聚焦 1px Secondary;view:docked,头部 56 + 1px Outline 分隔 + 结果区最小高 240"
),
row![bars, view].spacing(ui.space(Space::X6)),
]
.spacing(ui.space(Space::X2))
.into()
}
/// Slider:五态一行 + 0%/50%/100% 三行 + 离散 4 段一行。
fn slider_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let cells = Interaction::ALL
.iter()
.map(|state| {
let enabled = !state.is_disabled();
let mut spec = Slider::new(ui, 0.5).state(*state);
if enabled {
spec = spec.on_change(|_| pressed("slider"));
}
cell(ui, inputs::slider(spec))
})
.collect::<Vec<_>>();
let track_width = ui.space(Space::X8) * 14.0;
let mut values = column![].spacing(ui.space(Space::X2));
for (label, fraction) in [("0%", 0.0), ("50%", 0.5), ("100%", 1.0)] {
let spec = Slider::new(ui, fraction)
.on_change(|_| pressed("slider"))
.state(Interaction::Enabled);
values = values.push(
row![
row_label(ui, label),
container(inputs::slider(spec)).width(Length::Fixed(track_width)),
]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center),
);
}
// 离散:4 段 = 3 个内部停靠点(25 / 50 / 75%),当前 25% ⇒ 能看到 50% 与 75% 两个点
let discrete = row![
row_label(ui, "离散 4"),
container(inputs::slider(
Slider::new(ui, 0.25)
.steps(4)
.on_change(|_| pressed("slider"))
.state(Interaction::Enabled)
))
.width(Length::Fixed(track_width)),
]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
column![
note(
ui,
"轨 16 full(Primary / SecondaryContainer)· 竖条 4×44 full(overflow 上下各 14)· \
focus/pressed 收窄到 2 · 命中 48 · 离散停靠点 4"
),
state_row(ui, "五态", cells),
note(ui, "0% / 50% / 100%:竖条在两端贴边但不越界(左缘 = 轨道左缘,右缘 = 轨道右缘)"),
values,
note(
ui,
"离散:steps(4) ⇒ 3 个内部停靠点(25/50/75%),未激活轨在停靠点两侧留 6 空隙,\n 激活段只画到竖条中心",
),
discrete,
]
.spacing(ui.space(Space::X2))
.into()
}
/// Menu:一只五态菜单 + 一只展开态菜单(选中 / 分组线 / 禁用)。
fn menu_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let width = ui.space(Space::X8) * 8.0;
let mut states = Menu::new().width(Length::Fixed(width));
for state in Interaction::ALL {
let mut item = MenuItem::new("重命名", pressed("menu")).state(state);
if state.is_disabled() {
item = MenuItem::disabled("重命名").state(state);
}
states = states.item(item.leading(IconKind::Folder));
}
let open = Menu::new()
.width(Length::Fixed(width))
.item(MenuItem::new("重命名", pressed("menu")).leading(IconKind::Folder))
.item(
MenuItem::new("复制实例", pressed("menu"))
.leading(IconKind::Download)
.trailing(IconKind::Check)
.selected(),
)
.divider()
.item(MenuItem::disabled("删除").leading(IconKind::Trash));
column![
note(
ui,
"容器 SurfaceContainer + L2 阴影(blur 6 / offset 2 / Shadow 角色)· 圆角 4(令牌表)· \
项高 48 · 水平 12 · 上下留白 8 · 选中项 SecondaryContainer 指示器内缩 4、圆角 8"
),
row![
column![note(ui, "五态(每项强制一态)"), inputs::menu(ui, states)]
.spacing(ui.space(Space::X2)),
column![
note(ui, "展开:含选中项 / 分组分隔线(复用 divider)/ 不可点项"),
inputs::menu(ui, open)
]
.spacing(ui.space(Space::X2)),
]
.spacing(ui.space(Space::X6)),
]
.spacing(ui.space(Space::X2))
.into()
}
@@ -0,0 +1,393 @@
//! 文本框(`research/12b` §9):Filled / Outlined 两式,**受控组件**。
//!
//! ## 参考值(`[源]` = A1,本机 `material3-android:1.4.0` 的 `*TextFieldTokens` + `TextFieldDefaults`)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 9.1 | 高度 | min **56**(带标签);无标签单行本项目 **40** | `FIELD_HEIGHT`(G16)/ `LM.control_height` |
//! | 9.2 | 最小宽 | **280** → **G21 未施加**(iced 的 `Length` 无 min-width,同按钮 G15) | 文档 |
//! | 9.3 | 形状 | Outlined **4**;Filled 顶圆角 4、底直角 | `Component::TextField.spec().shape` |
//! | 9.4 | 描边 / 指示线 | Outlined 静息 1dp `Outline` → hover 1dp `OnSurface` → 聚焦 **2dp `Primary`**;Filled 底指示线静息 `OnSurfaceVariant` | [`Filled`] / [`Palette`] |
//! | 9.5 | 字级 | 输入/标签 `BodyLarge 16/24`;辅助/错误 `BodySmall 12/16` | `TypeRole` |
//! | 9.6 | 图标 | 前后 **24**、`OnSurfaceVariant`;图标可视边 **12**、文本从 **48** 起 | `FieldGeometry` |
//! | 9.7 | 错误态 | 线/标签/辅助文本全 **`Error`**、线宽 **2dp** | [`Field::error`] |
//! | 9.8 | 禁用态 | Filled 容器 `OnSurface`@**4%**(→ 令牌只有 12%,**登记偏离 D10**);Outlined 描边 `OnSurface`@**12%**;内容 @**38%** | `interaction::*` |
//!
//! ## 三处口径说明
//!
//! 1. **状态差异落在指示线/描边/标签上,容器底色不叠状态层**——这是 A1 文本字段的口径
//! (`HoverActiveIndicatorColor` / `FocusActiveIndicatorColor` 等 token),与按钮家族
//! 「叠一个 8%/10% 的状态层」不同。press 在 A1 里**没有**独立 token,本实现按 hover 处理(登记 D11)。
//! 2. 标签是「**常浮起**」的两段式:有标签就固定占上面那 16px(`BodySmall`),不做上浮动画
//! (M3 的动画由 motion mission 统一接,登记 D12)。
//! 3. 缺省宽度 `Length::Fill`:文本框在页面里按列宽伸展,最小宽 280 无法在 iced 表达(G21)。
use iced::widget::text::LineHeight;
use iced::widget::{column, container, row, text, text_input};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::FieldGeometry;
/// 两式容器(12b 9.3 / 9.4 的差别:Filled 有容器色 + 底部指示线;Outlined 只有描边)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FieldVariant {
/// 实心:`SurfaceContainerHighest` + 底部指示线,顶圆角 4 底直角。
Filled,
/// 描边:透明容器 + 1dp `Outline`,四角 4。
Outlined,
}
impl FieldVariant {
/// 两式全序(gallery 的行序)。
pub const ALL: [FieldVariant; 2] = [FieldVariant::Filled, FieldVariant::Outlined];
/// 稳定名字(图例 / 测试信息)。
pub const fn name(self) -> &'static str {
match self {
FieldVariant::Filled => "filled",
FieldVariant::Outlined => "outlined",
}
}
}
/// 一个文本框的装配描述(不持有 `String`:受控组件只借值,契约 §2)。
#[derive(Debug, Clone)]
pub struct Field<'a> {
variant: FieldVariant,
value: &'a str,
label: Option<&'a str>,
placeholder: &'a str,
supporting: Option<&'a str>,
leading: Option<IconKind>,
trailing: Option<IconKind>,
error: bool,
state: Option<Interaction>,
width: Length,
}
impl<'a> Field<'a> {
/// 一个 Filled 文本框。
pub fn new(value: &'a str) -> Self {
Self {
variant: FieldVariant::Filled,
value,
label: None,
placeholder: "",
supporting: None,
leading: None,
trailing: None,
error: false,
state: None,
width: Length::Fill,
}
}
/// 选式(Filled / Outlined)。
pub fn variant(mut self, variant: FieldVariant) -> Self {
self.variant = variant;
self
}
/// 浮动标签(`BodySmall`,常浮起,见模块文档第 2 条)。
pub fn label(mut self, label: &'a str) -> Self {
self.label = Some(label);
self
}
/// 占位文案(只在值为空时可见,`OnSurfaceVariant`)。
pub fn placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = placeholder;
self
}
/// 辅助/错误说明(字段下方 `BodySmall`)。
pub fn supporting(mut self, supporting: &'a str) -> Self {
self.supporting = Some(supporting);
self
}
/// 前置图标(24,图标可视边 12)。
pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind);
self
}
/// 后置图标(24,右侧内边距 16)。
pub fn trailing(mut self, kind: IconKind) -> Self {
self.trailing = Some(kind);
self
}
/// 错误态:线 / 标签 / 辅助文本转 `Error`,线宽 2dp(9.7)。
pub fn error(mut self) -> Self {
self.error = true;
self
}
/// 强制视觉态(gallery 状态矩阵取证;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 定宽(默认 `Fill`;gallery 的矩阵格用它钉宽度)。
pub fn width(mut self, width: Length) -> Self {
self.width = width;
self
}
}
/// 一个字段在某一态下的全套颜色(9.4 / 9.7 / 9.8 的落点)。
#[derive(Debug, Clone, Copy, PartialEq)]
struct Palette {
/// 容器底色;`None` = 透明(Outlined)。
fill: Option<Color>,
/// 描边/指示线色。
line: Color,
/// 描边/指示线宽(1 或 2)。
stroke: f32,
/// 标签(`BodySmall`)。
label: Color,
/// 输入文本。
input: Color,
/// 前后图标。
icon: Color,
/// 辅助文本。
supporting: Color,
/// 占位文本。
placeholder: Color,
}
/// 解析某一态的颜色(**唯一**做角色映射的地方)。
fn palette(ui: &Ui, variant: FieldVariant, state: Interaction, error: bool) -> Palette {
let geometry = FieldGeometry::of(ui, true, None);
if state.is_disabled() {
// 9.8:内容一律 OnSurface@38%;Filled 容器叠 12%(官方 4%,令牌无 4% 档 → D10);
// Outlined 描边正好是官方 12%。
let container = interaction::disabled_colors(ui, Role::Surface).0;
let ink = interaction::disabled_content_over_role(
ui,
Role::OnSurface,
matches!(variant, FieldVariant::Filled),
);
return Palette {
fill: match variant {
FieldVariant::Filled => Some(container),
FieldVariant::Outlined => None,
},
line: match variant {
FieldVariant::Filled => ink,
FieldVariant::Outlined => container,
},
stroke: geometry.stroke,
label: ink,
input: ink,
icon: ink,
supporting: ink,
placeholder: ink,
};
}
let focused = matches!(state, Interaction::Focused);
let hovered = matches!(state, Interaction::Hovered | Interaction::Pressed);
let (line, stroke) = if error {
(ui.color(Role::Error), geometry.focus_stroke)
} else if focused {
(ui.color(Role::Primary), geometry.focus_stroke)
} else if hovered {
(ui.color(Role::OnSurface), geometry.stroke)
} else {
(
match variant {
FieldVariant::Filled => ui.color(Role::OnSurfaceVariant),
FieldVariant::Outlined => ui.color(Role::Outline),
},
geometry.stroke,
)
};
Palette {
fill: match variant {
FieldVariant::Filled => Some(ui.color(Role::SurfaceContainerHighest)),
FieldVariant::Outlined => None,
},
line,
stroke,
label: if error {
ui.color(Role::Error)
} else if focused {
ui.color(Role::Primary)
} else {
ui.color(Role::OnSurfaceVariant)
},
input: ui.color(Role::OnSurface),
icon: if error {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
},
supporting: if error {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
},
placeholder: ui.color(Role::OnSurfaceVariant),
}
}
/// 一个文本框(Filled / Outlined 由 [`Field::variant`] 选)。
///
/// 受控:值由外部持有,输入经 `on_input` 回传;`state` 强制成 `Disabled` 时输入被摘掉
/// (`on_input_maybe(None)`),所以禁用字段真的点不动,不只是变灰。
pub fn text_field<'a, M: Clone + 'a>(
ui: &'a Ui,
field: Field<'a>,
on_input: impl Fn(String) -> M + 'a,
) -> Element<'a, M> {
let geometry = FieldGeometry::of(ui, field.label.is_some(), field.leading);
let state = field.state.unwrap_or(Interaction::Enabled);
let colors = palette(ui, field.variant, state, field.error);
let input_style = ui.style(TypeRole::BodyLarge);
let label_style = ui.style(TypeRole::BodySmall);
let text_input = text_input(field.placeholder, field.value)
.on_input_maybe(if state.is_disabled() {
None
} else {
Some(on_input)
})
.size(input_style.size_px)
.line_height(LineHeight::Absolute(Pixels(input_style.line_height_px)))
.font(ui.font_of(input_style))
.padding(Padding::default())
.width(Length::Fill)
.style(move |_theme, _status| text_input::Style {
background: Background::Color(Color::TRANSPARENT),
border: Border::default(),
icon: colors.icon,
placeholder: colors.placeholder,
value: colors.input,
selection: colors.line,
});
let mut content = row![].align_y(Alignment::Center).spacing(geometry.icon_gap);
if let Some(kind) = field.leading {
// Q9:图标必须经固定尺寸容器,否则会被 Fill 约束撑满(M68 的 46px 字形)
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.icon),
));
}
content = content.push(text_input);
if let Some(kind) = field.trailing {
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.icon),
));
}
let label = field.label.map(|label| {
text(label)
.size(label_style.size_px)
.line_height(LineHeight::Absolute(Pixels(label_style.line_height_px)))
.font(ui.font_of(label_style))
.color(colors.label)
});
// 内容块:带标签 = [标签 16 + 输入 24] 共 40,居中在内容区里;无标签 = 单行 24 居中。
// **垂直居中交给容器**(Q8 的教训:不要指望子元素自己贴边)。
let inner: Element<'a, M> = match label {
Some(label) => column![label, content].width(Length::Fill).into(),
None => content.into(),
};
let content_area = container(inner)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding {
top: 0.0,
right: geometry.padding.right,
bottom: 0.0,
left: geometry.padding.left,
});
// Filled:容器色 + **底部指示线**(静息 1dp / 聚焦与错误 2dp);Outlined:透明 + 描边。
// 指示线占容器底边那 1~2px(内容区因此少 1~2px,官方也是画在 padding-box 底边上的)。
let body: Element<'a, M> = match field.variant {
FieldVariant::Filled => {
let indicator = container(iced::widget::space::horizontal())
.width(Length::Fill)
.height(Length::Fixed(colors.stroke))
.style(move |_theme| container::Style {
background: Some(Background::Color(colors.line)),
..container::Style::default()
});
column![content_area, indicator].into()
}
FieldVariant::Outlined => content_area.into(),
};
let border = Border {
color: if matches!(field.variant, FieldVariant::Outlined) {
colors.line
} else {
Color::TRANSPARENT
},
width: if matches!(field.variant, FieldVariant::Outlined) {
colors.stroke
} else {
0.0
},
// Filled 是「顶圆角 4、底直角」= CornerExtraSmallTop;ShapeScale 没有「部分角」,
// 这里按官方语义手工指定上下角(登记 D13)。
radius: match field.variant {
FieldVariant::Filled => iced::border::Radius {
top_left: ui.radius_dp(geometry.shape).top_left,
top_right: ui.radius_dp(geometry.shape).top_right,
bottom_left: 0.0,
bottom_right: 0.0,
},
FieldVariant::Outlined => ui.radius_dp(geometry.shape),
},
};
let field_el: Element<'a, M> = container(body)
.width(field.width)
.height(Length::Fixed(geometry.height))
.style(move |_theme| container::Style {
background: colors.fill.map(Background::Color),
border,
..container::Style::default()
})
.into();
match field.supporting {
Some(supporting) => {
let note = text(supporting)
.size(label_style.size_px)
.line_height(LineHeight::Absolute(Pixels(label_style.line_height_px)))
.font(ui.font_of(label_style))
.color(colors.supporting);
column![field_el, layout::inset(0.0, ui.space(Space::X1), note)].into()
}
None => field_el,
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,115 @@
//! field 的单测(从 `field.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
fn build(field: Field<'_>, flavor: SchemeFlavor) -> Element<'_, u8> {
let ui = Box::leak(Box::new(ui(flavor)));
text_field(ui, field, |_| 0_u8)
}
#[test]
fn every_variant_and_state_builds() {
for variant in FieldVariant::ALL {
for state in Interaction::ALL {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let field = Field::new("实例一")
.variant(variant)
.label("名称")
.placeholder("请输入")
.supporting("辅助说明")
.leading(IconKind::Folder)
.trailing(IconKind::Close)
.state(state);
let _: Element<'_, u8> = build(field, flavor);
}
}
}
}
/// 9.4:聚焦 2dp `Primary`、hover 1dp `OnSurface`、静息 Outlined 1dp `Outline`(Filled 用 OnSurfaceVariant)。
#[test]
fn outline_colors_and_widths_follow_the_state() {
let ui = ui(SchemeFlavor::Light);
let rest = palette(&ui, FieldVariant::Outlined, Interaction::Enabled, false);
assert_eq!(rest.line, ui.color(Role::Outline));
assert_eq!(rest.stroke, 1.0);
assert_eq!(rest.fill, None, "Outlined 无容器色");
let hover = palette(&ui, FieldVariant::Outlined, Interaction::Hovered, false);
assert_eq!(hover.line, ui.color(Role::OnSurface));
assert_eq!(hover.stroke, 1.0);
let focus = palette(&ui, FieldVariant::Outlined, Interaction::Focused, false);
assert_eq!(focus.line, ui.color(Role::Primary));
assert_eq!(focus.stroke, 2.0, "FocusOutlineWidth = 2dp");
assert_eq!(focus.label, ui.color(Role::Primary), "FocusLabelColor");
// A1 没有 Pressed token:按 hover 处理(D11),不是漏做
assert_eq!(
palette(&ui, FieldVariant::Outlined, Interaction::Pressed, false),
hover
);
}
/// 9.7:错误态线/标签/辅助全 `Error`,线宽 2dp;错误优先于聚焦。
#[test]
fn error_beats_focus_and_paints_everything_error() {
let ui = ui(SchemeFlavor::Light);
let error = palette(&ui, FieldVariant::Outlined, Interaction::Focused, true);
assert_eq!(error.line, ui.color(Role::Error));
assert_eq!(error.label, ui.color(Role::Error));
assert_eq!(error.supporting, ui.color(Role::Error));
assert_eq!(error.stroke, 2.0, "错误线宽 2dp");
assert_eq!(error.input, ui.color(Role::OnSurface), "ErrorInputColor");
}
/// 9.8:禁用内容 38%、Outlined 描边 12%、Filled 容器 12%(官方 4%,令牌缺口 → D10)。
#[test]
fn disabled_uses_the_token_alphas() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let container = interaction::disabled_colors(&ui, Role::Surface).0;
let filled = palette(&ui, FieldVariant::Filled, Interaction::Disabled, false);
assert_eq!(filled.fill, Some(container));
assert_eq!(
filled.input,
interaction::disabled_content_over_role(&ui, Role::OnSurface, true)
);
let outlined = palette(&ui, FieldVariant::Outlined, Interaction::Disabled, false);
assert_eq!(outlined.fill, None);
assert_eq!(outlined.line, container, "DisabledOutlineOpacity = 0.12");
assert_eq!(
outlined.input,
interaction::disabled_content_over_role(&ui, Role::OnSurface, false)
);
assert_ne!(outlined.input, ui.color(Role::OnSurface), "禁用不许满档");
}
}
/// Filled 有容器色、Outlined 没有;两者圆角都取 `Component::TextField`(4)。
#[test]
fn filled_carries_the_container_role() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(
palette(&ui, FieldVariant::Filled, Interaction::Enabled, false).fill,
Some(ui.color(Role::SurfaceContainerHighest)),
"FilledTextFieldTokens.ContainerColor"
);
assert_eq!(FieldGeometry::of(&ui, true, None).shape.dp(), 4.0);
}
@@ -0,0 +1,309 @@
//! 输入类家族的**内部几何**:能取令牌的一律取令牌,取不到的登记 G 编号后集中在这里。
//!
//! ## 为什么这个文件存在
//!
//! `components::gate` 的 `t2_no_size_literals_in_components` 扫 `.width(40.0)` 这类**调用点字面量**;
//! 但「TextField 高 56」这种 A 级参考值在 `LayoutMetrics` / `ComponentMetrics` 里确实没有落点
//! (令牌层是另一条 mission 的 scope,不许我改)。所以按契约 §6.2:**登记 G 编号 + 只在这个文件里
//! 定义一次**,组件实现一个数字都不写。
//!
//! ## 参考值表(`[源]` = A1,本机 `material3-android:1.4.0` 的 token 源码;`[估]` = 团队口径)
//!
//! | G | 名称 | 参考值 | 出处 | 本文件落点 |
//! |---|---|---|---|---|
//! | G16 | TextField 可见高(带标签) | **56** | `TextFieldDefaults.MinHeight` / `OutlinedTextFieldTokens.ContainerHeight` | [`FIELD_HEIGHT`] |
//! | G17 | 聚焦/错误描边 | **2** | `OutlinedTextFieldTokens.FocusOutlineWidth`、`FilledTextFieldTokens.FocusActiveIndicatorHeight` | [`FOCUS_STROKE`] |
//! | G18 | Slider 轨道 / 竖条 / 停靠点 | **16 / 4×44 / 4** | `SliderTokens.ActiveTrackHeight` / `HandleWidth`+`HandleHeight` / `StopIndicatorSize` | [`TRACK_HEIGHT`] 等 |
//! | G19 | 菜单项高 | **48** | `Menu.kt:524 MenuListItemContainerHeight` | [`MENU_ITEM_HEIGHT`] |
//! | G20 | Search 宽高 | **min 360 / max 720 / 面板最小 240** | `SearchBar.kt:2712-2713`、`DockedExpandedTableMinHeight` | [`SEARCH_MIN_WIDTH`] 等 |
//!
//! **已经能取令牌、不许在这里重复定义的**(同一数值只许一份,见 `ComponentMetrics` 模块文档):
//! 搜索条高 56 = `LayoutMetrics::search_bar_height`;无标签单行 40 = `LayoutMetrics::control_height`;
//! 命中区 48 = `LayoutMetrics::touch_target`;水平/上下内边距与图标间距 = `Space::{X2,X3,X4}`;
//! 图标 24 = `IconSize::Toolbar`;圆角 = `ComponentSpec::shape`(TextField/Menu = 4,Search = Full,
//! SearchView = ExtraLarge 28);一像素线 = `stroke::HAIRLINE`。
//!
//! ## 与文档的两处口径冲突(按「代码优先」处理,报告里有偏离登记)
//!
//! 1. `12b §8.1` 说菜单圆角「本项目 12(偏离官方 4)」;令牌表 `Component::Menu.spec().shape`
//! 是 `ExtraSmall`(**4**)。令牌层是 M69 定案的真源,本实现取 **4**,不取 12。
//! 2. `12b §9.1` 说「无标签单行 40」是登记偏离——40 正好是 `LM.control_height`,
//! 所以那条偏离在实现上是「用骨架令牌」而不是「用字面量」。
use iced::Padding;
use unlucky_design::component_map::Component;
use unlucky_design::icons::{IconKind, IconSize};
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use crate::theme::{Ui, STROKE_PX};
/// G16:文本字段可见高(带浮动标签)。
pub const FIELD_HEIGHT: f32 = 56.0;
/// G17:聚焦/错误态的描边与指示线宽(官方 2dp;令牌层的 `stroke::HAIRLINE` 只有 1dp)。
pub const FOCUS_STROKE: f32 = 2.0;
/// G18:Slider 轨道高(激活/未激活同值)。
pub const TRACK_HEIGHT: f32 = 16.0;
/// G18:Slider 竖条 handle 宽(静息 / hover)。
pub const HANDLE_WIDTH: f32 = 4.0;
/// G18:Slider 竖条 handle 宽(focus / pressed)——A1 在这两态收窄到 2。
pub const HANDLE_WIDTH_ACTIVE: f32 = 2.0;
/// G18:Slider 竖条 handle 高(纵向溢出轨道,上下各 14)。
pub const HANDLE_HEIGHT: f32 = 44.0;
/// G18:离散停靠点直径。
pub const STOP_SIZE: f32 = 4.0;
/// G18:停靠点与轨道段之间的空隙(官方 `StopIndicatorTrailingSpace = 6`;6 不在 4px 栅格上)。
pub const STOP_GAP: f32 = 6.0;
/// G19:菜单项高。
pub const MENU_ITEM_HEIGHT: f32 = 48.0;
/// G20:搜索条最小宽(列宽不足时**不许压窄**,降级为 40 高 TextField 或 icon 入口)。
pub const SEARCH_MIN_WIDTH: f32 = 360.0;
/// G20:搜索条最大宽。
pub const SEARCH_MAX_WIDTH: f32 = 720.0;
/// G20:展开面板最小高(空结果也不许矮于它)。
pub const SEARCH_VIEW_MIN_HEIGHT: f32 = 240.0;
/// 文本字段的几何(Filled / Outlined 共用;差别只在颜色与形状)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FieldGeometry {
/// 可见高:带标签 [`FIELD_HEIGHT`],无标签 `LM.control_height`。
pub height: f32,
/// 内容内边距(无图标 16/16;有前置图标 12/16,图标与文本间距另算)。
pub padding: Padding,
/// 前置图标可视左边距(无偏移,就是内边距 12)。
pub icon_inset: f32,
/// 图标 ↔ 文本间距(12:12 + 24 + 12 = 48 起文本,与官方 48 的图标槽一致)。
pub icon_gap: f32,
/// 图标边长(24)。
pub icon: f32,
/// 静息描边(一像素线)。
pub stroke: f32,
/// 聚焦/错误描边(2)。
pub focus_stroke: f32,
/// 圆角档位(`Component::TextField` = ExtraSmall)。
pub shape: ShapeScale,
}
impl FieldGeometry {
/// 求值;`labeled` = 带浮动标签(56),否则单行 40;`leading` = 有没有前置图标。
pub fn of(ui: &Ui, labeled: bool, leading: Option<IconKind>) -> Self {
let icon_inset = ui.space(Space::X3);
let pad_end = ui.space(Space::X4);
let padding = if leading.is_some() {
Padding {
top: 0.0,
right: pad_end,
bottom: 0.0,
left: icon_inset,
}
} else {
Padding {
top: 0.0,
right: pad_end,
bottom: 0.0,
left: pad_end,
}
};
Self {
height: if labeled {
FIELD_HEIGHT
} else {
ui.metrics().control_height
},
padding,
icon_inset,
icon_gap: ui.space(Space::X3),
icon: IconSize::Toolbar.px(),
stroke: STROKE_PX,
focus_stroke: FOCUS_STROKE,
shape: Component::TextField.spec().shape,
}
}
}
/// Slider 的几何(轨道 / 竖条 / 停靠点 / 命中区)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SliderGeometry {
/// 轨道高(16)。
pub track: f32,
/// 竖条宽(静息 4 / focus·pressed 2)。
pub handle: f32,
/// 竖条高(44)。
pub handle_tall: f32,
/// 停靠点直径(4)。
pub stop: f32,
/// 控件高 = 命中区(`LM.touch_target` 48:竖条 44 + 上下各 2)。
pub height: f32,
}
impl SliderGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
Self {
track: TRACK_HEIGHT,
handle: HANDLE_WIDTH,
handle_tall: HANDLE_HEIGHT,
stop: STOP_SIZE,
height: ui.metrics().touch_target,
}
}
}
/// 菜单(Menu / DropdownMenuItem)的几何。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MenuGeometry {
/// 项高(48)。
pub item_height: f32,
/// 水平内边距(12,来自 `Space::X3`)。
pub pad_h: f32,
/// 上下留白(8,来自 `Space::X2`)。
pub pad_v: f32,
/// 槽位图标边长(24)。
pub icon: f32,
/// 图标 ↔ 文本间距(12)。
pub gap: f32,
/// 容器圆角档位(`Component::Menu` = ExtraSmall 4)。
pub shape: ShapeScale,
}
impl MenuGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
Self {
item_height: MENU_ITEM_HEIGHT,
pad_h: ui.space(Space::X3),
pad_v: ui.space(Space::X2),
icon: IconSize::Toolbar.px(),
gap: ui.space(Space::X3),
shape: Component::Menu.spec().shape,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// G16 / G17:两个 2dp 与 56dp 的参考值钉在这里(改参考表会让这条红)。
#[test]
fn field_geometry_matches_the_reference_table() {
let ui = ui();
let labeled = FieldGeometry::of(&ui, true, None);
assert_eq!(labeled.height, 56.0, "G16 TextFieldDefaults.MinHeight");
assert_eq!(labeled.stroke, 1.0, "静息一像素线");
assert_eq!(labeled.focus_stroke, 2.0, "G17 FocusOutlineWidth");
assert_eq!(labeled.shape.dp(), 4.0, "CornerExtraSmall");
// 无标签单行 40 = 骨架令牌,不是字面量
let compact = FieldGeometry::of(&ui, false, None);
assert_eq!(compact.height, ui.metrics().control_height);
assert_eq!(compact.height, 40.0);
// 内边距:无图标 16/16
assert_eq!(compact.padding.left, 16.0);
assert_eq!(compact.padding.right, 16.0);
assert_eq!(compact.padding.left, compact.padding.right);
}
/// 前置图标:图标可视边 12、文本从 48 起(= 12 + 24 + 12,官方 48 的图标槽)。
#[test]
fn field_icon_slot_matches_the_official_48() {
let ui = ui();
let leading = FieldGeometry::of(&ui, true, Some(IconKind::Search));
assert_eq!(leading.icon, 24.0, "LeadingIconSize");
assert_eq!(leading.icon_inset, 12.0, "textFieldHorizontalIconPadding");
// 文本起点 = 图标可视边 12 + 图标 24 + 间距 12 = 48(官方图标槽)
assert_eq!(leading.icon_inset + leading.icon + leading.icon_gap, 48.0);
// 右侧仍是 16(官方内边距不因图标变)
assert_eq!(leading.padding.right, 16.0);
}
/// G18:轨道 16 / 竖条 4×44 / 停靠点 4 / 命中 48;竖条比轨道高 28 ⇒ 上下各溢出 14。
#[test]
fn slider_geometry_matches_the_reference_table() {
let ui = ui();
let slider = SliderGeometry::of(&ui);
assert_eq!(slider.track, 16.0, "ActiveTrackHeight");
assert_eq!(slider.handle, 4.0, "HandleWidth");
assert_eq!(slider.handle_tall, 44.0, "HandleHeight");
assert_eq!(slider.stop, 4.0, "StopIndicatorSize");
assert_eq!(
(slider.handle_tall - slider.track) / 2.0,
14.0,
"上下各溢出"
);
assert_eq!(slider.height, 48.0, "命中下限 LM.touch_target");
assert!(slider.height >= slider.handle_tall, "命中区必须容得下竖条");
// focus / pressed 收窄到 2(A1 FocusHandleWidth / PressedHandleWidth)
assert_eq!(HANDLE_WIDTH_ACTIVE, 2.0);
assert!(HANDLE_WIDTH_ACTIVE < slider.handle);
}
/// G19:项高 48、水平 12、上下 8、图标 24、圆角按令牌表(4,**不是** 12b 说的 12)。
#[test]
fn menu_geometry_matches_the_reference_table() {
let ui = ui();
let menu = MenuGeometry::of(&ui);
assert_eq!(menu.item_height, 48.0, "MenuListItemContainerHeight");
assert_eq!(menu.pad_h, 12.0, "DropdownMenuItemHorizontalPadding");
assert_eq!(menu.pad_v, 8.0, "DropdownMenuVerticalPadding");
assert_eq!(menu.icon, 24.0);
assert_eq!(
menu.shape.dp(),
4.0,
"MenuTokens.ContainerShape = CornerExtraSmall"
);
assert_eq!(
menu.shape,
Component::Menu.spec().shape,
"圆角只能来自令牌表"
);
assert_eq!(menu.gap, ui.space(Space::X3), "图标 ↔ 文本 12 走栅格令牌");
}
/// G20:搜索条 min 360 / max 720;面板最小高 240;条高取骨架令牌 56(**不在这里重复定义**)。
#[test]
fn search_constants_do_not_duplicate_layout_metrics() {
let ui = ui();
assert_eq!(SEARCH_MIN_WIDTH, 360.0);
assert_eq!(SEARCH_MAX_WIDTH, 720.0);
assert_eq!(SEARCH_VIEW_MIN_HEIGHT, 240.0);
assert_eq!(ui.metrics().search_bar_height, 56.0, "条高来自 LM");
assert_eq!(
ShapeScale::Full.radius_px(ui.metrics().search_bar_height),
28.0
);
assert_eq!(Component::Search.spec().shape, ShapeScale::Full);
// 图标可视边 = pad + offset(M69 的 SEARCH_BAR 不变量,本文件不重复定义 12/4)
let bar = Component::Search.metrics().expect("Search 有组件级度量");
assert_eq!(bar.pad_start + bar.icon_offset, 16.0);
assert_eq!(bar.icon, 24.0);
}
}
@@ -0,0 +1,354 @@
//! 菜单(`research/12b` §8):`Menu` 弹出层 + `DropdownMenuItem`。
//!
//! ## 参考值(`[源]` = A1,`MenuTokens` / `Menu.kt` 1.4.0)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 8.1 | 容器圆角 | 官方 **4**(`ContainerShape = CornerExtraSmall`);`12b` 说「本项目 12」——**取令牌表的 4**(报告偏离 D18) | `Component::Menu.spec().shape` |
//! | 8.2 | 容器色 / 高程 | `SurfaceContainer` + **L2** | `Role::SurfaceContainer` + `Elevation::Level2` |
//! | 8.3 | 项高 | **48** | `MENU_ITEM_HEIGHT`(G19) |
//! | 8.4 | 项内边距 | 水平 **12**、菜单上下留白 **8** | `Space::X3` / `Space::X2` |
//! | 8.5 | 字级 / 图标 | 文本 `BodyMedium 14/20`;图标 **24** | `TypeRole` + `IconSize::Toolbar` |
//! | 8.6 | 选中项 | 容器 `SecondaryContainer` + 文本 `OnSecondaryContainer`;指示器水平内缩 **4**、圆角 **8** | [`MenuItem::selected`] |
//! | 8.7 | 宽度 | 不窄于触发元素、长文本不换行(`ellipsis` 本轮不做,登记 D19) | [`Menu::width`] |
//!
//! ## 弹出层阴影
//!
//! 高程取 `Component::Menu.spec().elevation`(= L2),阴影参数取 `Elevation::shadow()`
//! (`ShadowSpec`:blur 6 / offset 2 / alpha 0.14,令牌层给的值),阴影色取 `Role::Shadow`。
//! **容器色不取 `MaterialLayer::Overlay`**:那是 `SurfaceContainerHigh`,比 A1 的 `SurfaceContainer`
//! 高一档;`MaterialLayer` 只用来确认「弹出层属于 Overlay 语义」,颜色以 A1 为准。
//!
//! ## 分组分隔线
//!
//! 复用 [`crate::app::components::divider::full`](一像素线 + `OutlineVariant`),**不自己画线**。
//! 菜单水平内边距 12 由容器给,所以分隔线自然落在 12 内缩后的宽度上。
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, text};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow, Vector,
};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::divider;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::MenuGeometry;
/// 菜单里的一项。
#[derive(Debug, Clone)]
pub struct MenuItem<'a, M> {
label: &'a str,
leading: Option<IconKind>,
trailing: Option<IconKind>,
on_press: Option<M>,
selected: bool,
state: Option<Interaction>,
}
impl<'a, M> MenuItem<'a, M> {
/// 一个可点的项。
pub fn new(label: &'a str, on_press: M) -> Self {
Self {
label,
leading: None,
trailing: None,
on_press: Some(on_press),
selected: false,
state: None,
}
}
/// 一个禁用的项(无消息 = 点不动,与按钮家族同一口径)。
pub fn disabled(label: &'a str) -> Self {
Self {
label,
leading: None,
trailing: None,
on_press: None,
selected: false,
state: None,
}
}
/// 前置槽位(24)。
pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind);
self
}
/// 后置槽位(24;勾选/快捷键提示用)。
pub fn trailing(mut self, kind: IconKind) -> Self {
self.trailing = Some(kind);
self
}
/// 选中项(`SecondaryContainer` + `OnSecondaryContainer`)。
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
/// 强制视觉态(gallery 状态矩阵取证)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// 菜单里的一行:普通项或分组分隔线。
#[derive(Debug, Clone)]
pub enum Entry<'a, M> {
/// 一个可点/禁用的项。
Item(MenuItem<'a, M>),
/// 分组分隔线(复用 `components::divider`)。
Divider,
}
/// 菜单弹出层。
#[derive(Debug, Clone)]
pub struct Menu<'a, M> {
entries: Vec<Entry<'a, M>>,
width: Length,
}
impl<'a, M> Default for Menu<'a, M> {
fn default() -> Self {
Self::new()
}
}
impl<'a, M> Menu<'a, M> {
/// 空菜单(默认按容器宽度伸展,宽度由调用方定)。
pub fn new() -> Self {
Self {
entries: Vec::new(),
width: Length::Fill,
}
}
/// 追加一项。
pub fn item(mut self, item: MenuItem<'a, M>) -> Self {
self.entries.push(Entry::Item(item));
self
}
/// 追加一条分组分隔线。
pub fn divider(mut self) -> Self {
self.entries.push(Entry::Divider);
self
}
/// 菜单宽度(8.7:不窄于触发元素由调用方保证)。
pub fn width(mut self, width: Length) -> Self {
self.width = width;
self
}
}
/// 一项的颜色(8.5 / 8.6 / 禁用)。
#[derive(Debug, Clone, Copy, PartialEq)]
struct ItemPalette {
/// 项底色;`None` = 透明(不画状态层)。
fill: Option<Color>,
/// 文本色。
ink: Color,
/// 图标色(前置槽位;A1 是 `OnSecondaryContainer`)。
icon: Color,
/// 圆角档位(选中指示器 8)。
shape: ShapeScale,
}
/// 解析一项的颜色:选中优先,其余按五态叠状态层(`interaction::overlay_color`)。
fn item_palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
if state.is_disabled() {
// 禁用:内容 38%(无容器时叠在表面色上;菜单项本身透明)
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
return ItemPalette {
fill: None,
ink,
icon: ink,
shape: ShapeScale::Small,
};
}
if selected {
// 选中项也要跟态走:状态层叠在 SecondaryContainer 上(不透明结果,D27)
let fill = interaction::overlay_color(
ui,
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)
.unwrap_or_else(|| ui.color(Role::SecondaryContainer));
return ItemPalette {
fill: Some(fill),
ink: ui.color(Role::OnSecondaryContainer),
icon: ui.color(Role::OnSecondaryContainer),
shape: ShapeScale::Small,
};
}
ItemPalette {
// 状态层叠在**菜单容器色**上(SurfaceContainer),得到不透明结果(D27)
fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::SurfaceContainer),
ink: ui.color(Role::OnSurface),
icon: ui.color(Role::OnSecondaryContainer),
shape: ShapeScale::Small,
}
}
/// 菜单弹出层(`SurfaceContainer` + L2 阴影)。
pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M> {
let geometry = MenuGeometry::of(ui);
let spec = Component::Menu.spec();
let elevation = spec.elevation;
debug_assert_eq!(
elevation,
Elevation::Level2,
"MenuTokens.ContainerElevation"
);
let shadow_spec = elevation.shadow();
let panel = ui.color(Role::SurfaceContainer);
let shape = geometry.shape;
let mut rows = column![].width(Length::Fill);
for entry in menu.entries {
rows = rows.push(match entry {
Entry::Item(item) => item_row(ui, item, geometry),
// 分组分隔线:复用 `divider::full`(一像素线 + OutlineVariant),不自己画
Entry::Divider => divider::full::<M>(ui),
});
}
container(layout::inset(0.0, geometry.pad_v, rows))
.width(menu.width)
.style(move |_theme| container::Style {
background: Some(Background::Color(panel)),
border: Border {
radius: ui.radius_dp(shape),
..Border::default()
},
shadow: Shadow {
color: Color {
a: shadow_spec.alpha,
..ui.color(Role::Shadow)
},
offset: Vector::new(0.0, shadow_spec.offset_y_px),
blur_radius: shadow_spec.blur_px,
},
..container::Style::default()
})
.into()
}
/// 一行菜单项(用 `button` 承载命中与四态,几何与颜色自绘)。
fn item_row<'a, M: Clone + 'a>(
ui: &'a Ui,
item: MenuItem<'a, M>,
geometry: MenuGeometry,
) -> Element<'a, M> {
let forced = item.state.or(if item.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
// 选中项的背景也要跟态走:禁用时不许留满档 SecondaryContainer
let colors = item_palette(ui, forced.unwrap_or(Interaction::Enabled), item.selected);
let body = ui.style(TypeRole::BodyMedium);
let label = text(item.label)
.size(body.size_px)
.line_height(LineHeight::Absolute(Pixels(body.line_height_px)))
.font(ui.font_of(body))
.color(colors.ink);
let mut row = iced::widget::Row::new()
.align_y(Alignment::Center)
.spacing(geometry.gap);
if let Some(kind) = item.leading {
// Q9:图标必须经固定尺寸容器
row = row.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.icon),
));
}
row = row.push(label);
if let Some(kind) = item.trailing {
// **撑开**:label 与 trailing 之间插一个水平 Fill,trailing 才会被推到行右内边距上
// (M3 里 trailing 是「快捷键 / 子菜单箭头 / 勾」,必须在一列菜单里成一条竖线)。
// 没有 trailing 时**不插**:label 仍在左边,不会被居中或右对齐。
row = row.push(iced::widget::space::horizontal());
row = row.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.icon),
));
}
// 行必须撑满 item 宽度:上面的 Fill 空间要有宽度可撑(Q8 的教训:撑开靠容器,不靠子元素)。
// 顺带把内容垂直居中——`button` 只把内容放在 padding-box 的左上角。
let content = iced::widget::container(row)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center);
let selected = item.selected;
let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let colors = item_palette(ui, state, selected);
button::Style {
background: colors.fill.map(Background::Color),
text_color: colors.ink,
border: Border {
radius: ui.radius_dp(colors.shape),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
};
let inset = ui.space(Space::X1);
let button = button(content)
.width(Length::Fill)
.height(Length::Fixed(geometry.item_height))
.padding(Padding {
top: 0.0,
// 选中指示器水平内缩 4(8.6 / 参考图纸),内容仍落在 12 上:12 = 4 + 8
right: geometry.pad_h - inset,
bottom: 0.0,
left: geometry.pad_h - inset,
})
.style(style);
let element: Element<'a, M> = match item.on_press {
Some(message) => button.on_press(message).into(),
None => button.into(),
};
container(element)
.width(Length::Fill)
.padding(Padding {
top: 0.0,
right: inset,
bottom: 0.0,
left: inset,
})
.into()
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,123 @@
//! menu 的单测(从 `menu.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
fn sample(flavor: SchemeFlavor) -> Element<'static, u8> {
let ui = Box::leak(Box::new(ui(flavor)));
let spec = Menu::new()
.item(MenuItem::new("重命名", 1_u8).leading(IconKind::Folder))
.item(
MenuItem::new("复制实例", 2_u8)
.selected()
.trailing(IconKind::Check),
)
.divider()
.item(MenuItem::disabled("删除").leading(IconKind::Trash));
menu(ui, spec)
}
/// 8.2:容器 `SurfaceContainer` + L2;阴影参数来自高程令牌。
#[test]
fn container_uses_surface_container_and_level2_shadow() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(Component::Menu.spec().elevation, Elevation::Level2);
assert_eq!(
Component::Menu.spec().elevation.surface_role(),
Role::SurfaceContainer,
"L2 的容器角色与 MenuTokens.ContainerColor 一致"
);
let shadow = Elevation::Level2.shadow();
assert!(shadow.is_visible(), "菜单是浮层,必须有可见阴影");
assert_eq!(shadow.blur_px, 6.0);
assert_eq!(shadow.offset_y_px, 2.0);
// 容器色**不是** MaterialLayer::Overlay(那会高一档)
assert_ne!(
ui.layer(unlucky_design::elevation::MaterialLayer::Overlay),
ui.color(Role::SurfaceContainer)
);
}
/// 8.6:选中 = SecondaryContainer + OnSecondaryContainer;禁用内容 38%。
#[test]
fn selected_and_disabled_items_use_the_right_roles() {
let ui = ui(SchemeFlavor::Light);
let selected = item_palette(&ui, Interaction::Enabled, true);
assert_eq!(selected.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(selected.ink, ui.color(Role::OnSecondaryContainer));
assert_eq!(selected.icon, ui.color(Role::OnSecondaryContainer));
// 选中 + hover:状态层叠在 SecondaryContainer 上(不是满档底色)
let hovered = item_palette(&ui, Interaction::Hovered, true);
assert_ne!(hovered.fill, selected.fill);
assert_eq!(
hovered.fill,
interaction::overlay_color(
&ui,
Interaction::Hovered,
Role::OnSecondaryContainer,
Role::SecondaryContainer
)
);
let disabled = item_palette(&ui, Interaction::Disabled, true);
assert_eq!(disabled.fill, None, "禁用项不留满档选中底色");
assert_eq!(
disabled.ink,
interaction::disabled_content_over_role(&ui, Role::OnSurface, false)
);
assert_ne!(disabled.ink, ui.color(Role::OnSurface));
}
/// 五态:hover 8% / pressed 10% / focus 10% 都叠在 SurfaceContainer 上(不透明)。
#[test]
fn item_states_blend_on_the_menu_container() {
let ui = ui(SchemeFlavor::Light);
let rest = item_palette(&ui, Interaction::Enabled, false);
assert_eq!(rest.fill, None, "静息不叠色(菜单底色透出来)");
for state in [
Interaction::Hovered,
Interaction::Pressed,
Interaction::Focused,
] {
let fill = item_palette(&ui, state, false)
.fill
.expect("可交互态必须给底色");
assert_eq!(fill.a, 1.0, "结果必须不透明(D27)");
assert_ne!(Some(fill), rest.fill);
}
}
/// 8.3 / 8.4:项高 48、水平 12(内容)、选中指示器内缩 4 ⇒ 内容仍在 12。
#[test]
fn item_geometry_matches_the_reference_table() {
let ui = ui(SchemeFlavor::Light);
let geometry = MenuGeometry::of(&ui);
assert_eq!(geometry.item_height, 48.0);
assert_eq!(geometry.pad_h, 12.0);
assert_eq!(geometry.pad_v, 8.0);
let inset = ui.space(Space::X1);
assert_eq!(inset, 4.0);
assert_eq!((geometry.pad_h - inset) + inset, 12.0, "内容落在 12 上");
}
/// 三种形态(带槽位 / 选中 / 禁用 / 分组线)都能构建。
#[test]
fn menu_shapes_build() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let _: Element<'static, u8> = sample(flavor);
}
}
+36 -29
View File
@@ -1,35 +1,42 @@
//! **输入类家族(桩)**:由后续 mission 填充(§9 TextField / §20 Search / §11 Slider / §8 Menu)。
//! **输入类家族**(D39 §1 的 `inputs/`):§9 TextField / §20 Search / §11 Slider / §8 Menu。
//!
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/inputs.rs` 也已经在 `gallery/mod.rs` 里声明好,
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
//! 四个组件的共同纪律(与本目录外的契约一一对应):
//!
//! 填充时请照抄按钮家族的分层:
//! - **对消息类型泛型**(`M: Clone`,不 import `crate::app::Message`)——组件库要能脱离业务验收;
//! - **`&'a Ui` 传入**,组件内部不 `Ui::new`(深浅热切靠外部重建整树);
//! - **不持有 `String`**:文本框与搜索条是受控组件(借值 + `on_input`/`on_change` 回传),
//! 滑块也只借一个比例;
//! - **hover/pressed/focus/disabled 一律走 [`super::interaction`]**(α 只有一处定义);
//! - **精确尺寸走 [`super::layout`]**(Q8/Q9 的两个固定解法),图标一律 `layout::fixed`;
//! - **零尺寸字面量**:能取令牌的取令牌,取不到的集中登记在 [`geometry`] 的 G16~G20。
//!
//! - 几何进 `inputs/geometry.rs`(数值从 `ComponentMetrics::SEARCH_BAR` / `Space` / `ShapeScale` 取,
//! 缺令牌登记 G 编号);
//! - 状态层只能走 [`crate::app::components::interaction`](α 一处定义);
//! - 精确尺寸用 [`crate::app::components::layout`](Q8/Q9 的解法),别手写 `.width(x)`;
//! - 受控组件签名收 `impl Fn(String) -> M`(契约 §2),组件内不持有 `String`。
//! ## 文件分工
//!
//! | 文件 | 内容 |
//! |---|---|
//! | [`geometry`] | 内部几何 + G 编号常量(`FieldGeometry` / `SliderGeometry` / `MenuGeometry`) |
//! | [`field`] | [`text_field`](Filled / Outlined) |
//! | [`search`] | [`search_bar`](收起)+ [`search_view`](展开 docked) |
//! | [`slider`] | [`slider`](自绘 `Widget`:轨道 16 + 竖条 4×44) |
//! | [`menu`] | [`menu`](`Menu` + `MenuItem` + 分组分隔线) |
//!
//! ## 与契约 §2 示例签名的关系
//!
//! 契约示例是 `filled(ui, label, on_press)`;本家族的四个组件都比三个参数多(变体 × 五态 ×
//! 前后槽位 × 错误/辅助文本),所以照按钮家族的先例把「部件参数」收进构造器结构体:
//! `text_field(ui, Field::new(value).label("名称"), on_input)`。**泛型纪律一字不改**,
//! 差别只是位置参数变命名参数(按钮家族已登记同一决定,见 `buttons` 模块文档)。
use iced::widget::{container, text};
use iced::Element;
pub mod geometry;
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
mod field;
mod menu;
#[cfg(test)]
mod probe;
mod search;
mod slider;
use crate::theme::Ui;
/// 占位 demo(ASCII 文案,理由同 `selection::demo`)。
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
let style = ui.style(TypeRole::BodySmall);
container(
text("inputs · text field / search / slider / menu — pending")
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
)
.padding(ui.space(Space::X2))
.into()
}
pub use field::{text_field, Field, FieldVariant};
pub use menu::{menu, Menu, MenuItem};
pub use search::{search_bar, search_view, SearchBar, SearchView};
pub use slider::{slider, Slider};
@@ -0,0 +1,347 @@
//! 渲染层几何断言(D30 / M68 的教训:**纯函数对 ≠ 图画对**)。
//!
//! ## 为什么这个文件在 `inputs/` 而不是 `gallery/probe.rs`
//!
//! 像素工具 `gallery::scan` 是 `mod scan;`(**私有**于 `gallery`),我的 probe 拿不到它,
//! 而 `gallery/mod.rs` 不在本 mission 的 scope 里。所以这里自带一份最小的读图器
//! (约 40 行:读 PNG / 取点 / 连通块 / 行覆盖),判据全在本文件——已作为 finding 报给 tower
//! (建议把 `gallery::scan` 提升为 `pub(crate)`,四个家族就能共用一份)。
//!
//! ## 怎么跑(worktree 根目录)
//!
//! ```text
//! # ① 出图:副本工程只把 inputs 段提到首位(组件代码逐字节相同),见 target/m77/sync.py
//! python target/m77/sync.py && (cd /d/tmp/m77-shot && cargo build --no-default-features ...)
//! M77_PART=text ./target/debug/unlucky-launcher-iced.exe --software-renderer --screen=gallery \
//! --screenshot target/m77/text-light.png --screenshot-theme=light
//! # ② 量图
//! ULL_INPUTS_DIR=target/m77 cargo test -p unlucky-app-iced inputs::probe
//! ```
//!
//! 没给 `ULL_INPUTS_DIR` 就**大声跳过**(不静默通过),与 `gallery::probe` 的口径一致。
//!
//! ## 量什么(每条都对着一个「已经可能画错」的形态)
//!
//! | 断言 | 抓的错误 |
//! |---|---|
//! | 文本框高 **56**、宽 **320** | 高度被内容/内边距吃掉(不是 56 的 A 级值) |
//! | Filled 底部指示线 静息 **1.0** / 聚焦 **2.0** 墨量 | 把 2dp 画成 1dp(或反过来) |
//! | Outlined 描边 静息 **1.0** / 聚焦 **2.0** 墨量 | 同上 |
//! | 错误态:标签 / 线 / 辅助文本全 `Error` | 只把线染红、忘了标签与辅助文本 |
//! | 搜索条高 **56**、图标墨迹偏移 **20**(= 16 + viewBox 4) | 手抄 16/20 当图标边、或 pad 与 offset 少加一段 |
//! | docked 面板 宽 **360** / 头 **56** / 分隔线 **1.0 `Outline`** / 体 **240** | 用 `OutlineVariant`、或头部高度与 bar 不一致 |
//! | Slider 0%(竖条贴左)、50%(分界在中央)、100%(无未激活段) | 位移区间取错(0..W 而不是 handle/2..W−handle/2) |
//! | Slider 竖条 **4×44** | 画成圆点或撑满命中区 |
//! | 菜单项 pitch **48**、选中指示器内缩 **4**、容器高 **161** | 项高/内边距抄错 |
//! | 分组分隔线 **1.0** 墨量 | 分隔线变成 2px |
use std::path::{Path, PathBuf};
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use crate::theme::{Families, Ui};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 颜色比对容差(抗锯齿/取整)。
const TOLERANCE: u8 = 10;
type Rgb = [u8; 3];
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
fn rgb(color: iced::Color) -> Rgb {
[
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
]
}
fn matches(pixel: Rgb, expected: Rgb) -> bool {
matches_within(pixel, expected, TOLERANCE)
}
/// 指定容差的同色判断(量**阴影边**时要用更严的容差:阴影叠上去会落在 10 的容差内)。
fn matches_within(pixel: Rgb, expected: Rgb, tolerance: u8) -> bool {
pixel
.iter()
.zip(expected)
.all(|(a, b)| a.abs_diff(b) <= tolerance)
}
/// 一张图的最小访问器(读 PNG / 取点 / 连通块)。
struct Shot {
width: usize,
height: usize,
pixels: Vec<Rgb>,
}
/// 一张图里某颜色的连通块外接框(闭区间)。
#[derive(Debug, Clone, Copy)]
struct BBox {
left: usize,
right: usize,
top: usize,
bottom: usize,
}
impl BBox {
fn width(&self) -> usize {
self.right - self.left + 1
}
fn height(&self) -> usize {
self.bottom - self.top + 1
}
}
impl Shot {
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(),
})
}
fn at(&self, x: usize, y: usize) -> Rgb {
self.pixels[y * self.width + x]
}
fn bbox_of(&self, color: Rgb, min_area: usize) -> 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 {
left: x,
right: x,
top: y,
bottom: y,
};
while let Some((cx, cy)) = stack.pop() {
bbox.left = bbox.left.min(cx);
bbox.right = bbox.right.max(cx);
bbox.top = bbox.top.min(cy);
bbox.bottom = bbox.bottom.max(cy);
for (nx, ny) in [
(cx + 1, cy),
(cx.saturating_sub(1), cy),
(cx, cy + 1),
(cx, cy.saturating_sub(1)),
] {
if nx < self.width
&& ny < self.height
&& !seen[ny * self.width + nx]
&& matches(self.at(nx, ny), color)
{
seen[ny * self.width + nx] = true;
stack.push((nx, ny));
}
}
}
if bbox.width() * bbox.height() >= min_area {
boxes.push(bbox);
}
}
}
boxes
}
fn count_in(&self, color: Rgb, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
let mut total = 0;
for y in y0..y1.min(self.height) {
for x in x0..x1.min(self.width) {
if matches(self.at(x, y), color) {
total += 1;
}
}
}
total
}
}
/// 一行像素相对底色向 `line` 靠拢的比例(0..=1)——1dp 的线在各行上摊开时总和 ≈ 1.0。
fn coverage(pixel: Rgb, surface: Rgb, line: Rgb) -> f32 {
let channel = |i: usize| {
let full = f32::from(line[i]) - f32::from(surface[i]);
if full.abs() < 1.0 {
return 0.0;
}
((f32::from(pixel[i]) - f32::from(surface[i])) / full).clamp(0.0, 1.0)
};
(channel(0) + channel(1) + channel(2)) / 3.0
}
/// 竖直方向上的**线墨量**:`y0..y1` 每行在 x 区间上的平均覆盖之和(1dp ⇒ ≈1.0)。
fn line_ink(
shot: &Shot,
x0: usize,
x1: usize,
y0: usize,
y1: usize,
surface: Rgb,
line: Rgb,
) -> f32 {
let mut total = 0.0;
for y in y0..y1.min(shot.height) {
let mut sum = 0.0;
for x in x0..x1.min(shot.width) {
sum += coverage(shot.at(x, y), surface, line);
}
total += sum / (x1 - x0) as f32;
}
total
}
/// 某一行里「两色之一」的最长连续段 `(start, end)`,允许 ≤`max_gap` 的空隙。
///
/// 用它而不是「本行最左/最右」:深色档的说明文字里有零星像素落在令牌色容差内,
/// 取 min/max 会把轨道量成从 x=24 起(实测)。
fn longest_run(shot: &Shot, y: usize, a: Rgb, b: Rgb, max_gap: usize) -> Option<(usize, usize)> {
let mut best: Option<(usize, usize)> = None;
let mut start: Option<usize> = None;
let mut last: Option<usize> = None;
for x in 0..shot.width {
if matches(shot.at(x, y), a) || matches(shot.at(x, y), b) {
if start.is_none() {
start = Some(x);
}
last = Some(x);
} else if let (Some(from), Some(to)) = (start, last) {
if x - to > max_gap {
if best.is_none_or(|(p, q)| to - from > q - p) {
best = Some((from, to));
}
start = None;
last = None;
}
}
}
if let (Some(from), Some(to)) = (start, last) {
if best.is_none_or(|(p, q)| to - from > q - p) {
best = Some((from, to));
}
}
best
}
/// `bbox` 范围内「整行至少 `min_pixels` 个同色像素」的行区间(闭区间)。
///
/// 菜单的阴影会把 bbox 外扩 ~5px,而阴影行绝不会有 100 个容器色像素——用它把阴影滤掉。
fn row_extent(shot: &Shot, bbox: &BBox, color: Rgb, min_pixels: usize) -> (usize, usize) {
let mut first = None;
let mut last = None;
for y in bbox.top..=bbox.bottom {
let mut count = 0;
for x in bbox.left..=bbox.right {
// 容差收到 2:**阴影边**会落在默认容差 10 内,会把容器量高 ~5px
if matches_within(shot.at(x, y), color, 2) {
count += 1;
}
}
if count >= min_pixels {
first.get_or_insert(y);
last = Some(y);
}
}
(
first.expect("行内没有足够的同色像素"),
last.expect("行内没有足够的同色像素"),
)
}
/// `pixel` 更接近 `fill` 还是 `surface`(含平局都算 `fill`)。
fn nearer(pixel: Rgb, fill: Rgb, surface: Rgb) -> bool {
let dist = |a: Rgb, b: Rgb| -> u32 {
a.iter()
.zip(b)
.map(|(x, y)| (i32::from(*x) - i32::from(y)).unsigned_abs())
.sum()
};
dist(pixel, fill) <= dist(pixel, surface)
}
/// 截图目录(`ULL_INPUTS_DIR`);没给就返回 `None`(调用方大声跳过)。
fn shot_dir() -> Option<PathBuf> {
let raw = std::env::var_os("ULL_INPUTS_DIR")?;
let path = PathBuf::from(raw);
if path.is_absolute() || path.is_dir() {
return Some(path);
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Some(root.join(path))
}
/// 取一张图;缺图直接 panic(给了目录就说明这轮要认真量)。
fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot {
let slug = match flavor {
SchemeFlavor::Light => "light",
SchemeFlavor::Dark => "dark",
};
let path = dir.join(format!("{part}-{slug}.png"));
let shot = Shot::load(&path).unwrap_or_else(|error| panic!("{error}"));
assert_eq!(
(shot.width, shot.height),
(1180, 760),
"验收图必须是 D30 的 1180×760 @100% DPI"
);
shot
}
/// 一个矩阵格在某列的竖直「实色高度」:从 `bbox.top-4` 往上/下走到不再接近 `fill` 为止。
fn vertical_extent(
shot: &Shot,
bbox: &BBox,
fill: Rgb,
surface: Rgb,
column: usize,
) -> (usize, usize) {
let mut top = bbox.top;
while top > 0 && nearer(shot.at(column, top - 1), fill, surface) {
top -= 1;
}
let mut bottom = bbox.bottom;
while bottom + 1 < shot.height && nearer(shot.at(column, bottom + 1), fill, surface) {
bottom += 1;
}
(top, bottom)
}
macro_rules! require_dir {
() => {
match shot_dir() {
Some(dir) => dir,
None => {
eprintln!(
"[inputs::probe] SKIPPED:未设置 ULL_INPUTS_DIR。这是**渲染层**断言,\
需要先按本文件模块文档出 8 张图(target/m77/*.png)。"
);
return;
}
}
};
}
mod menu;
mod search;
mod slider;
mod text;
@@ -0,0 +1,102 @@
//! M77 probe · menu(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
/// 8.3 / 8.4 / 8.6:菜单容器高 161、项 pitch 48、选中指示器内缩 4、分组分隔线 1dp。
#[test]
fn menu_container_items_and_divider() {
let dir = require_dir!();
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let shot = shot(&dir, "menu", flavor);
let ui = ui(flavor);
let container = rgb(ui.color(Role::SurfaceContainer));
let selected = rgb(ui.color(Role::SecondaryContainer));
let divider = rgb(ui.color(Role::OutlineVariant));
// 右侧那只 = 「展开态」菜单。容器色块可能被**分组分隔线 / 选中项**切成多块,
// 所以先把 x>290 的块并成一个外接框,再逐行量容器色(容差 2,滤掉阴影外扩的 ~5px)。
let menus = shot.bbox_of(container, 2000);
let right: Vec<BBox> = menus.iter().filter(|b| b.left > 290).copied().collect();
assert!(!right.is_empty(), "找不到右侧的「展开态」菜单");
let mut open = right[0];
for b in right.iter().skip(1) {
open.left = open.left.min(b.left);
open.top = open.top.min(b.top);
open.right = open.right.max(b.right);
open.bottom = open.bottom.max(b.bottom);
}
let (top, bottom) = row_extent(&shot, &open, container, 1);
open.top = top;
open.bottom = bottom;
// 布局高 **161** = 8 + 48×3 + 1 + 8;抗锯齿会吃掉上下各 1 行 ⇒ 实测 160
assert!(
(159..=162).contains(&open.height()),
"8.3/8.4 菜单高应 161(= 8 + 48×3 + 1 + 8):实测 {}",
open.height()
);
// 选中项:SecondaryContainer,高 48、左右各内缩 4
let selected_box = shot
.bbox_of(selected, 2000)
.into_iter()
.filter(|b| b.height() >= 44 && b.height() <= 50)
.max_by_key(|b| b.width())
.expect("找不到选中项的 SecondaryContainer");
assert!(
(46..=48).contains(&selected_box.height()),
"8.3 项高应 48:实测 {}",
selected_box.height()
);
// 容器左右缘:在第一项那一行按严格同色扫(bbox 会被阴影外扩 ~2px,不能用)
let scan_y = open.top + 10;
let row: Vec<usize> = (open.left..=open.right)
.filter(|x| matches_within(shot.at(*x, scan_y), container, 2))
.collect();
let (menu_left, menu_right) = (*row.first().unwrap(), *row.last().unwrap());
assert_eq!(selected_box.left - menu_left, 4, "8.6 选中指示器左内缩应 4");
assert_eq!(
menu_right - selected_box.right,
4,
"8.6 选中指示器右内缩应 4"
);
// 分组分隔线:1dp OutlineVariant 墨量 ≈1.0(位置在选中项与最后一项之间)
let ink = line_ink(
&shot,
open.left + 20,
open.right - 20,
selected_box.bottom + 1,
selected_box.bottom + 6,
container,
divider,
);
assert!(
(0.6..=1.4).contains(&ink),
"8.x 分组分隔线应 1dp `OutlineVariant`:实测墨量 {ink:.2}"
);
// trailing 槽位必须靠到**行右内边距**上(视觉 r5 P1:早先它紧贴 label 右边)。
//
// 位置公式:item 右缘 = menu_right − 4(选中指示器内缩);button 内容右缘 = item 右缘 −
// (pad_h − inset) = menu_right − 12;trailing 图标盒 24 的**墨迹**再内缩 k(`Check` 字形
// 在 24 viewBox 里右边界 ≈ 5,与 leading 图标「可视边 12、墨迹 16」同一口径)。
// 所以墨迹右缘 ≈ menu_right − 17。变异(去掉撑开的 Fill)会让它贴着 label ⇒ 差 ~120。
let trailing_ink_color = rgb(ui.color(Role::OnSecondaryContainer));
let mut trailing_right = 0usize;
for y in selected_box.top..=selected_box.bottom {
for x in selected_box.left..=selected_box.right {
if matches(shot.at(x, y), trailing_ink_color) {
trailing_right = trailing_right.max(x);
}
}
}
let trailing_offset = menu_right - trailing_right;
assert!(
(16..=18).contains(&trailing_offset),
"8.6/trailing:带 trailing 的项,其 trailing 墨迹右缘应在 item 右内边距上\
(menu_right − 17,允许 ±1):实测 menu_right={menu_right}、墨迹右缘={trailing_right}\
⇒ 偏 {trailing_offset}(贴 label 时会是 ~120)"
);
}
}
@@ -0,0 +1,80 @@
//! M77 probe · search(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
/// 20.1 / 20.4 / 20.6 / 20.8 / 20.9:条 56、图标墨迹偏移 20(= 16 + viewBox 4)、
/// docked 面板 360 / 头 56 / 分隔线 1.0 `Outline` / 体 240。
#[test]
fn search_bar_and_docked_panel_geometry() {
let dir = require_dir!();
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let shot = shot(&dir, "search", flavor);
let ui = ui(flavor);
let bar_color = rgb(ui.color(Role::SurfaceContainerHigh));
let ink = rgb(ui.color(Role::OnSurface));
let bars = shot.bbox_of(bar_color, 2000);
let bar = bars
.iter()
.filter(|b| b.height() <= 60 && b.width() >= 360)
.min_by_key(|b| b.top)
.copied()
.expect("找不到搜索条(高 ≤60、宽 ≥360)");
assert_eq!(bar.height(), 56, "20.1 搜索条高必须 56");
assert!(bar.width() >= 360, "20.2 搜索条宽 ≥ 360");
// 20.4:图标墨迹左缘 = pad 12 + offset 4 + viewBox 内 ≈4 = 20(±1)
let inner = (bar.top + 12)..(bar.bottom - 12);
let mut left_ink = usize::MAX;
for y in inner.clone() {
for x in bar.left..bar.right {
if matches(shot.at(x, y), ink) {
left_ink = left_ink.min(x);
}
}
}
let offset = left_ink - bar.left;
assert!(
(19..=21).contains(&offset),
"20.4 前置图标墨迹偏移应 20(= 12 + 4 + viewBox 4):实测 {offset}"
);
// 20.6 / 20.9:docked 面板 360 宽,头 56 + 1px 分隔线 + 体 240
// (头与体被那条线切成**两个同色块**,所以按 top 排序取前两块)
let mut parts = bars
.iter()
.filter(|b| b.width() == 360)
.copied()
.collect::<Vec<_>>();
parts.sort_by_key(|b| b.top);
assert_eq!(parts.len(), 2, "docked 面板 = 头部 + 结果区两块");
let (header, body) = (parts[0], parts[1]);
assert!(
(55..=56).contains(&header.height()),
"20.6 面板头部高 56(与 bar 同高;顶部圆角会吃掉 1 行抗锯齿,实测 {})",
header.height()
);
assert_eq!(body.height(), 240, "20.9 结果区最小高 240");
assert!(
(2..=3).contains(&(body.top - header.bottom)),
"20.8 头与体之间正好 1dp(CPU 档摊成 2 行 ⇒ 隔 2~3 行):实测 {}",
body.top - header.bottom
);
let panel = header;
// 20.8:分隔线是 `Outline`(不是 OutlineVariant),1dp 墨量 ≈1.0
let divider_ink = line_ink(
&shot,
panel.left + 40,
panel.right - 40,
panel.top + 50,
panel.top + 62,
bar_color,
rgb(ui.color(Role::Outline)),
);
assert!(
(0.7..=1.3).contains(&divider_ink),
"20.8 头部分隔线应 1dp `Outline`:实测墨量 {divider_ink:.2}"
);
}
}
@@ -0,0 +1,96 @@
//! M77 probe · slider(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
/// 11.1~11.6:竖条 4×44、0% 贴左、50% 分界在中央、100% 无未激活段(thumb 永不越界)。
#[test]
fn slider_thumb_positions_at_zero_half_and_full() {
let dir = require_dir!();
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let shot = shot(&dir, "slider", flavor);
let ui = ui(flavor);
let active = rgb(ui.color(Role::Primary));
let inactive = rgb(ui.color(Role::SecondaryContainer));
// 三条「值行」= 宽 ≥400 的轨道(0% / 50% / 100%),按 y 排序
let mut rows: Vec<(usize, usize, usize)> = Vec::new();
for y in 0..shot.height {
if let Some((left, right)) = longest_run(&shot, y, active, inactive, 20) {
// 先按「跨度 ≥300」收行(把抗锯齿行也纳进来),band 建好后再按宽度筛。
if right - left + 1 >= 300 {
rows.push((y, left, right));
}
}
}
// 合并相邻的 y(同一条轨道占 16 行),并把每行的端点累积成 min/max——
// 首行/末行都是抗锯齿行,只取它们会把轨道量窄 2~3px。
let mut bands: Vec<(usize, usize, usize, usize)> = Vec::new();
for (y, left, right) in rows {
match bands.last_mut() {
Some(last) if y - last.1 <= 3 => {
last.1 = y;
last.2 = last.2.min(left);
last.3 = last.3.max(right);
}
_ => bands.push((y, y, left, right)),
}
}
// 值行是 448 宽(`Space::X8 × 14`);五态行 5 条轨道会被并成 ~663 的带,按宽度筛掉
let bands: Vec<(usize, usize, usize, usize)> = bands
.into_iter()
.filter(|(_, _, left, right)| (440..=470).contains(&(right - left + 1)))
.collect();
assert_eq!(
bands.len(),
4,
"值行应有 4 条(0% / 50% / 100% / 离散):实测 {}",
bands.len()
);
for (label, band) in ["0%", "50%", "100%"].iter().zip(bands.iter()) {
let (top, bottom, band_left, band_right) = *band;
let mid = (top + bottom) / 2; // 轨道中心行
let (left, right) = (band_left, band_right);
let width = right - left + 1;
let handle_centre = left + 2 + (width - 4) / 2; // 位移区间 = handle/2 .. W-handle/2
let boundary = (left..=right)
.find(|x| matches(shot.at(*x, mid), inactive))
.unwrap_or(right + 1);
match *label {
"0%" => assert!(
boundary <= left + 4,
"11.x 0% 时未激活轨必须从竖条(4 宽)之后立刻开始:实测分界 {}",
boundary - left
),
"50%" => assert!(
boundary.abs_diff(handle_centre) <= 2,
"11.x 50% 的轨道分界应在正中({handle_centre}):实测 {boundary}"
),
_ => {
let tail = shot.count_in(inactive, left, right + 1, mid, mid + 1);
assert_eq!(tail, 0, "11.x 100% 时不该有未激活段:实测 {tail}px");
}
}
// 竖条必须落在轨道内(0% / 100% 的最狠情形)
let handles: Vec<BBox> = shot
.bbox_of(active, 100)
.into_iter()
.filter(|b| b.width() == 4 && b.height() >= 40)
.collect();
assert!(!handles.is_empty(), "11.2 找不到 4×44 的竖条");
for handle in handles {
assert!(
handle.left >= left && handle.right <= right,
"11.4 竖条越界:{handle:?} 不在轨道 {left}..{right} 内"
);
assert!(
(43..=44).contains(&handle.height()),
"11.2 竖条高必须 44(抗锯齿会吃掉 1 行,实测 {})",
handle.height()
);
assert_eq!(handle.width(), 4, "11.2 竖条宽必须 4");
}
}
}
}
@@ -0,0 +1,205 @@
//! M77 probe · text(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
/// 9.1 / 9.2:Filled 高 56、宽 320;Filled 指示线 静息 1.0 / 聚焦 2.0 墨量。
#[test]
fn text_field_height_width_and_indicator_thickness() {
let dir = require_dir!();
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let shot = shot(&dir, "text", flavor);
let ui = ui(flavor);
let surface = rgb(ui.color(Role::Surface));
let fill = rgb(ui.color(Role::SurfaceContainerHighest));
let line = rgb(ui.color(Role::OnSurfaceVariant));
let primary = rgb(ui.color(Role::Primary));
let mut boxes = shot.bbox_of(fill, 400);
boxes.sort_by_key(|b| (b.top, b.left));
let wide: Vec<&BBox> = boxes.iter().filter(|b| b.width() >= 280).collect();
// 320 宽的有两个 Filled(空 / 有值);第三格是 Outlined 错误态——它没有容器色,
// 所以这里只要求 ≥2,错误态由 `outlined_stroke_and_error_paint` 单独验。
assert!(
wide.len() >= 2,
"{flavor:?}: 至少要有 2 个 320 宽的 Filled 文本字段(实测 {})",
wide.len()
);
// 320 宽那三个 = 「空 / 有值 / 错误」三格;高度必须精确 56
for bbox in wide.iter().take(2) {
assert!(
(280..=320).contains(&bbox.width()),
"9.2 最小宽 280:实测 {}(320 宽格)",
bbox.width()
);
let column = bbox.right - 20;
let (top, bottom) = vertical_extent(&shot, bbox, fill, surface, column);
assert_eq!(
bottom - top + 1,
56,
"9.1 高度必须精确 56(带浮动标签):实测 {flavor:?} @x={column}"
);
}
// 矩阵第一行(Filled 五态)=最靠上的 5 个 160 宽格:列序 = Interaction::ALL
// 五态里 disabled 的容器是 `OnSurface@12%`(另一种颜色),所以这里只有 4 块:
// enabled / hovered / pressed / focused —— 列序与 `Interaction::ALL` 一致。
let row: Vec<&BBox> = boxes
.iter()
.filter(|b| (140..=170).contains(&b.width()))
.take(4)
.collect();
assert_eq!(row.len(), 4, "{flavor:?}: Filled 矩阵应有 4 个同色格");
let enabled = row[0];
let focused = row[3];
// 静息指示线 1dp:墨量 ≈ 1.0
let enabled_ink = line_ink(
&shot,
enabled.left + 8,
enabled.right - 8,
enabled.bottom,
enabled.bottom + 4,
fill,
line,
);
assert!(
(0.6..=1.4).contains(&enabled_ink),
"9.4 静息指示线应 1dp:实测墨量 {enabled_ink:.2}"
);
// 聚焦指示线 2dp:墨量 ≈ 2.0(画成 1dp 会掉到 ~1.0)
let focused_ink = line_ink(
&shot,
focused.left + 8,
focused.right - 8,
focused.bottom,
focused.bottom + 5,
fill,
primary,
);
assert!(
(1.5..=2.5).contains(&focused_ink),
"9.4 聚焦指示线应 2dp:实测墨量 {focused_ink:.2}(1dp 会掉到 ≈1.0)"
);
assert!(
focused_ink > enabled_ink + 0.5,
"聚焦必须比静息粗一档({focused_ink:.2} vs {enabled_ink:.2})"
);
}
}
/// 9.4 / 9.7:Outlined 描边 静息 1dp / 聚焦 2dp;错误态线/标签/辅助文本全 `Error`。
///
/// 量法:**在某一列的竖线上扫**(不是找同色块)——深色档里 hover 描边的抗锯齿像素也会落进
/// `Outline` 的容差里,按「最上面的同色块」取会取错行(实测 dark 取到了 hover 那一行)。
#[test]
fn outlined_stroke_and_error_paint() {
let dir = require_dir!();
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let shot = shot(&dir, "text", flavor);
let ui = ui(flavor);
let surface = rgb(ui.color(Role::Surface));
let fill = rgb(ui.color(Role::SurfaceContainerHighest));
let outline = rgb(ui.color(Role::Outline));
let primary = rgb(ui.color(Role::Primary));
let error = rgb(ui.color(Role::Error));
// 矩阵第一列(enabled)的左缘与行距:列宽 160 + 间距 8 = 168
let left = shot
.bbox_of(fill, 400)
.into_iter()
.filter(|b| (140..=170).contains(&b.width()))
.min_by_key(|b| (b.top, b.left))
.expect("找不到 Filled 矩阵格")
.left;
let filled_bottom = shot
.bbox_of(fill, 400)
.into_iter()
.filter(|b| (140..=170).contains(&b.width()))
.min_by_key(|b| (b.top, b.left))
.expect("找不到 Filled 矩阵格")
.bottom;
let column = |index: usize| left + index * 168 + 120;
// 静息(第 1 列)描边 1dp:在竖线上找第一处比底色更接近 `Outline` 的像素
let rest_y = ((filled_bottom + 8)..shot.height)
.find(|y| nearer(shot.at(column(0), *y), outline, surface))
.expect("竖线上找不到 Outlined 静息描边");
let rest_ink = line_ink(
&shot,
column(0) - 40,
column(0) + 40,
rest_y.saturating_sub(2),
rest_y + 3,
surface,
outline,
);
// 描边的墨量随**亚像素相位**在 1.0~1.67 之间浮动(同一 1dp:整行对齐 = 1.0,
// 落在半像素上 = 1 行满 + 1 行 0.67)。所以静息只做宽松下限,**宽度差异由比值判**。
assert!(
(0.8..=1.9).contains(&rest_ink),
"9.4 Outlined 静息描边应 1dp(实测墨量 {rest_ink:.2};2dp 会到 2.0)"
);
// 聚焦(第 4 列)描边 2dp
let focus_y = ((filled_bottom + 8)..shot.height)
.find(|y| nearer(shot.at(column(3), *y), primary, surface))
.expect("竖线上找不到 Outlined 聚焦描边");
let focus_ink = line_ink(
&shot,
column(3) - 40,
column(3) + 40,
focus_y.saturating_sub(2),
focus_y + 4,
surface,
primary,
);
assert!(
(1.8..=2.3).contains(&focus_ink),
"9.4 Outlined 聚焦描边应 2dp(实测墨量 {focus_ink:.2};1dp 会掉到 1.67)"
);
let ratio = focus_ink / rest_ink;
assert!(
(1.6..=2.5).contains(&ratio),
"聚焦必须正好粗一倍(2dp/1dp):实测比值 {ratio:.2}({focus_ink:.2} vs {rest_ink:.2})"
);
// 9.7:错误格——描边 2dp + 标签(框内上带)+ 辅助文本(框下)全 `Error`
let error_box = shot
.bbox_of(error, 2000)
.into_iter()
.filter(|b| b.width() >= 280)
.max_by_key(|b| b.width())
.expect("找不到错误态字段");
assert!(
error_box.height() >= 50,
"错误格应是一整个 320 宽的字段(实测 {}×{})",
error_box.width(),
error_box.height()
);
let label_ink = shot.count_in(
error,
error_box.left,
error_box.right + 1,
error_box.top + 2,
error_box.top + 22,
);
let supporting_ink = shot.count_in(
error,
error_box.left,
error_box.right + 1,
error_box.bottom + 1,
error_box.bottom + 30,
);
assert!(
label_ink > 20,
"9.7 错误标签没变成 Error(框内上带墨迹 {label_ink}px)"
);
assert!(
supporting_ink > 20,
"9.7 错误辅助文本没变成 Error(框下墨迹 {supporting_ink}px)"
);
}
}
@@ -0,0 +1,353 @@
//! 搜索(`research/15` §20):官方是**两个组件**——[`search_bar`](收起)+ [`search_view`](展开)。
//!
//! ## 参考值(`[源]` = A1;本机 `material3-android:1.4.0` 的 `SearchBarTokens` / `SearchBar.kt`)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 20.1 | 高 + 圆角 | 高 **56**、圆角 **full = 28**(药丸,不是圆角矩形) | `LM.search_bar_height` + `Component::Search.spec().shape` |
//! | 20.2 | 宽 | min **360** / max **720**(G20:iced 无 min-width,只施加 max) | [`SEARCH_MAX_WIDTH`](super::geometry::SEARCH_MAX_WIDTH) |
//! | 20.3 | 容器色 / 高程 | `SurfaceContainerHigh` + **L0**(token 写 L3,默认值是 L0,按观感取 L0) | `Role::SurfaceContainerHigh` |
//! | 20.4 | 图标位 | leading 24/`OnSurface`、trailing 24/`OnSurfaceVariant`;**可视边 16 = pad 12 + offset 4** | `ComponentMetrics::SEARCH_BAR` |
//! | 20.5 | 文本 / 占位 / 聚焦 | 输入 `BodyLarge`+`OnSurface`;占位 `OnSurfaceVariant`;聚焦 `Secondary` 1px 指示 | `TypeRole` / `Role::Secondary` |
//! | 20.6 | docked 面板 | 头部 **56**、形状 `CornerExtraLarge` = **28** | `ShapeScale::ExtraLarge` |
//! | 20.8 | 头部 / 结果区分隔 | **1px `Outline`**(**不是** `OutlineVariant`!) | 见下 |
//! | 20.9 | 面板尺寸 | 顶距 8、最小高 **240**、docked 宽 **360** | §20B |
//!
//! ## 三处实现口径
//!
//! 1. **图标可视边一律 `pad + icon_offset` 求和**(`SEARCH_BAR.pad_start = 12` + `icon_offset = 4` = 16)。
//! M69 把这两个值钉成不变量,本文件**不写 16、也不写 12/4**——只做加法。
//! 2. `20.8` 的分隔线用 `Role::Outline`,而 `components::divider` 给的是 `OutlineVariant`
//! (那是正文分隔线的角色)。两个角色在 A1 里明确不同,所以这里**不复用** `divider`,
//! 只按一像素线(`stroke::HAIRLINE`)画一条 —— 复用会把 20.8 判成「报 OutlineVariant 即错」。
//! 3. bar 的 hover / pressed / disabled 在 `SearchBarTokens` 里**没有** token。
//! hover/pressed 按通用状态层(`interaction::overlay_color`,8%/10%),disabled 按令牌的
//! 12%/38%,都登记为 [估](D14 / D15);焦点指示用 A1 的 `FocusIndicatorColor = Secondary`。
use iced::widget::text::LineHeight;
use iced::widget::{column, container, row, text, text_input};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels};
use unlucky_design::color::Role;
use unlucky_design::component_map::{Component, ComponentMetrics};
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::{Ui, STROKE_PX};
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::{SEARCH_MAX_WIDTH, SEARCH_MIN_WIDTH, SEARCH_VIEW_MIN_HEIGHT};
/// 搜索条(收起形态)。
///
/// 状态矩阵用 [`SearchBar::state`] 强制;运行期五态由 `Tracker`/`text_input` 的真实事件驱动。
#[derive(Debug, Clone)]
pub struct SearchBar<'a> {
value: &'a str,
placeholder: &'a str,
trailing: Option<IconKind>,
state: Option<Interaction>,
width: Length,
}
impl<'a> SearchBar<'a> {
/// 一条收起态搜索条(默认占位文案由页面给,组件内不拼中文)。
pub fn new(value: &'a str) -> Self {
Self {
value,
placeholder: "",
trailing: Some(IconKind::Filter),
state: None,
width: Length::Fill,
}
}
/// 占位文案。
pub fn placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = placeholder;
self
}
/// 强制视觉态(gallery 取证)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 定宽(默认 `Fill`)。
pub fn width(mut self, width: Length) -> Self {
self.width = width;
self
}
}
/// 搜索条内的一行内容(bar 与 view 头部共用,保证「展开时头部不跳」——20.6)。
#[allow(clippy::too_many_arguments)]
fn bar_content<'a, M: Clone + 'a>(
ui: &'a Ui,
value: &'a str,
placeholder: &'a str,
trailing: Option<IconKind>,
state: Interaction,
on_input: impl Fn(String) -> M + 'a,
) -> Element<'a, M> {
// 20.4:可视边 = pad + offset(**求和**,不抄 16)
let bar: ComponentMetrics = Component::Search.metrics().expect("Search 有组件级度量");
// 20.4:图标**可视边** = pad + offset(M69 不变量)。这里反解出偏移,两侧共用同一个量。
let icon_edge = bar.pad_start + bar.icon_offset;
let icon_offset = icon_edge - bar.pad_start;
let ink = if state.is_disabled() {
interaction::disabled_content_over_role(ui, Role::OnSurface, true)
} else {
ui.color(Role::OnSurface)
};
let subtle = if state.is_disabled() {
ink
} else {
ui.color(Role::OnSurfaceVariant)
};
let body_style = ui.style(TypeRole::BodyLarge);
let input = text_input(placeholder, value)
.on_input_maybe(if state.is_disabled() {
None
} else {
Some(on_input)
})
.size(body_style.size_px)
.line_height(LineHeight::Absolute(Pixels(body_style.line_height_px)))
.font(ui.font_of(body_style))
.padding(Padding::default())
.width(Length::Fill)
.style(move |_theme, _status| text_input::Style {
background: Background::Color(Color::TRANSPARENT),
border: Border::default(),
icon: subtle,
placeholder: subtle,
value: ink,
selection: ink,
});
// 前置图标:左边 = pad(容器内边距 12)+ offset(4)= 16。两层分别来自令牌的两个字段。
let leading = container(layout::fixed(
bar.icon,
bar.icon,
icons::view::<M>(IconKind::Search, ink),
))
.padding(Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: icon_offset,
});
let mut contents = row![leading, input]
.align_y(Alignment::Center)
.spacing(bar.gap);
if let Some(kind) = trailing {
// 尾随图标:右边 = pad_end + offset(镜像同式),所以右缘也在 16 上
let trailing = container(layout::fixed(
bar.icon,
bar.icon,
icons::view::<M>(kind, subtle),
))
.padding(Padding {
top: 0.0,
right: icon_offset,
bottom: 0.0,
left: 0.0,
});
contents = contents.push(trailing);
}
container(contents)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding {
top: 0.0,
right: bar.pad_end,
bottom: 0.0,
left: bar.pad_start,
})
.into()
}
/// 收起态搜索条(56 高、药丸、`SurfaceContainerHigh`、无可见阴影)。
pub fn search_bar<'a, M: Clone + 'a>(
ui: &'a Ui,
bar: SearchBar<'a>,
on_input: impl Fn(String) -> M + 'a,
) -> Element<'a, M> {
let state = bar.state.unwrap_or(Interaction::Enabled);
let component = Component::Search.spec();
let height = ui.metrics().search_bar_height;
let background = if state.is_disabled() {
interaction::disabled_colors(ui, Role::Surface).0
} else {
let role = Role::SurfaceContainerHigh;
interaction::overlay_color(ui, state, Role::OnSurface, role).unwrap_or(ui.color(role))
};
// 20.5:聚焦指示 = 1px `Secondary`(A1 的 token 存在但 Compose 未消费 → 标 [规格])
let focus_ring = matches!(state, Interaction::Focused);
let border = Border {
color: if focus_ring {
ui.color(Role::Secondary)
} else {
Color::TRANSPARENT
},
width: if focus_ring { STROKE_PX } else { 0.0 },
radius: ui.radius(component.shape, height),
};
container(bar_content(
ui,
bar.value,
bar.placeholder,
bar.trailing,
state,
on_input,
))
.width(bar.width)
// 20.2:max **720**(`SearchBarMaxWidth`)。min 360 无法在 iced 表达(G20),由页面保证列宽。
.max_width(SEARCH_MAX_WIDTH)
.height(Length::Fixed(height))
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border,
..container::Style::default()
})
.into()
}
/// 展开态搜索面板(docked 形态:桌面 1180 窗口永远用它,`20C` 的结论)。
///
/// 形状:头部 56(半径 28)+ 1px `Outline` 分隔 + 结果区(最小 240)。
#[derive(Debug, Clone)]
pub struct SearchView<'a> {
value: &'a str,
placeholder: &'a str,
empty: &'a str,
results: &'a [&'a str],
width: f32,
}
impl<'a> SearchView<'a> {
/// 一个展开面板(`empty` = 空结果文案,由页面给 i18n 文本)。
pub fn new(value: &'a str, results: &'a [&'a str], empty: &'a str) -> Self {
Self {
value,
placeholder: "",
empty,
results,
width: SEARCH_MIN_WIDTH,
}
}
/// 占位文案。
pub fn placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = placeholder;
self
}
}
/// 展开态搜索面板。
pub fn search_view<'a, M: Clone + 'a>(
ui: &'a Ui,
view: SearchView<'a>,
on_input: impl Fn(String) -> M + 'a,
) -> Element<'a, M> {
let panel = ui.color(Role::SurfaceContainerHigh);
let header_height = ui.metrics().search_bar_height;
let body_style = ui.style(TypeRole::BodyMedium);
// 20.8:头部/结果区分隔是 **`Outline`**(正文分隔线才是 OutlineVariant)
let divider = container(iced::widget::space::horizontal())
.width(Length::Fill)
.height(Length::Fixed(STROKE_PX))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::Outline))),
..container::Style::default()
});
let mut rows = column![].width(Length::Fill).spacing(0);
for result in view.results {
rows = rows.push(
container(
text(*result)
.size(body_style.size_px)
.font(ui.font_of(body_style))
.color(ui.color(Role::OnSurface)),
)
.width(Length::Fill)
.padding(Padding {
top: ui.space(Space::X2),
right: ui.space(Space::X4),
bottom: ui.space(Space::X2),
left: ui.space(Space::X4),
}),
);
}
if view.results.is_empty() {
let small = ui.style(TypeRole::BodySmall);
rows = rows.push(
container(
text(view.empty)
.size(small.size_px)
.font(ui.font_of(small))
.color(ui.color(Role::OnSurfaceVariant)),
)
.width(Length::Fill)
.padding(Padding {
top: ui.space(Space::X4),
right: ui.space(Space::X4),
bottom: ui.space(Space::X4),
left: ui.space(Space::X4),
}),
);
}
let body = container(rows)
.width(Length::Fill)
.height(Length::Fixed(SEARCH_VIEW_MIN_HEIGHT))
.align_y(Alignment::Start);
let panel_content = column![
container(bar_content(
ui,
view.value,
view.placeholder,
Some(IconKind::Close),
Interaction::Enabled,
on_input,
))
.width(Length::Fill)
.height(Length::Fixed(header_height)),
divider,
body,
];
container(panel_content)
.width(Length::Fixed(view.width))
.style(move |_theme| container::Style {
background: Some(Background::Color(panel)),
// 20.6:docked 面板 = CornerExtraLarge(28);bar ⇄ view 半径不变(§20C)
border: Border {
radius: ui.radius_dp(ShapeScale::ExtraLarge),
..Border::default()
},
..container::Style::default()
})
.into()
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,93 @@
//! search 的单测(从 `search.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
fn leaked(flavor: SchemeFlavor) -> &'static Ui {
Box::leak(Box::new(ui(flavor)))
}
/// **M69 不变量**:图标可视边 = pad + offset(12 + 4 = 16),且本实现只做加法。
#[test]
fn icon_edge_is_pad_plus_offset_not_a_literal() {
let bar = Component::Search.metrics().expect("Search 有组件级度量");
assert_eq!(bar.pad_start, 12.0, "M69 钉住的 pad_start");
assert_eq!(bar.icon_offset, 4.0, "M69 钉住的 icon_offset");
assert_eq!(bar.pad_start + bar.icon_offset, 16.0);
assert_eq!(bar.icon, 24.0, "SearchBarTokens 图标 24");
// 文本从 pad(12)起:图标(16..40)+ gap 8 → 48 是文字的实际起点
assert_eq!(
bar.pad_start + bar.icon_offset + bar.icon + bar.gap,
48.0,
"图标槽 + 间距后文字起点 48"
);
}
/// 20.1:高 56、圆角 full = 28(药丸);条高来自 LM,不重复定义。
#[test]
fn bar_height_and_radius_come_from_tokens() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(ui.metrics().search_bar_height, 56.0);
assert_eq!(
ShapeScale::Full.radius_px(ui.metrics().search_bar_height),
28.0,
"SearchBarCornerRadius = InputFieldHeight / 2"
);
assert_eq!(Component::Search.spec().shape, ShapeScale::Full);
// 高程按观感取 L0(无可见阴影)
assert_eq!(
Component::Search.spec().elevation,
unlucky_design::elevation::Elevation::Level0
);
}
/// 20.6 / 20.9:面板头部 56、形状 ExtraLarge(28)、最小高 240、docked 宽 360。
#[test]
fn docked_panel_geometry() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(ShapeScale::ExtraLarge.dp(), 28.0, "DockedContainerShape");
assert_eq!(
SEARCH_VIEW_MIN_HEIGHT, 240.0,
"DockedExpandedTableMinHeight"
);
assert_eq!(SEARCH_MIN_WIDTH, 360.0, "SearchBarMinWidth = docked 面板宽");
assert_eq!(ui.metrics().search_bar_height, 56.0, "头部与 bar 同高");
}
/// bar 与 view 都能构建(含禁用态:输入被摘掉)。
#[test]
fn both_forms_build() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
for state in Interaction::ALL {
let ui = leaked(flavor);
let bar = SearchBar::new("").placeholder("搜索").state(state);
let _: Element<'_, u8> = search_bar(ui, bar, |_| 0_u8);
let results: &[&str] = &["实例一", "1.21.4"];
let view = SearchView::new("实例", results, "没有结果").placeholder("搜索");
let _: Element<'_, u8> = search_view(ui, view, |_| 0_u8);
}
}
}
/// 空结果也必须给面板内容(20C:不许收回到 bar)。
#[test]
fn empty_results_still_render_a_panel() {
let ui = leaked(SchemeFlavor::Light);
let empty: &[&str] = &[];
let view = SearchView::new("xyz", empty, "没有结果");
let _: Element<'_, u8> = search_view(ui, view, |_| 0_u8);
}
@@ -0,0 +1,396 @@
//! 滑块(`research/12b` §11,A1):**自绘 `Widget`**——M3 Expressive 的滑块是「竖条」,不是圆点。
//!
//! ## 参考值(`[源]` = A1,`SliderTokens`)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 11.1 | 轨道高 | **16**,圆角 full(= 8) | `TRACK_HEIGHT`(G18) |
//! | 11.2 | handle 形态 | **4 宽 × 44 高**,圆角 full;focus / pressed 收窄到 **2** | `HANDLE_WIDTH` / `HANDLE_HEIGHT` |
//! | 11.3 | 颜色 | 激活轨 `Primary`、未激活轨 `SecondaryContainer`、handle `Primary` | `Role::*` |
//! | 11.4 | handle 与轨道 | 纵向溢出:上下各 **14**((44−16)/2) | 推导 |
//! | 11.5 | 命中高度 | **48**(`LM.touch_target`;竖条 44 + 上下各 2) | `SliderGeometry::height` |
//! | 11.6 | 禁用 | 激活轨 `OnSurface`@**38%**、未激活 @**12%**、handle @**38%** | `interaction::*` |
//! | — | 离散停靠点 | **4** 圆点 `SecondaryContainer`(`StopIndicatorSize`);尾随间距 6 | [`Slider::steps`] |
//!
//! ## 为什么必须自绘
//!
//! iced 内置 `slider` 只能画「轨道 + 圆形/矩形 handle(高度 = 控件高)」,
//! **没有停靠点、没有禁用态、无法强制取证态**;而 §11.2 的 4×44 竖条要求 handle 高(44)
//! 与轨道高(16)分离。所以这里用 `advanced::Widget` 自绘:布局只有「宽 × 48」一个原子,
//! 轨道/激活段/停靠点/竖条全部用 `fill_quad` 画进同一个 bounds——几何因此在像素级可断言。
//!
//! ## 状态层([估],登记 D16)
//!
//! `SliderTokens` **没有**状态层直径(M3 Expressive 去掉了旧版的水滴 halo),hover/pressed 只体现为
//! handle 宽度与颜色。为了让五态在验收图上可辨且 α 只认令牌,这里把**通用状态层**
//! (`interaction::overlay_color`:hover 8% / focus 10% / pressed 10%)叠在 handle 上,
//! 宽度仍按 A1 收窄——两件事都来自令牌,没有新数值。
//!
//! ## 拖动
//!
//! `on_change` 有值就启用拖动与点击跳转;`None` = 禁用(也不吃事件)。
//! 值由外部持有(受控组件),拖动只 `publish` 新比例:组件不持有业务状态(契约 §2)。
use iced::advanced::layout::{self, Layout};
use iced::advanced::renderer::Quad;
use iced::advanced::widget::{tree, Tree};
use iced::advanced::{mouse, Clipboard, Shell, Widget};
use iced::{Background, Border, Color, Element, Event, Length, Rectangle, Size};
use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction, Tracker};
use super::geometry::{SliderGeometry, HANDLE_HEIGHT, HANDLE_WIDTH_ACTIVE, STOP_GAP, STOP_SIZE};
mod track;
pub use track::TrackGeometry;
use track::{fraction_at, quantize, EPSILON};
/// 一个滑块(受控:`fraction` 由外部给,变化经 `on_change` 回传)。
pub struct Slider<'a, M> {
ui: &'a Ui,
fraction: f32,
steps: Option<u32>,
on_change: Option<Box<dyn Fn(f32) -> M + 'a>>,
forced: Option<Interaction>,
}
impl<'a, M> Slider<'a, M> {
/// 连续值滑块(0.0 ~= 1.0)。
pub fn new(ui: &'a Ui, fraction: f32) -> Self {
Self {
ui,
fraction: fraction.clamp(0.0, 1.0),
steps: None,
on_change: None,
forced: None,
}
}
/// 离散值:`steps` = 分段数(4 → 5 个停靠点 0 / 25 / 50 / 75 / 100%)。
pub fn steps(mut self, steps: u32) -> Self {
self.steps = Some(steps.max(1));
self.fraction = quantize(self.fraction, self.steps);
self
}
/// 值变化回调;不给 = 禁用。
pub fn on_change(mut self, on_change: impl Fn(f32) -> M + 'a) -> Self {
self.on_change = Some(Box::new(on_change));
self
}
/// 强制视觉态(gallery 状态矩阵取证)。
pub fn state(mut self, state: Interaction) -> Self {
self.forced = Some(state);
self
}
/// 当前比例(量化后)。
fn value(&self) -> f32 {
quantize(self.fraction, self.steps)
}
/// 禁用与否:没有回调就是禁用(与按钮 `on_press: Option<M>` 同一口径)。
fn enabled(&self) -> bool {
self.on_change.is_some() && !matches!(self.forced, Some(Interaction::Disabled))
}
}
/// `Widget` 的状态:五态状态机([`Tracker`])+ 是否正在拖动。
#[derive(Debug, Default)]
struct State {
tracker: Tracker,
dragging: bool,
}
/// 一个圆角实心块(轨道的所有部件都是它)。
fn quad(bounds: Rectangle, radius: f32, color: Color) -> (Quad, Background) {
(
Quad {
bounds,
border: Border {
radius: radius.into(),
..Border::default()
},
..Quad::default()
},
Background::Color(color),
)
}
impl<'a, M, Theme, Renderer> Widget<M, Theme, Renderer> for Slider<'a, M>
where
M: Clone,
Renderer: iced::advanced::Renderer,
{
fn size(&self) -> Size<Length> {
Size {
width: Length::Fill,
height: Length::Fixed(SliderGeometry::of(self.ui).height),
}
}
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State {
tracker: Tracker::new(self.enabled()),
dragging: false,
})
}
fn layout(
&mut self,
_tree: &mut Tree,
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::atomic(
limits,
Length::Fill,
Length::Fixed(SliderGeometry::of(self.ui).height),
)
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
_viewport: &Rectangle,
) {
if self.forced.is_some() {
return;
}
let enabled = self.enabled();
let geometry = SliderGeometry::of(self.ui);
let bounds = layout.bounds();
let state = tree.state.downcast_mut::<State>();
if state.tracker.set_enabled(enabled) {
shell.request_redraw();
}
let mut target: Option<f32> = None;
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
if enabled && cursor.is_over(bounds) =>
{
if let Some(position) = cursor.position() {
state.dragging = true;
target = Some(fraction_at(bounds, position.x, geometry.handle));
shell.capture_event();
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
if let Some(position) = cursor.position() {
target = Some(fraction_at(bounds, position.x, geometry.handle));
shell.capture_event();
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
state.dragging = false;
}
_ => {}
}
if let Some(fraction) = target {
let next = quantize(fraction, self.steps);
if (next - self.value()).abs() >= EPSILON {
if let Some(on_change) = &self.on_change {
self.fraction = next;
shell.publish(on_change(next));
}
}
}
if state.tracker.event(event, cursor.is_over(bounds)) {
shell.request_redraw();
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
_theme: &Theme,
_style: &iced::advanced::renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
_viewport: &Rectangle,
) {
let ui = self.ui;
let geometry = SliderGeometry::of(ui);
let bounds = layout.bounds();
let state = self
.forced
.unwrap_or_else(|| tree.state.downcast_ref::<State>().tracker.state());
let disabled = state.is_disabled();
let (active_color, inactive_color, handle_color, handle_width) = if disabled {
(
interaction::disabled_content_over_role(ui, Role::OnSurface, false),
interaction::disabled_colors(ui, Role::Surface).0,
interaction::disabled_content_over_role(ui, Role::OnSurface, false),
geometry.handle,
)
} else {
// 状态层只叠在 handle 上(D16):α 全部来自 interaction(hover 8 / focus 10 / pressed 10)
let handle_color =
interaction::overlay_color(ui, state, Role::OnSurface, Role::Primary)
.unwrap_or_else(|| ui.color(Role::Primary));
let handle_width = match state {
Interaction::Focused | Interaction::Pressed => HANDLE_WIDTH_ACTIVE,
_ => geometry.handle,
};
(
ui.color(Role::Primary),
ui.color(Role::SecondaryContainer),
handle_color,
handle_width,
)
};
let centre_y = bounds.y + bounds.height / 2.0;
let track_x = bounds.x;
let track_w = bounds.width;
let track = TrackGeometry::of(bounds, self.value());
let centre_x = track.handle_centre();
let track_rect = |x: f32, width: f32| Rectangle {
x,
y: centre_y - geometry.track / 2.0,
width: width.max(0.0),
height: geometry.track,
};
// 离散停靠点:A1 的 `StopIndicatorColor = SecondaryContainer` 与未激活轨**同色**,
// 所以停靠点只有在「未激活轨在它两侧留出空隙」时才读得出来(官方也是这么画的:
// `StopIndicatorSize = 4` + `StopIndicatorTrailingSpace = 6`)。空隙两侧各留半个点 + 6。
// **不含两端**:0% 与 100% 上不画停靠点(那是轨道自己的圆角端,官方也不画)
let stops: Vec<f32> = match self.steps {
Some(steps) if steps >= 2 => (1..steps)
.map(|index| TrackGeometry::of(bounds, index as f32 / steps as f32).handle_centre())
.collect(),
_ => Vec::new(),
};
let gap = STOP_SIZE / 2.0 + STOP_GAP;
// 未激活轨:从**竖条中心**到右端,按停靠点切成段(每段自己圆角,端点读数更清楚)
let mut cursor = centre_x;
let mut segments: Vec<(f32, f32)> = Vec::new();
for anchor in stops.iter().copied() {
if anchor <= cursor {
continue;
}
segments.push((cursor, anchor - gap - cursor));
cursor = anchor + gap;
}
if track_x + track_w > cursor {
segments.push((cursor, track_x + track_w - cursor));
}
for (x, width) in segments {
if width <= 0.0 {
continue;
}
let (inactive, inactive_bg) =
quad(track_rect(x, width), geometry.track / 2.0, inactive_color);
renderer.fill_quad(inactive, inactive_bg);
}
// 激活轨:从容器左缘到**竖条中心**(与官方一致:激活段托在竖条下面)
let active_w = track.active_width;
if active_w > 0.0 {
let (active, active_bg) = quad(
track_rect(track_x, active_w),
geometry.track / 2.0,
active_color,
);
renderer.fill_quad(active, active_bg);
}
// 停靠点本体:4dp 圆点,只画在**未激活侧**(激活段已经走过的不重复画)
if !disabled {
for anchor in stops
.iter()
.copied()
.filter(|anchor| *anchor > centre_x + EPSILON)
{
let (stop, stop_bg) = quad(
Rectangle {
x: anchor - STOP_SIZE / 2.0,
y: centre_y - STOP_SIZE / 2.0,
width: STOP_SIZE,
height: STOP_SIZE,
},
STOP_SIZE / 2.0,
inactive_color,
);
renderer.fill_quad(stop, stop_bg);
}
}
// 竖条:4×44(focus / pressed 收窄到 2),圆角 full
let (bar, bar_bg) = quad(
Rectangle {
x: track.handle_centre() - handle_width / 2.0,
y: centre_y - HANDLE_HEIGHT / 2.0,
width: handle_width,
height: HANDLE_HEIGHT,
},
handle_width / 2.0,
handle_color,
);
renderer.fill_quad(bar, bar_bg);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
if !self.enabled() {
return mouse::Interaction::None;
}
if tree.state.downcast_ref::<State>().dragging {
return mouse::Interaction::Grabbing;
}
if cursor.is_over(layout.bounds()) {
mouse::Interaction::Grab
} else {
mouse::Interaction::None
}
}
}
impl<'a, M, Theme, Renderer> From<Slider<'a, M>> for Element<'a, M, Theme, Renderer>
where
M: Clone + 'a,
Theme: 'a,
Renderer: iced::advanced::Renderer + 'a,
{
fn from(slider: Slider<'a, M>) -> Self {
Element::new(slider)
}
}
/// 便捷入口:把 [`Slider`] 直接变成 `Element`(与其它组件的函数式签名一致)。
pub fn slider<'a, M: Clone + 'a>(spec: Slider<'a, M>) -> Element<'a, M> {
Element::new(spec)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,122 @@
//! slider 的单测(从 `slider.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
fn track() -> Rectangle {
Rectangle {
x: 100.0,
y: 200.0,
width: 300.0,
height: 48.0,
}
}
/// 11.1 / 11.2 / 11.4:轨道 16、竖条 4×44、上下各溢出 14。
#[test]
fn track_and_handle_are_the_m3_expressive_shapes() {
let g = SliderGeometry::of(&ui());
assert_eq!(g.track, 16.0);
assert_eq!((g.handle, g.handle_tall), (4.0, 44.0));
assert_eq!((g.handle_tall - g.track) / 2.0, 14.0);
assert_eq!(g.stop, 4.0);
assert_eq!(g.height, 48.0, "命中区 = LM.touch_target");
}
/// 0% / 50% / 100% 三档的位置:竖条**永不越界**,激活段与轨道分界一致。
#[test]
fn thumb_stays_inside_the_track_at_both_ends() {
let bounds = track();
let zero = TrackGeometry::of(bounds, 0.0);
assert_eq!(zero.handle_left, bounds.x, "0% 时竖条左缘 = 轨道左缘");
assert!(zero.handle_left >= bounds.x);
assert_eq!(zero.active_width, 2.0, "0% 时激活段只剩竖条半宽");
let half = TrackGeometry::of(bounds, 0.5);
assert!((half.handle_left - (bounds.x + bounds.width / 2.0 - 2.0)).abs() < 0.01);
let full = TrackGeometry::of(bounds, 1.0);
assert_eq!(
full.handle_right,
bounds.x + bounds.width,
"100% 时右缘贴轨道右缘"
);
assert!(full.handle_right <= bounds.x + bounds.width);
// 激活段画到**竖条中心**为止(= 右缘 − 2),不是画到右缘:100% 时也被竖条自己盖住
assert_eq!(full.active_width, bounds.width - 2.0);
}
/// 越界的比例(<0 或 >1)被 clamp,不会把竖条推到轨道外。
#[test]
fn out_of_range_fractions_are_clamped() {
let bounds = track();
for fraction in [-3.0, -0.01, 0.0, 1.0, 1.5, 9.0] {
let geometry = TrackGeometry::of(bounds, fraction);
assert!(
geometry.handle_left >= bounds.x,
"{fraction} 的竖条左缘越界:{}",
geometry.handle_left
);
assert!(
geometry.handle_right <= bounds.x + bounds.width,
"{fraction} 的竖条右缘越界:{}",
geometry.handle_right
);
}
}
/// 离散值:4 段 → 1/4 步进;连续值原样。
#[test]
fn discrete_sliders_quantize_to_the_stops() {
assert_eq!(quantize(0.3, Some(4)), 0.25);
assert_eq!(quantize(0.4, Some(4)), 0.5);
assert_eq!(quantize(1.4, Some(4)), 1.0);
assert_eq!(quantize(0.3, None), 0.3);
// 停靠点位置 = 五等分,且都落在轨道内
let bounds = track();
for index in 0..=4 {
let geometry = TrackGeometry::of(bounds, index as f32 / 4.0);
assert!(geometry.handle_left >= bounds.x);
assert!(geometry.handle_right <= bounds.x + bounds.width);
}
}
/// 拖动换算:指针在轨道左缘 → 0、右缘 → 1,中间线性。
#[test]
fn fraction_at_is_the_inverse_of_handle_center() {
let bounds = track();
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] {
let centre = TrackGeometry::of(bounds, fraction).handle_centre();
let back = fraction_at(bounds, centre, 4.0);
assert!((back - fraction).abs() < 1e-4, "{fraction} → {back}");
}
assert_eq!(fraction_at(bounds, bounds.x, 4.0), 0.0);
assert_eq!(fraction_at(bounds, bounds.x + bounds.width, 4.0), 1.0);
}
/// 没有回调 = 禁用:不吃拖动,也不响应状态机。
#[test]
fn slider_without_a_callback_is_disabled() {
let ui = ui();
let slider: Slider<'_, u8> = Slider::new(&ui, 0.5);
assert!(!slider.enabled());
let enabled: Slider<'_, u8> = Slider::new(&ui, 0.5).on_change(|_| 0_u8);
assert!(enabled.enabled());
let forced: Slider<'_, u8> = Slider::new(&ui, 0.5)
.on_change(|_| 0_u8)
.state(Interaction::Disabled);
assert!(!forced.enabled(), "强制禁用时不吃事件");
}
@@ -0,0 +1,66 @@
//! Slider 的**纯几何**(无 iced 画布依赖):比例 ⇄ 像素,`draw` 与单测共用同一条公式。
//!
//! 单独成文件的原因:`slider.rs` 已到 400 行红线,而这几条公式是「0% / 100% 竖条不越界」的
//! 唯一实现点——独立出来才好被人一眼审完。`draw` 与 `#[cfg(test)] mod tests` 都调这里。
use iced::Rectangle;
use super::super::geometry::HANDLE_WIDTH;
/// 比例比较的容差(浮点拖动会产生 1e-7 级抖动,别让它每帧发一条消息)。
pub(super) const EPSILON: f32 = 1e-4;
/// 量化到停靠点(连续值时原样返回)。
pub(super) fn quantize(fraction: f32, steps: Option<u32>) -> f32 {
match steps {
Some(steps) => {
let steps = steps as f32;
(fraction.clamp(0.0, 1.0) * steps).round() / steps
}
None => fraction.clamp(0.0, 1.0),
}
}
/// 一条竖条的横坐标(**位移区间是 `handle/2 .. width - handle/2`**,所以 0%/100% 都不越界)。
fn handle_center(bounds: Rectangle, fraction: f32, handle: f32) -> f32 {
let travel = (bounds.width - handle).max(0.0);
bounds.x + handle / 2.0 + travel * fraction.clamp(0.0, 1.0)
}
/// 指针横坐标 → 比例(`handle_center` 的逆函数,同样不越界)。
pub(super) fn fraction_at(bounds: Rectangle, x: f32, handle: f32) -> f32 {
let travel = (bounds.width - handle).max(1.0);
((x - bounds.x - handle / 2.0) / travel).clamp(0.0, 1.0)
}
/// 轨道 / 停靠点 / 竖条的纯几何(像素级断言用;`Widget::draw` 与测试共用同一条公式)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TrackGeometry {
/// 激活段的左缘。
pub active_left: f32,
/// 激活段的宽。
pub active_width: f32,
/// 竖条左缘(`f = 0` 时应 = 轨道左缘)。
pub handle_left: f32,
/// 竖条右缘(`f = 1` 时应 = 轨道右缘)。
pub handle_right: f32,
}
impl TrackGeometry {
/// 按轨道矩形与比例求值(`f` 会被 clamp 到 0..=1)。
pub fn of(track: Rectangle, fraction: f32) -> Self {
let centre = handle_center(track, fraction, HANDLE_WIDTH);
let handle_left = centre - HANDLE_WIDTH / 2.0;
Self {
active_left: track.x,
active_width: centre - track.x,
handle_left,
handle_right: handle_left + HANDLE_WIDTH,
}
}
/// 竖条中心(= 激活段与未激活段的分界)。
pub fn handle_centre(&self) -> f32 {
self.handle_left + HANDLE_WIDTH / 2.0
}
}