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,253 @@
//! 进度指示器(`research/12b` §12):`linear` 与 `circular`,各有确定态与不定态。
//!
//! ## 两种形态、四种态
//!
//! | 形态 | 确定态 | 不定态 |
//! |---|---|---|
//! | [`linear`] | 轨道 `SecondaryContainer` + 指示条 `Primary`,厚 **4**、圆角 full | 官方 1750ms **双段**追逐([`phase_segments`]) |
//! | [`circular`] | 40 环 + 4 线宽,弧长 = 数值 × 360° | 官方 6000ms(弧长 0.1↔0.87 + 旋转 0↔360,[`CircularGeometry::indeterminate`]) |
//!
//! ## 为什么不定态要调用方给 `phase`
//!
//! 契约 §2:组件不持有时钟。`phase`(0..1 的一个周期)由页面按 `Subscription` 推进;
//! harness 因此能拍**确定**的中间帧(否则每张图都是抓阄)。周期时长在几何里
//! ([`LinearGeometry::cycle_ms`] / [`CircularGeometry::cycle_ms`]),调用方按它换算。
//!
//! ## CPU 降级路径(D27)
//!
//! 本组件**零半透明、零模糊**:轨道/指示条都是不透明实色,环形是 `stroke` 实色。
//! 波浪形态(线性振幅 3 / 波长 40,环形振幅 1.6 / 波长 15,`[源]`)**未实现**,
//! 用直线段/等弧长替代——这是 `12b §12.4` 明确允许的登记项(见报告「偏离登记」)。
//!
//! ## 环形为什么手搓 SVG
//!
//! iced 的 `canvas` feature 没开(`Cargo.toml` 不归本 mission),而圆弧用容器边框画不出来。
//! 令牌图标的做法(24 网格 SVG path + `currentColor` + 内容哈希缓存)同样适用于几何图元:
//! 一份 handle 渲染两层(轨道一层、弧一层),相位不变时 iced 侧命中缓存。
use iced::border::Radius;
use iced::widget::{container, row, svg, Space};
use iced::{Background, Border, Element, Length};
use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::layout;
use super::geometry::{phase_segments, CircularGeometry, LinearGeometry};
/// `FillPortion` 的比例分辨率:0..1 的占比 × 1000 取整(1/1000 像素级,够用且不溢出 u16)。
const PORTIONS: f32 = 1000.0;
/// 弧长小于这个角度就不画激活层(圆头在 0 弧长上会画出一个点,看着像「已完成」)。
const MIN_SWEEP_DEG: f32 = 1.0;
/// 100% 时弧首尾重合,`A` 命令画不出整圆 ⇒ 超过这个角度改画整环。
const FULL_SWEEP_DEG: f32 = 359.9;
/// 进度的取值:确定(0..1)或不定(相位 0..1)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Amount {
/// 确定态:0..1。
Determinate(f32),
/// 不定态:一个周期内的相位(0..1,可越界,采样时按周期取模)。
Indeterminate(f32),
}
/// 一个进度指示器的取值(**不持有任何状态**)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Progress {
amount: Amount,
}
impl Progress {
/// 确定态:`value` 越界会被夹到 0..1(0% 只画轨道)。
pub fn determinate(value: f32) -> Self {
Self {
amount: Amount::Determinate(value.clamp(0.0, 1.0)),
}
}
/// 不定态:`phase` 是周期内的相位(调用方按 `cycle_ms` 推进)。
pub fn indeterminate(phase: f32) -> Self {
Self {
amount: Amount::Indeterminate(phase),
}
}
/// 取值(断言用;生产路径不读它,内部直接 match 字段)。
#[cfg(test)]
pub fn amount(self) -> Amount {
self.amount
}
/// 本帧要画的占轨道比例区间(0..1)。
///
/// 确定态 1 段、不定态 2 段(官方双线);区间可能为空(相位在「两段都在屏幕外」的时刻),
/// 调用方按 `end > start` 过滤。
pub fn spans(self) -> Vec<(f32, f32)> {
match self.amount {
Amount::Determinate(value) => vec![(0.0, value.clamp(0.0, 1.0))],
Amount::Indeterminate(phase) => phase_segments(phase).to_vec(),
}
}
}
/// 线性进度条(轨道 + 指示条,厚 4、圆角 full)。
///
/// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**,
/// 所以 50% 在任何宿主宽度下都精确是半条。
pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
let geometry = LinearGeometry::of(ui);
let track_color = ui.color(Role::SecondaryContainer);
let active_color = ui.color(Role::Primary);
let mut bar = row![]
.width(Length::Fill)
.height(Length::Fixed(geometry.thickness));
let mut cursor = 0.0_f32;
for (start, end) in progress.spans() {
let (start, end) = (start.clamp(0.0, 1.0), end.clamp(0.0, 1.0));
if end <= start {
continue;
}
if start > cursor {
bar = bar.push(spacer(start - cursor));
}
bar = bar.push(
container(Space::new().width(Length::Fill).height(Length::Fill))
.width(Length::FillPortion(portion(end - start)))
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(active_color)),
border: Border {
radius: Radius::new(geometry.radius),
..Border::default()
},
..container::Style::default()
}),
);
cursor = end;
}
if cursor < 1.0 {
bar = bar.push(spacer(1.0 - cursor));
}
container(bar)
.width(Length::Fill)
.height(Length::Fixed(geometry.thickness))
.style(move |_theme| container::Style {
background: Some(Background::Color(track_color)),
border: Border {
radius: Radius::new(geometry.radius),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 环形进度(40 直径 / 4 线宽;确定态画弧,不定态按官方节奏扫)。
pub fn circular<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
let geometry = CircularGeometry::of(ui);
let (sweep, rotation) = match progress.amount {
Amount::Determinate(value) => (value.clamp(0.0, 1.0) * 360.0, 0.0),
Amount::Indeterminate(phase) => {
let (sweep_fraction, rotation_fraction) = geometry.indeterminate(phase);
(sweep_fraction * 360.0, rotation_fraction * 360.0)
}
};
let track = glyph(
&geometry,
ring_source(&geometry),
ui.color(Role::SecondaryContainer),
);
// 0% 只画轨道(不画弧,也不画圆头小点);100% 画整环(`A` 命令的首尾重合画不出整圆)
let active = if sweep < MIN_SWEEP_DEG {
None
} else if sweep >= FULL_SWEEP_DEG {
Some(glyph(
&geometry,
ring_source(&geometry),
ui.color(Role::Primary),
))
} else {
Some(glyph(
&geometry,
arc_source(&geometry, sweep, rotation),
ui.color(Role::Primary),
))
};
let mut layers = vec![track];
layers.extend(active);
// Q9 的同族:几何图元也必须落在**固定尺寸**的盒子里,否则 usvg 会按 Contain 撑满父级。
layout::fixed(
geometry.diameter,
geometry.diameter,
iced::widget::stack(layers),
)
}
/// 两段之间/两端的透明占位。
fn spacer<'a, M: 'a>(fraction: f32) -> Element<'a, M> {
Space::new()
.width(Length::FillPortion(portion(fraction)))
.height(Length::Fill)
.into()
}
/// 0..1 的占比 → `FillPortion`(≥1,避免 0 段;调用方已过滤空段)。
fn portion(fraction: f32) -> u16 {
(fraction.clamp(0.0, 1.0) * PORTIONS).round().max(1.0) as u16
}
/// 一层几何图元(固定尺寸 + `currentColor` 上色)。
fn glyph<'a, M: 'a>(
geometry: &CircularGeometry,
source: String,
color: iced::Color,
) -> Element<'a, M> {
svg(svg::Handle::from_memory(source.into_bytes()))
.width(Length::Fixed(geometry.diameter))
.height(Length::Fixed(geometry.diameter))
.style(move |_theme, _status| svg::Style { color: Some(color) })
.into()
}
/// 整环(轨道用,也用作 100% 的激活层)。
fn ring_source(geometry: &CircularGeometry) -> String {
let (size, thickness) = (geometry.diameter, geometry.thickness);
let center = size / 2.0;
let radius = geometry.arc_radius();
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{size}\" height=\"{size}\" \
viewBox=\"0 0 {size} {size}\"><circle cx=\"{center}\" cy=\"{center}\" r=\"{radius}\" \
fill=\"none\" stroke=\"currentColor\" stroke-width=\"{thickness}\"/></svg>"
)
}
/// 一段圆弧(`rotation` 起、扫 `sweep_deg`;0° = 12 点、顺时针 = M3 口径)。
fn arc_source(geometry: &CircularGeometry, sweep_deg: f32, rotation_deg: f32) -> String {
let (size, thickness) = (geometry.diameter, geometry.thickness);
let center = size / 2.0;
let radius = geometry.arc_radius();
let (x0, y0) = point(center, radius, rotation_deg);
let (x1, y1) = point(center, radius, rotation_deg + sweep_deg);
let large = u8::from(sweep_deg > 180.0);
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{size}\" height=\"{size}\" \
viewBox=\"0 0 {size} {size}\"><path d=\"M {x0:.3} {y0:.3} A {radius} {radius} 0 \
{large} 1 {x1:.3} {y1:.3}\" fill=\"none\" stroke=\"currentColor\" \
stroke-width=\"{thickness}\" stroke-linecap=\"round\"/></svg>"
)
}
/// 极角(0° = 12 点,顺时针)→ 画布坐标(y 向下,所以向上取负)。
fn point(center: f32, radius: f32, deg: f32) -> (f32, f32) {
let rad = deg.to_radians();
(center + radius * rad.sin(), center - radius * rad.cos())
}