Merge branch 'feat/p4-iced-components-inputs-textfield-sear'
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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(÷r_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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user