feat(iced): 反馈类家族四件(Progress / Snackbar / Dialog / EmptyState)

D39 五家族并行的反馈类线(M78 / COMPFEED)。全部对消息类型泛型、不 import app::Message,
数值来自令牌或登记为 G17(令牌层没有反馈类的 ComponentMetrics 与骨架位)。

- Progress:linear(确定态 FillPortion 占比 + 官方 1750ms 双段不定态 keyframes)与
  circular(40/4 环,弧长 = 数值×360°,不定态走官方 6000ms 弧长+旋转 keyframes)。
  环形用几何 SVG(canvas feature 没开),组件不计时,相位由调用方给。
- Snackbar:48/68 两种高、上限宽 600(A1 ContainerMaxWidth)、12 外边距、贴顶/贴底、
  action(InversePrimary)+ 关闭键(InverseOnSurface);**组件不计时**,超时归调用方。
  iced 的 Length 没有 min-width ⇒ 344 的最小宽由宿主保证(登记 G17-d,与按钮 G15 同墙)。
- Dialog:scrim(黑@0.32 预合成实色,D27)+ R28 + SurfaceContainerHigh + pad 24 +
  标题/正文/动作三区 + 无动作/单动作/双动作;官方 280..560 区间内取 480。
- EmptyState:**官方没有这个组件**,96 图标块/32 图标/HeadlineSmall 全部标 [估]。
- action.rs:Dialog/Snackbar 共用的文字/图标动作(内容角色作参数,状态层仍走 interaction)。
- gallery/feedback.rs:状态矩阵;家族内再切一段 ULL_FEEDBACK_PART(all|progress|snackbar|
  dialog|empty),开关本身住在 feedback/gallery_parts.rs,不动 gallery/mod.rs。
- feedback/probe.rs:对着真截图量像素(渲染层断言),四类几何各逐项钉住。
- feedback/tests.rs:几何/节奏/角色/scrim 的纯函数断言。

gates: fmt --all --check · clippy -p unlucky-app-iced --all-targets -D warnings ·
test -p unlucky-app-iced(144 通过 / 0 失败)
This commit is contained in:
AskaEth committed 2026-11-02 15:56:42 +08:00
1 parent 27f0d2a551
commit 36f58d7904
14 files changed
+2900 -39

No files matched your search

@@ -0,0 +1,331 @@
//! 纯函数级断言:几何对令牌、节奏对 A1、角色对变体。
//!
//! 这里**只测数值与结构**(M68 的教训:函数对不代表图对)。对着真截图量像素的是
//! [`super::probe`],两者缺一不可——本文件保证「数字对」,probe 保证「画出来是那个数字」。
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};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
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。
#[test]
fn snackbar_heights_widths_and_radius_are_the_tokens() {
let geometry = SnackbarGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.height_for(1), 48.0, "14.3 单行 48");
assert_eq!(geometry.height_for(2), 68.0, "14.3 双行 68");
assert_eq!(geometry.radius, 4.0, "14.1 圆角 4(CornerExtraSmall)");
assert_eq!(geometry.max_width, 600.0, "SnackbarKt.ContainerMaxWidth");
assert_eq!(geometry.min_width, 344.0, "G17-d [估] 最小宽");
assert!(geometry.min_width < geometry.max_width, "上下限必须有区间");
assert_eq!(geometry.margin, 12.0, "G17-c [规格] 外边距 12");
assert_eq!(geometry.pad_h, 16.0, "A1 HorizontalSpacing");
assert_eq!(geometry.pad_v, 6.0, "A1 SnackbarVerticalPadding");
}
// ---- §7 Dialog --------------------------------------------------------------
/// 7.4:官方区间 **280..560**,本项目取 **480**,且 480 落在区间内。
#[test]
fn dialog_geometry_sits_inside_the_official_bounds() {
let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.min_width, 280.0, "A1 DialogMinWidth");
assert_eq!(geometry.max_width, 560.0, "A1 DialogMaxWidth");
assert_eq!(geometry.width, 480.0, "G17-e 本项目取值");
assert!(
geometry.min_width <= geometry.width && geometry.width <= geometry.max_width,
"自选宽必须落在官方区间内"
);
}
/// 7.1 / 7.3 / 7.6:圆角 28、内距 24 / 16 / 24、动作 8 / 12。
#[test]
fn dialog_padding_and_action_gaps_are_the_tokens() {
let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.radius, 28.0, "7.1 CornerExtraLarge");
assert_eq!(geometry.padding, 24.0, "7.3 DialogPadding");
assert_eq!(geometry.title_gap, 16.0, "7.3 TitlePadding bottom");
assert_eq!(geometry.body_gap, 24.0, "7.3 TextPadding bottom");
assert_eq!(geometry.action_gap, 8.0, "7.6 ButtonsMainAxisSpacing");
assert_eq!(geometry.action_cross, 12.0, "7.6 ButtonsCrossAxisSpacing");
}
/// 7.7 + D27:scrim = 黑@0.32 叠在 canvas 上之后的**不透明**实色。
#[test]
fn scrim_is_scrimalpha_precomposited_onto_the_canvas() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let scheme = Scheme::baseline(flavor);
let canvas = scheme.get(MaterialLayer::Canvas.surface_role());
let expected = to_iced(scheme.scrim().over(canvas));
assert_eq!(
scrim(&ui),
expected,
"{flavor:?}:scrim 必须逐位等于「黑@0.32 叠 canvas」"
);
assert_eq!(
scrim(&ui).a,
1.0,
"D27:CPU 档不出现半透明(预合成后必须不透明)"
);
assert_ne!(
scrim(&ui),
ui.layer(MaterialLayer::Canvas),
"遮罩必须与画布有可见差异"
);
assert!((SCRIM_ALPHA - 0.32).abs() < f32::EPSILON, "令牌口径 α=0.32");
}
}
// ---- §15 空态(项目自定) ----------------------------------------------------
/// 15.1:图标块 96 / 圆角 16 / 图标 32(**全部 `[估]`**,官方没有这个组件)。
#[test]
fn empty_state_geometry_is_the_project_defined_set() {
let geometry = EmptyStateGeometry::of(&ui(SchemeFlavor::Light));
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);
}
// ---- 结构 / 角色 ------------------------------------------------------------
/// 五个入口都能在**任意**消息类型下构建(契约 §2:不 import `app::Message`)。
#[test]
fn every_component_builds_for_a_foreign_message_type() {
#[derive(Clone, Debug, PartialEq)]
enum Other {
Yes,
No,
}
let ui = ui(SchemeFlavor::Light);
let _: Element<'_, Other> = linear(&ui, Progress::determinate(0.5));
let _: Element<'_, Other> = circular(&ui, Progress::indeterminate(0.3));
let _: Element<'_, Other> = snackbar(
&ui,
Snackbar::new("已保存")
.placement(Placement::Top)
.two_lines()
.action("撤销", Other::No)
.dismiss(Other::No),
);
let _: Element<'_, Other> = dialog(
&ui,
Dialog::new("确认删除?", "此操作不可撤销。")
.double(("取消", Other::No), ("删除", Other::Yes)),
);
let _: Element<'_, Other> = empty_state(
&ui,
EmptyState::new(
unlucky_design::icons::IconKind::Instances,
"还没有实例",
"建一个开始游戏",
)
.action("新建", Other::Yes),
);
// 无动作的三种形态也要能构建(DialogActions::None / 无 action 的 Snackbar)
let _: Element<'_, Other> = dialog(&ui, Dialog::new("已是最新版本", "没有可用更新。"));
let _: Element<'_, Other> = snackbar(&ui, Snackbar::new("已复制"));
let _: Element<'_, Other> = empty_state(
&ui,
EmptyState::new(
unlucky_design::icons::IconKind::Logs,
"没有日志",
"启动一次游戏后这里会有内容",
),
);
}
/// 状态层必须来自 `interaction`(不是组件里自己乘 α):hover 层不透明且与容器色不同。
#[test]
fn snackbar_state_layers_come_from_the_interaction_module() {
let ui = ui(SchemeFlavor::Light);
let hover = interaction::overlay_color(
&ui,
Interaction::Hovered,
Role::InversePrimary,
Role::InverseSurface,
)
.expect("hover 有状态层");
assert_eq!(hover.a, 1.0, "状态层必须是实色(CPU/GPU 一致)");
assert_ne!(
hover,
ui.color(Role::InverseSurface),
"叠了 8% 必须看得出来"
);
// 禁用内容走 token 口径(38%),不是「变灰一点」
let disabled = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
assert_ne!(disabled, ui.color(Role::OnSurface));
assert_eq!(disabled.a, 1.0);
}