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,235 @@
//! Dialog(`research/12b` §7):scrim + 容器 + 标题/正文/动作三区。
//!
//! ## 官方值(`[源]` = A1,`DialogTokens` + `AlertDialogKt`,`javap` 核对)
//!
//! | # | 项 | 值 | 落点 |
//! |---|---|---|---|
//! | 7.1 | 圆角 | **28**(`CornerExtraLarge`) | `Component::Dialog.spec().shape` |
//! | 7.2 | 容器 | **`SurfaceContainerHigh`**(`DialogTokens.ContainerColor`) | `Ui::layer(MaterialLayer::Overlay)` |
//! | 7.3 | 内距 | 四边 **24**;标题下 **16**;正文下 **24** | [`DialogGeometry`](`Space` 档位) |
//! | 7.4 | 宽 | 官方 **280..560**;本项目 **480** | [`DialogGeometry::width`] |
//! | 7.5 | 字级 | 标题 `HeadlineSmall 24/32`、正文 `BodyMedium 14/20`、动作 `LabelLarge 14/20 w500` | 组件层取角色 |
//! | 7.6 | 动作区 | 文本 **`Primary`**;主轴 **8** / 交叉 **12**;右对齐 | [`DialogGeometry`] |
//! | 7.7 | 遮罩 | `scrim` = 纯黑 @ **0.32**(`SCRIM_ALPHA`) | [`scrim`] |
//! | 7.8 | 数量 | 一屏 **1 个** modal | 页面责任(组件不计数) |
//!
//! ## 三种动作形态
//!
//! [`DialogActions::None`](纯告知)/ [`DialogActions::Single`](「知道了」)/
//! [`DialogActions::Double`](「取消」+「删除」,右边是主操作)。双动作的 `dismiss` 在左、
//! `confirm` 在右(M3 的阅读序;本项目**不**做「危险操作反色」——官方 Dialog 动作只有 `Primary`)。
//!
//! ## scrim 为什么是**不透明预合成**
//!
//! 官方是「纯黑 @0.32 盖在内容上」。D27 要求 CPU 档零半透明(否则 CPU/GPU 两图不一致),
//! 所以 [`scrim`] 返回的是 `黑@0.32` **叠在 `MaterialLayer::Canvas` 上**之后的实色:
//! 逐像素与「真半透明铺在 surface 上」等价(遮罩下通常就是底色),且两档渲染一致。
//! 代价:遮罩下若是图片/卡片,合成目标不是 surface —— 这条登记为偏离 D-2。
//!
//! ## 不画阴影(偏离 D-1)
//!
//! 官方 L3 有阴影,但 D27(CPU 禁模糊)+ 令牌层 `ShadowSpec` 仍是 M63 占位值 ⇒ 先不画,
//! 层级由 `SurfaceContainerHigh` + scrim 表达。
use iced::widget::{column, container, row, text, Space};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::{Role, Scheme};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::TypeRole;
use crate::theme::{to_iced, Ui};
use super::super::interaction::Interaction;
use super::action;
use super::panel::DialogGeometry;
/// 动作区的三种形态(「无动作」「单动作」「双动作」)。
#[derive(Debug, Clone)]
pub enum DialogActions<'a, M> {
/// 无动作:只有标题与正文(纯告知型,例如「已是最新版本」)。
None,
/// 单动作:确认/知道了。
Single {
/// 动作文案。
label: &'a str,
/// 按下后的消息。
on_press: M,
},
/// 双动作:左侧 dismiss、右侧 confirm。
Double {
/// 左(次要)动作。
dismiss: (&'a str, M),
/// 右(主)动作。
confirm: (&'a str, M),
},
}
impl<'a, M> DialogActions<'a, M> {
/// 是否为空(图例/测试用)。
pub fn is_empty(&self) -> bool {
matches!(self, DialogActions::None)
}
}
/// 一个对话框的装配描述(**不持有任何状态所有权**)。
#[derive(Debug, Clone)]
pub struct Dialog<'a, M> {
title: &'a str,
body: &'a str,
actions: DialogActions<'a, M>,
state: Option<Interaction>,
}
impl<'a, M> Dialog<'a, M> {
/// 标题 + 正文,默认无动作。
pub fn new(title: &'a str, body: &'a str) -> Self {
Self {
title,
body,
actions: DialogActions::None,
state: None,
}
}
/// 单动作。
pub fn confirm(mut self, label: &'a str, on_press: M) -> Self {
self.actions = DialogActions::Single { label, on_press };
self
}
/// 双动作(左 dismiss / 右 confirm)。
pub fn double(mut self, dismiss: (&'a str, M), confirm: (&'a str, M)) -> Self {
self.actions = DialogActions::Double { dismiss, confirm };
self
}
/// 强制动作件的视觉态(gallery 取证用;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// 遮罩色:`黑 @ SCRIM_ALPHA` 叠在 `MaterialLayer::Canvas` 上之后的**实色**(D27)。
pub fn scrim(ui: &Ui) -> iced::Color {
let scheme = Scheme::baseline(ui.flavor());
let canvas = scheme.get(MaterialLayer::Canvas.surface_role());
to_iced(scheme.scrim().over(canvas))
}
/// 把内容放进一块遮罩里(模态的「背后压暗」)。页面把它压在自己的内容上面即可。
pub fn scrimmed<'a, M: 'a>(ui: &'a Ui, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
let backdrop = scrim(ui);
container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(backdrop)),
..container::Style::default()
})
.into()
}
/// 画一个对话框(不含遮罩;遮罩用 [`scrimmed`])。
pub fn dialog<'a, M: Clone + 'a>(ui: &'a Ui, spec: Dialog<'a, M>) -> Element<'a, M> {
let geometry = DialogGeometry::of(ui);
let surface = ui.layer(MaterialLayer::Overlay);
let radius = iced::border::Radius::new(geometry.radius);
let title_style = ui.style(TypeRole::HeadlineSmall);
let body_style = ui.style(TypeRole::BodyMedium);
let mut content = column![].width(Length::Fill).spacing(0.0).push(
text(spec.title)
.size(title_style.size_px)
.font(ui.font_of(title_style))
.color(ui.color(Role::OnSurface)),
);
content = content.push(gap(geometry.title_gap)).push(
text(spec.body)
.size(body_style.size_px)
.font(ui.font_of(body_style))
.color(ui.color(Role::OnSurfaceVariant)),
);
if !spec.actions.is_empty() {
content = content.push(gap(geometry.body_gap)).push(actions_row(
ui,
&geometry,
spec.actions,
spec.state,
));
}
container(content)
.width(Length::Fixed(geometry.width))
.max_width(geometry.max_width)
.padding(Padding::from(geometry.padding))
.style(move |_theme| container::Style {
background: Some(Background::Color(surface)),
border: Border {
radius,
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 两区之间的固定留白(A1 的「标题下 16 / 正文下 24」)。
fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> {
Space::new()
.width(Length::Fill)
.height(Length::Fixed(height))
.into()
}
/// 动作区:右对齐,主轴间距 8(A1 `ButtonsMainAxisSpacing`)。
fn actions_row<'a, M: Clone + 'a>(
ui: &'a Ui,
geometry: &DialogGeometry,
actions: DialogActions<'a, M>,
state: Option<Interaction>,
) -> Element<'a, M> {
let controls = match actions {
DialogActions::None => row![],
DialogActions::Single { label, on_press } => row![action::text_action(
ui,
label,
Role::Primary,
MaterialLayer::Overlay.surface_role(),
Some(on_press),
state,
)],
DialogActions::Double { dismiss, confirm } => row![
action::text_action(
ui,
dismiss.0,
Role::Primary,
MaterialLayer::Overlay.surface_role(),
Some(dismiss.1),
state,
),
action::text_action(
ui,
confirm.0,
Role::Primary,
MaterialLayer::Overlay.surface_role(),
Some(confirm.1),
state,
),
],
};
container(
controls
.spacing(geometry.action_gap)
.align_y(Alignment::Center),
)
.width(Length::Fill)
.align_x(Alignment::End)
.into()
}