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:
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);
|
||||
}
|
||||
Reference in new issue
Block a user