fix(iced/feedback): 空态间距双算、图标块对比度、Dialog 宽度与动作禁用底色(P2-1/2/4)
- P2-1 空态间距双算:`Column::spacing(8)` 与显式 `gap(16/24)` 叠加 ⇒ 实际渲染 32/40、 条目总高 264,而登记的是 16/8/24、232。现在 `spacing(0.0)`,间距只有一个来源(`gap()`); 断言从 `gap > 0.0` 改成**精确等于令牌档位** 16/8/24。 - P2-2 空态图标块几乎没有边界:`SurfaceContainerLow` 与画布 ΔRGB 只有 11/5/0(≈1%), 浅色下看不出块。换 `SurfaceContainerHigh`(材质层 Overlay)+ 1px `OutlineVariant` 描边, 并用 `block_layer()` 让 probe 与组件取同一个层(不再各写一份角色)。 - P2-1 Dialog 恒 480 宽:`Length::Fixed(480)` 在窄宿主会溢出,`.max_width(560)` 对 Fixed 是 空操作。改成 `Fill + max_width(480)` = `min(宿主, 480)`(iced 无 min-width,见 G17-d)。 - P2-4 动作禁用底色取错:`interaction::disabled_content_over_role(..., false)` 把 38% 内容色 固定叠在 **Surface** 上,而 Snackbar 的底是 `InverseSurface`、Dialog 是 `SurfaceContainerHigh` ⇒ 真出现禁用态会是「38% 深字叠深底」。改为 `content_color()` 按真实容器角色合成(同令牌 α)。 - probe:Dialog 上内距判据 `(24..=40)` 收紧到 `(24..=30)`;空态新增「块↔标题 ≈16」与 「条目总高 ≈232」两条渲染层断言。
This commit is contained in:
1 parent
e9af27c8f1
commit
c723d1faff
5 files changed
+261
-172
No files matched your search
@@ -13,23 +13,45 @@
|
||||
//! ## 禁用是状态不是构造器
|
||||
//!
|
||||
//! 照 `selection/chip.rs` 修好的模式:`on_press: Option<M>`,`None` = 禁用;文字色降到
|
||||
//! `OnSurface@38%`([`interaction::disabled_content_over_role`]),标签本身**不删**。
|
||||
//! `OnSurface@38%`,标签本身**不删**。禁用色的**底**必须是这两个动作件自己的容器角色
|
||||
//! (`InverseSurface` / `SurfaceContainerHigh`)——`interaction::disabled_content_over_role`
|
||||
//! 只支持「叠在 Surface / 叠在 12% 容器上」两档,拿它合成会得到「38% 深字叠深底」(复审 P2-4)。
|
||||
|
||||
use iced::widget::{button, text};
|
||||
use iced::{Background, Border, Element, Length, Padding, Shadow};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::color::state::DISABLED_CONTENT_ALPHA;
|
||||
use unlucky_design::color::{Role, Scheme};
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
use crate::theme::{to_iced, Ui};
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::super::layout;
|
||||
|
||||
/// 禁用态内容色:`OnSurface@38%` 叠在**真实容器色**上(复审 P2-4)。
|
||||
///
|
||||
/// `interaction::disabled_content_over_role` 的 `on_container` 只有「叠在 Surface / 叠在
|
||||
/// 12% 容器上」两档,而这两个动作件的底是 `InverseSurface`(Snackbar)与
|
||||
/// `SurfaceContainerHigh`(Dialog)——直接调它会得到「38% 深字叠深底」。
|
||||
/// 这里按同一令牌口径([`DISABLED_CONTENT_ALPHA`])在本文件合成一次,底换成真实容器角色。
|
||||
fn content_color(ui: &Ui, role: Role, container_role: Role, disabled: bool) -> iced::Color {
|
||||
if !disabled {
|
||||
return ui.color(role);
|
||||
}
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
to_iced(
|
||||
scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(scheme.get(container_role)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 一个文字动作(Dialog 的动作区、Snackbar 的 action 共用)。
|
||||
///
|
||||
/// - `content` / `container_role`:内容色与**状态层叠在什么颜色上**(Dialog =
|
||||
@@ -51,11 +73,7 @@ pub(super) fn text_action<'a, M: Clone + 'a>(
|
||||
let radius = ui.radius(shape, height);
|
||||
let disabled = on_press.is_none();
|
||||
|
||||
let ink = if disabled {
|
||||
interaction::disabled_content_over_role(ui, Role::OnSurface, false)
|
||||
} else {
|
||||
ui.color(content)
|
||||
};
|
||||
let ink = content_color(ui, content, container_role, disabled);
|
||||
|
||||
let element = button(
|
||||
text(label)
|
||||
@@ -115,11 +133,7 @@ pub(super) fn icon_action<'a, M: Clone + 'a>(
|
||||
let radius = ui.radius(shape, side);
|
||||
let disabled = on_press.is_none();
|
||||
|
||||
let ink = if disabled {
|
||||
interaction::disabled_content_over_role(ui, Role::OnSurface, false)
|
||||
} else {
|
||||
ui.color(content)
|
||||
};
|
||||
let ink = content_color(ui, content, container_role, disabled);
|
||||
|
||||
let element = button(layout::centered_fixed(
|
||||
glyph_side,
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
//! 逐像素与「真半透明铺在 surface 上」等价(遮罩下通常就是底色),且两档渲染一致。
|
||||
//! 代价:遮罩下若是图片/卡片,合成目标不是 surface —— 这条登记为偏离 D-2。
|
||||
//!
|
||||
//! **深色档的观感(观察项 D-5)**:画布本身很暗,0.32 叠上去只从 (9,21,28) 压到 (6,14,19)
|
||||
//! (≈4%),几乎看不出「压暗」。数值是官方 token 推出来的,**不调**:要更强的层级感应该动
|
||||
//! Dialog 的容器档或阴影,而不是把 scrim 抬到 0.4(那就偏离 token 了)。
|
||||
//!
|
||||
//! ## 不画阴影(偏离 D-1)
|
||||
//!
|
||||
//! 官方 L3 有阴影,但 D27(CPU 禁模糊)+ 令牌层 `ShadowSpec` 仍是 M63 占位值 ⇒ 先不画,
|
||||
@@ -164,9 +168,12 @@ pub fn dialog<'a, M: Clone + 'a>(ui: &'a Ui, spec: Dialog<'a, M>) -> Element<'a,
|
||||
));
|
||||
}
|
||||
|
||||
// 宽度 = `min(宿主, 480)`:**不能**写 `Length::Fixed(480)`(复审 P2-1)——
|
||||
// 窗口窄于 528 时对话框会直接溢出宿主,而 `.max_width(480)` 对 `Fixed` 是空操作。
|
||||
// `Fill` + `max_width` 才是 iced 里表达「至多 480」的唯一办法(无 min-width,见 G17-d)。
|
||||
container(content)
|
||||
.width(Length::Fixed(geometry.width))
|
||||
.max_width(geometry.max_width)
|
||||
.width(Length::Fill)
|
||||
.max_width(geometry.width)
|
||||
.padding(Padding::from(geometry.padding))
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(surface)),
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! ## ⚠️ 这是**项目自定**组件,官方 Material 3 没有它
|
||||
//!
|
||||
//! `research/14`(官方组件清单)里**没有** EmptyState 这一项,Compose `material3` 也没有
|
||||
//! 对应 token。所以本文件的数值**全部是 `[估]`**(`research/12b §15` + `11b R14` + M53 的空态锚点),
|
||||
//! 不得对外声称「按官方规格实现」:
|
||||
//! 对应 token。所以**几何与排布是我们自定的**:下面表里标 `[估]` 的(96 块 / 三个间距 / 底色)
|
||||
//! 没有任何官方出处,标 `[规格]` 的是 `11b R14` 的排版口径、标 `[源]` 的只有「CTA 就是标准按钮」
|
||||
//! 这一条(按钮家族自己的 A1)。**不得整体对外声称「按官方规格实现」**:
|
||||
//!
|
||||
//! | # | 项 | 值 | 等级 |
|
||||
//! |---|---|---|---|
|
||||
@@ -75,9 +76,11 @@ pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> El
|
||||
icons::view::<M>(spec.icon, ui.color(Role::OnSurfaceVariant)),
|
||||
);
|
||||
let block_radius = iced::border::Radius::new(geometry.block_radius);
|
||||
// 块底色 `[估]`:取材质层 `Card`(`SurfaceContainerLow`)——空态块是「内容卡片」层级,
|
||||
// 不是浮层也不是画布;官方没有这个组件,所以这条是我们自己的口径。
|
||||
let block_color = ui.layer(MaterialLayer::Card);
|
||||
// 块底色 `[估]`:`SurfaceContainerHigh`(材质层 `Overlay`)。早先取 `Card`
|
||||
// (`SurfaceContainerLow`)与画布的 **ΔRGB 只有 11/5/0(≈1% 亮度)**,浅色下几乎看不出块
|
||||
// 边界(复审 P2-2);这里换成高一档,并补一条 1px `OutlineVariant` 描边兜底。
|
||||
let block_color = ui.layer(BLOCK_LAYER);
|
||||
let block_outline = ui.color(Role::OutlineVariant);
|
||||
let block = container(glyph)
|
||||
.width(Length::Fixed(geometry.block))
|
||||
.height(Length::Fixed(geometry.block))
|
||||
@@ -86,15 +89,19 @@ pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> El
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(block_color)),
|
||||
border: Border {
|
||||
color: block_outline,
|
||||
width: crate::theme::STROKE_PX,
|
||||
radius: block_radius,
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
});
|
||||
|
||||
// **列间距走显式 gap,不走 `Column::spacing`**(复审 P2-1):两者叠加时
|
||||
// block↔标题 会渲染成 8 + 16 + 8 = **32**、正文↔CTA 会渲染成 **40**,
|
||||
// 而登记的是 16 / 8 / 24(实测内容总高 264,按登记值应 232)。
|
||||
let mut content = column![]
|
||||
.align_x(Alignment::Center)
|
||||
.spacing(geometry.gap_body)
|
||||
.spacing(0.0)
|
||||
.push(block)
|
||||
.push(gap(geometry.gap_title))
|
||||
.push(
|
||||
@@ -103,6 +110,7 @@ pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> El
|
||||
.font(ui.font_of(title_style))
|
||||
.color(ui.color(Role::OnSurface)),
|
||||
)
|
||||
.push(gap(geometry.gap_body))
|
||||
.push(
|
||||
text(spec.body)
|
||||
.size(body_style.size_px)
|
||||
@@ -125,7 +133,17 @@ pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> El
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 固定高度的透明留白(列间距在 `spacing` 之外还要分档时用)。
|
||||
/// 图标块的材质层(`[估]`:空态块是「抬起来的卡片」层级,不是画布也不是浮层)。
|
||||
///
|
||||
/// probe 与测试都从这里取,避免「组件一个色、断言另一个色」。
|
||||
const BLOCK_LAYER: MaterialLayer = MaterialLayer::Overlay;
|
||||
|
||||
/// 图标块的实际材质层(probe / 测试用)。
|
||||
pub fn block_layer() -> MaterialLayer {
|
||||
BLOCK_LAYER
|
||||
}
|
||||
|
||||
/// 固定高度的透明留白(列间距的唯一来源:`Column::spacing` 必须保持 0,否则双算)。
|
||||
fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> {
|
||||
iced::widget::Space::new()
|
||||
.width(Length::Fill)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! 两个「面」的渲染层判据:Dialog(§7)与空态(§15)。`probe` 的子模块。
|
||||
//!
|
||||
//! 量什么:
|
||||
//!
|
||||
//! - Dialog:最宽行 **480**(且落在官方 280..560 内)· 标题墨迹左缩 **≈24**、顶缩 **≈26**
|
||||
//! (A1 `DialogPadding = 24`)· 动作色是 `Primary` · scrim 带 ≥700 宽且色值 = 预合成实色;
|
||||
//! - 空态:图标块 **96×96** · 块↔标题的**实际**间距 ≈16 + 字形 bearing · 条目总高 **≈232**
|
||||
//! (96 + 16 + 32 + 8 + 16 + 24 + 40)· CTA **40**。
|
||||
//!
|
||||
//! 空态那两条间距断言是复审 P2-1 的回归:早先 `Column::spacing(8)` 与显式 `gap(16/24)` 双算,
|
||||
//! 渲染成 32/40、总高 264,而登记的是 16/24、232。
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
|
||||
use super::super::dialog::scrim;
|
||||
use super::super::empty_state::block_layer;
|
||||
use super::super::pixel::{
|
||||
band_width, bands, ink_inside, matches, near, rgb, row_ink, runs, BBox, Shot,
|
||||
};
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// Dialog 宽(本项目取值,官方区间 280..560 内)。
|
||||
const DIALOG_W: usize = 480;
|
||||
/// 空态图标块边长。
|
||||
const BLOCK: usize = 96;
|
||||
/// 空态条目总高:96 块 + 16 + 32 标题 + 8 + 16 副文案 + 24 + 40 CTA。
|
||||
const EMPTY_TOTAL: usize = 232;
|
||||
|
||||
/// §7:卡片宽 / 内距 / 动作色 / scrim。
|
||||
pub(super) fn check_dialog(shot: &Shot, ui: &Ui) {
|
||||
// 参考值写字面量(A1 AlertDialogKt):280 / 560 / 480 / pad 24。
|
||||
let surface = ui.color(Role::Surface);
|
||||
let card = ui.layer(MaterialLayer::Overlay);
|
||||
|
||||
// ---- 7.4 宽:所有行里最宽的一段(卡片上下留白那几行没有文字,量得到全宽)------
|
||||
let mut widest = (0usize, 0usize, 0usize); // (len, x, y)
|
||||
for y in 0..shot.height {
|
||||
for (x, len) in runs(shot, y, card, surface) {
|
||||
if len > widest.0 {
|
||||
widest = (len, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (width, card_left, card_row) = widest;
|
||||
assert!(
|
||||
near(width, DIALOG_W),
|
||||
"7.4:对话框宽必须是 {DIALOG_W}(G17-e 项目取值),实测 {width}"
|
||||
);
|
||||
assert!(
|
||||
(280..=560).contains(&width),
|
||||
"7.4:宽必须落在官方区间 280..560 内(A1 DialogMinWidth/DialogMaxWidth),实测 {width}"
|
||||
);
|
||||
|
||||
// 该行上下扩成卡片带(同一张卡片的连续行;两张卡并排时各自成带)
|
||||
let mut top = card_row;
|
||||
while top > 0 && row_ink(shot, top - 1, card, surface) >= width / 2 {
|
||||
top -= 1;
|
||||
}
|
||||
let mut bottom = card_row;
|
||||
while bottom + 1 < shot.height && row_ink(shot, bottom + 1, card, surface) >= width / 2 {
|
||||
bottom += 1;
|
||||
}
|
||||
|
||||
// ---- 7.3 内距:标题墨迹距卡片左缘 ≈24、顶 ≈24 + 行内 bearing -----------------
|
||||
let bbox = BBox {
|
||||
left: card_left,
|
||||
right: card_left + width - 1,
|
||||
top,
|
||||
bottom,
|
||||
};
|
||||
let title =
|
||||
ink_inside(shot, &bbox, ui.color(Role::OnSurface), card, 2).expect("卡片里必须有标题墨迹");
|
||||
let inset = title.left.saturating_sub(card_left);
|
||||
assert!(
|
||||
(22..=28).contains(&inset),
|
||||
"7.3:四边内距 24 ⇒ 标题墨迹左缩 ≈24(含 bearing 22..28),实测 {inset}"
|
||||
);
|
||||
// 顶缩收到 (24..=30):写错成 32 也过的话这条等于没写(复审 P2-5)。
|
||||
// HeadlineSmall 24/32 的行盒顶到字形顶 ≈3~5px,所以 24+3..24+6。
|
||||
let top_inset = title.top.saturating_sub(top);
|
||||
assert!(
|
||||
(24..=30).contains(&top_inset),
|
||||
"7.3:上内距 24 ⇒ 标题墨迹顶缩 24+字形 bearing(24..30),实测 {top_inset}"
|
||||
);
|
||||
|
||||
// ---- 7.7 scrim:一块 ≥700 宽的遮罩带,颜色 = 预合成实色 ----------------------
|
||||
let backdrop = scrim(ui);
|
||||
let scrim_bands = bands(shot, backdrop, surface, 700);
|
||||
assert!(
|
||||
!scrim_bands.is_empty(),
|
||||
"7.7:没找到 ≥700 宽的遮罩带(宿主 800 宽那格)"
|
||||
);
|
||||
|
||||
// ---- 7.6 动作色:Dialog 动作是 `Primary`(不是 OnSurfaceVariant)-------------
|
||||
// 按「更靠近 Primary 而不是卡片」判:14px 中文字形只有边缘几个像素能命中精确色。
|
||||
let action = rgb(ui.color(Role::Primary));
|
||||
let card_rgb = rgb(card);
|
||||
let action_pixels = (top..=bottom)
|
||||
.map(|y| {
|
||||
(card_left..(card_left + width).min(shot.width))
|
||||
.filter(|x| {
|
||||
let pixel = shot.at(*x, y);
|
||||
let to_action: u32 = pixel
|
||||
.iter()
|
||||
.zip(action)
|
||||
.map(|(a, b)| u32::from(a.abs_diff(b)))
|
||||
.sum();
|
||||
let to_card: u32 = pixel
|
||||
.iter()
|
||||
.zip(card_rgb)
|
||||
.map(|(a, b)| u32::from(a.abs_diff(b)))
|
||||
.sum();
|
||||
to_action < to_card
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.sum::<usize>();
|
||||
assert!(
|
||||
action_pixels > 40,
|
||||
"7.6:动作文本应是 Primary(实测该色只有 {action_pixels} px)"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"[feedback-probe] dialog:宽 {width} · 标题左缩 {inset} · 顶缩 {top_inset} · \
|
||||
卡片 y {top}..{bottom} · scrim 带 {} 条 · Primary {action_pixels}px",
|
||||
scrim_bands.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// §15:图标块 96(`[估]`)+ 间距 16 / 8 / 24 + CTA 40。
|
||||
pub(super) fn check_empty(shot: &Shot, ui: &Ui) {
|
||||
let surface = ui.color(Role::Surface);
|
||||
let block = ui.layer(block_layer());
|
||||
|
||||
// 下限 40 而不是 88:块只有 96 宽,中间的图标(32)会把那几行的块色墨量压到 ≈64。
|
||||
let found = bands(shot, block, surface, 40);
|
||||
let (top, bottom) = found
|
||||
.iter()
|
||||
.copied()
|
||||
.max_by_key(|(top, bottom)| bottom - top)
|
||||
.filter(|(top, bottom)| near(bottom - top + 1, BLOCK))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("15.1:没找到高 {BLOCK} 的图标块(`[估]` 项目自定值),实测量到的带:{found:?}")
|
||||
});
|
||||
|
||||
let width = band_width(shot, (top, bottom), block, surface);
|
||||
assert!(
|
||||
near(width, BLOCK),
|
||||
"15.1:图标块必须 {BLOCK}×{BLOCK},实测宽 {width}"
|
||||
);
|
||||
|
||||
// ---- P2-1 回归:块↔标题的**实际**间距(16 而不是 32)-------------------------
|
||||
let bbox = BBox {
|
||||
left: 0,
|
||||
right: shot.width - 1,
|
||||
top: bottom + 1,
|
||||
bottom: shot.height - 1,
|
||||
};
|
||||
let title = ink_inside(shot, &bbox, ui.color(Role::OnSurface), surface, 0)
|
||||
.expect("块下面必须有标题墨迹");
|
||||
let gap = title.top.saturating_sub(bottom + 1);
|
||||
assert!(
|
||||
(16..=24).contains(&gap),
|
||||
"15.3:块↔标题应为 16(+ 字形 bearing),实测 {gap}(32 = spacing 与 gap 双算回来了)"
|
||||
);
|
||||
|
||||
// ---- 条目总高 ≈232(96+16+32+8+16+24+40)-------------------------------------
|
||||
let cta = bands(shot, ui.color(Role::Primary), surface, 20);
|
||||
let (cta_top, cta_bottom) = cta
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|(top, bottom)| near(bottom - top + 1, 40))
|
||||
.unwrap_or_else(|| panic!("15.6:CTA 应是标准按钮(可见高 40),实测带 {cta:?}"));
|
||||
let total = cta_bottom + 1 - top;
|
||||
assert!(
|
||||
total.abs_diff(EMPTY_TOTAL) <= 6,
|
||||
"15.x:条目总高应 ≈{EMPTY_TOTAL}(96+16+32+8+16+24+40),实测 {total}"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · 块↔标题 {gap} · \
|
||||
总高 {total} · CTA {}..{}",
|
||||
bottom - top + 1,
|
||||
cta_top,
|
||||
cta_bottom
|
||||
);
|
||||
}
|
||||
@@ -7,173 +7,28 @@ use iced::Element;
|
||||
|
||||
use unlucky_design::color::{Role, Scheme, SchemeFlavor, SCRIM_ALPHA};
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::{to_iced, Families, Ui};
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::dialog::scrim;
|
||||
use super::geometry::{phase_segments, CircularGeometry, LinearGeometry};
|
||||
use super::keyframes::{KeyPoint, Keyframes};
|
||||
use super::panel::{DialogGeometry, EmptyStateGeometry, SnackbarGeometry};
|
||||
use super::progress::{Amount, Progress};
|
||||
use super::snackbar::{Placement, Snackbar};
|
||||
use super::{circular, dialog, empty_state, linear, snackbar, Dialog, EmptyState};
|
||||
|
||||
mod progress;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
pub(super) fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
// ---- §12 进度 ---------------------------------------------------------------
|
||||
|
||||
/// 12.1:线性厚度 **4**、圆角 full ⇒ 2(`ShapeScale::Full.radius_px(4)`)。
|
||||
#[test]
|
||||
fn linear_thickness_and_radius_come_from_the_tokens() {
|
||||
let geometry = LinearGeometry::of(&ui(SchemeFlavor::Light));
|
||||
assert_eq!(geometry.thickness, 4.0, "12.1 线性厚度 = 4(A1 Height)");
|
||||
assert_eq!(
|
||||
geometry.radius,
|
||||
ShapeScale::Full.radius_px(4.0),
|
||||
"12.1 圆角 full = 厚的一半"
|
||||
);
|
||||
assert_eq!(geometry.radius, 2.0);
|
||||
assert_eq!(
|
||||
geometry.cycle_ms, 1750,
|
||||
"不定态周期 = LinearAnimationDuration"
|
||||
);
|
||||
}
|
||||
|
||||
/// 12.3:环形 **40 / 4**,环中线半径 (40 − 4) / 2 = **18**。
|
||||
#[test]
|
||||
fn circular_geometry_matches_the_tokens() {
|
||||
let geometry = CircularGeometry::of(&ui(SchemeFlavor::Dark));
|
||||
assert_eq!(
|
||||
geometry.diameter, 40.0,
|
||||
"A1 CircularProgressIndicatorTokens.Size"
|
||||
);
|
||||
assert_eq!(geometry.thickness, 4.0, "A1 ActiveThickness");
|
||||
assert_eq!(geometry.radius, 20.0, "圆角 full = 直径的一半");
|
||||
assert_eq!(geometry.arc_radius(), 18.0);
|
||||
assert_eq!(
|
||||
geometry.cycle_ms, 6000,
|
||||
"A1 CircularAnimationProgressDuration"
|
||||
);
|
||||
}
|
||||
|
||||
/// 12.4:不定态两段**始终非负**且真的在跑(相位扫一圈,段中心单调右移)。
|
||||
#[test]
|
||||
fn indeterminate_segments_stay_ordered_and_travel_right() {
|
||||
let mut travelled = false;
|
||||
let mut previous_end = 0.0;
|
||||
for step in 0..=200 {
|
||||
let phase = step as f32 / 200.0;
|
||||
for (start, end) in phase_segments(phase) {
|
||||
assert!(
|
||||
start <= end + f32::EPSILON,
|
||||
"相位 {phase:.3}:段起点 {start} 大于终点 {end}(前缘/后缘写反就会这样)"
|
||||
);
|
||||
assert!(
|
||||
(0.0..=1.0).contains(&start) && (0.0..=1.0).contains(&end),
|
||||
"相位 {phase:.3}:段越出轨道 {start}..{end}"
|
||||
);
|
||||
}
|
||||
let first = phase_segments(phase)[0].1;
|
||||
if phase > 0.0 && phase < 0.5 && first > previous_end {
|
||||
travelled = true;
|
||||
}
|
||||
previous_end = first;
|
||||
}
|
||||
assert!(travelled, "段前沿必须随相位右移(否则就是一根不动的条)");
|
||||
|
||||
// 相位 0:两段都是空的(前沿/后缘都在 0)
|
||||
assert_eq!(phase_segments(0.0), [(0.0, 0.0), (0.0, 0.0)]);
|
||||
// 1000/1750 ≈ 0.5714:第一线前沿到头(= 1.0)
|
||||
let (_, head) = phase_segments(1000.0 / 1750.0)[0];
|
||||
assert!((head - 1.0).abs() < 1.0e-5, "第一线前沿应在 1000ms 到 1.0");
|
||||
}
|
||||
|
||||
/// 环形不定态:弧长在 0.1..0.87 之间(A1 min/max),旋转走满一圈。
|
||||
#[test]
|
||||
fn circular_indeterminate_sweeps_the_official_range() {
|
||||
let geometry = CircularGeometry::of(&ui(SchemeFlavor::Light));
|
||||
let mut min = f32::MAX;
|
||||
let mut max = f32::MIN;
|
||||
let mut rotation = f32::MIN;
|
||||
for step in 0..=600 {
|
||||
let (sweep, spin) = geometry.indeterminate(step as f32 / 600.0);
|
||||
min = min.min(sweep);
|
||||
max = max.max(sweep);
|
||||
rotation = rotation.max(spin);
|
||||
}
|
||||
assert!(
|
||||
(min - 0.1).abs() < 1.0e-3,
|
||||
"弧长下限 0.1(CircularIndeterminateMinProgress),实测 {min}"
|
||||
);
|
||||
assert!((max - 0.87).abs() < 1.0e-3, "弧长上限 0.87,实测 {max}");
|
||||
assert!(
|
||||
(rotation - 1.0).abs() < 1.0e-3,
|
||||
"旋转应走满一整圈,实测 {rotation}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 确定态:越界值夹到 0..1(0% 只画轨道,100% 画满)。
|
||||
#[test]
|
||||
fn determinate_values_are_clamped() {
|
||||
assert_eq!(
|
||||
Progress::determinate(1.5).amount(),
|
||||
Amount::Determinate(1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
Progress::determinate(-2.0).amount(),
|
||||
Amount::Determinate(0.0)
|
||||
);
|
||||
assert_eq!(Progress::determinate(0.5).spans(), vec![(0.0, 0.5)]);
|
||||
assert_eq!(Progress::determinate(0.0).spans(), vec![(0.0, 0.0)]);
|
||||
}
|
||||
|
||||
/// keyframes 求值器:端点保持、相位循环、越界相位按周期取模。
|
||||
#[test]
|
||||
fn keyframes_hold_ends_and_wrap() {
|
||||
const LINE: Keyframes = Keyframes {
|
||||
cycle_ms: 1000,
|
||||
points: &[
|
||||
KeyPoint {
|
||||
ms: 250,
|
||||
value: 0.0,
|
||||
easing: unlucky_design::motion::Easing::Linear,
|
||||
},
|
||||
KeyPoint {
|
||||
ms: 750,
|
||||
value: 1.0,
|
||||
easing: unlucky_design::motion::Easing::Linear,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(LINE.sample(0.0), 0.0, "第一点之前保持首值");
|
||||
assert_eq!(LINE.sample(0.25), 0.0, "正好在第一点");
|
||||
assert!((LINE.sample(0.5) - 0.5).abs() < 1.0e-5, "中点线性插值");
|
||||
assert_eq!(LINE.sample(0.75), 1.0);
|
||||
assert_eq!(LINE.sample(0.9), 1.0, "最后一点之后保持末值");
|
||||
assert_eq!(LINE.sample(1.25), 0.0, "相位 1.25 = 0.25(循环)");
|
||||
assert_eq!(
|
||||
LINE.sample(-0.75),
|
||||
0.0,
|
||||
"负相位按周期折返:-0.75 → 0.25 相位 = 250ms = 首点"
|
||||
);
|
||||
assert!(
|
||||
(LINE.sample(-0.6) - 0.3).abs() < 1.0e-5,
|
||||
"-0.6 → 0.4 相位 = 400ms = 30%"
|
||||
);
|
||||
assert_eq!(LINE.sample(f32::NAN), 0.0, "NaN 不 panic,给首值");
|
||||
}
|
||||
|
||||
// ---- §14 Snackbar -----------------------------------------------------------
|
||||
|
||||
/// 14.3 / 14.1 / 宽度上限:48 / 68 / 4 / 600。
|
||||
@@ -254,7 +109,14 @@ fn empty_state_geometry_is_the_project_defined_set() {
|
||||
assert_eq!(geometry.block, 96.0);
|
||||
assert_eq!(geometry.block_radius, 16.0, "ShapeScale::Large");
|
||||
assert_eq!(geometry.icon, 32.0, "IconSize::Empty");
|
||||
assert!(geometry.gap_title > 0.0 && geometry.gap_body > 0.0);
|
||||
// **复审 P2-1 的回归断言**:三个间距必须精确等于令牌档位。
|
||||
// 早先这里只写 `> 0.0`,所以「Column::spacing(8) 与显式 gap(16/24) 双算成 32/40」
|
||||
// 完全藏得住(渲染实测 264 vs 登记 232)。
|
||||
assert_eq!(geometry.gap_title, 16.0, "块↔标题 = Space::X4");
|
||||
assert_eq!(geometry.gap_body, 8.0, "标题↔副文案 = Space::X2");
|
||||
assert_eq!(geometry.gap_action, 24.0, "副文案↔CTA = Space::X6");
|
||||
assert_eq!(geometry.block, 96.0);
|
||||
assert_eq!(super::empty_state::block_layer(), MaterialLayer::Overlay);
|
||||
}
|
||||
|
||||
// ---- 结构 / 角色 ------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user