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