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

@@ -1,22 +1,322 @@
//! gallery 里的反馈类小节(**桩**):后续 mission 只改本文件。
//! gallery 里的反馈类小节:把状态矩阵摆进 harness(**断言不在这里**,在
//! [`crate::app::components::feedback::probe`])。
//!
//! ## 段落切分:`ULL_FEEDBACK_PART`(本文件自己的开关)
//!
//! `ULL_GALLERY_SECTION=feedback` 只能把**整个家族**拎出来,而四件的高度超过 1180×760 首屏,
//! 所以再加一层**家族内**切分(契约 §9.2 的口径:环境变量 + 默认向后兼容 + 未识别的值回落
//! 默认并打 stderr;区别只是它住在**本家族自己的文件**里,不动 `gallery/mod.rs`):
//!
//! ```text
//! ULL_FEEDBACK_PART=all # 默认:四件全渲染(progress → snackbar → empty → dialog)
//! ULL_FEEDBACK_PART=progress # Linear 0/50/100 + 三相位 · Circular 0/25/50/75/100 + 不定态
//! ULL_FEEDBACK_PART=snackbar # 四格:贴顶·上限宽 / 双行 / 宿主 300 / 宿主 344
//! ULL_FEEDBACK_PART=dialog # 两形态并排 + 双动作 + 一屏 scrim
//! ULL_FEEDBACK_PART=empty # 空态(带 CTA)
//! ```
//!
//! ## 宿主尺寸为什么写死
//!
//! 量宽度必须有确定的宿主宽:`Snackbar` 的 600 是「宿主给 800 时的夹取值」、344 与 300 是
//! 「宿主给多少就是多少」的反证(组件没有 min-width,G17-d)。`Linear` 轨道统一 320(一行 3 格 = 960 < 1128)。
//!
//! 复现(worktree 根目录):
//!
//! ```text
//! ULL_GALLERY_SECTION=feedback ULL_FEEDBACK_PART=progress ./target/debug/unlucky-launcher-iced \
//! --software-renderer --screen=gallery \
//! --screenshot target/m78/progress-light.png --screenshot-theme=light
//! ULL_GALLERY_SHOT=target/m78/progress-light.png cargo test -p app-iced feedback::probe
//! ```
use iced::Element;
use iced::widget::{column, container, row, text};
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use super::super::feedback;
use crate::theme::Ui;
use super::super::feedback::gallery_parts::{Part, DIALOG, EMPTY, PROGRESS, SNACKBAR};
use super::super::feedback::{self, Dialog, EmptyState, Placement, Progress, Snackbar};
use super::super::interaction::Interaction;
use super::{heading, DemoMessage};
/// 线性进度的宿主宽(格宽,不是组件尺寸)。
const TRACK_HOST: f32 = 320.0;
/// `Snackbar` 的宿主宽:**条宽 + 2×12 外边距**(12 是官方外边距,组件的定位层会扣掉)。
///
/// 800(条夹到上限 600)/ 368(条 344)/ 324(条 300,用来证明**没有**下限,G17-d)。
const HOST_WIDE: f32 = 800.0;
const HOST_MIN: f32 = 368.0;
const HOST_NARROW: f32 = 324.0;
/// `Snackbar` 宿主高(比 68 + 2×12 略高,留出贴顶/贴底的判定余量)。
const HOST_TALL: f32 = 96.0;
/// 单行提示条的宿主高(48 + 2×12 外边距 = 72,正好装下)。
const HOST_SHORT: f32 = 72.0;
/// 空态宿主高:内容本征高 ≈96+16+32+8+16+24+40 ≈ 232,留出上下各 40 的余量。
const HOST_EMPTY: f32 = 320.0;
/// 反馈类家族的小节。
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
iced::widget::column![
heading(
ui,
"feedback · progress / snackbar / dialog / empty state",
"12b §7/§12/§14/§15 —— pending(后续 mission 填充本文件)"
),
feedback::demo::<DemoMessage>(ui),
let part = Part::from_env();
part.log_once();
let mut body = column![heading(
ui,
"feedback · progress / snackbar / dialog / empty state",
"12b §7/§12/§14/§15:Linear 厚 4 圆角 full · Circular 40/4 · Snackbar 48/68 上限宽 600 · \
Dialog R28 pad24 宽 480 · EmptyState 项目自定[估](官方无此组件)"
)]
.spacing(ui.space(Space::X3));
if part.has(PROGRESS) {
body = body.push(progress_block(ui));
}
if part.has(SNACKBAR) {
body = body.push(snackbar_block(ui));
}
if part.has(EMPTY) {
body = body.push(empty_block(ui));
}
if part.has(DIALOG) {
// 只有这一屏不画进度/提示条时,才把三形态与 scrim 都摆开(高度够)。
let roomy = !part.has(PROGRESS) && !part.has(SNACKBAR);
body = body.push(dialog_block(ui, roomy));
}
body.into()
}
/// 一行判据说明(`LabelSmall`,与矩阵的正文档分开)。
fn caption<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::LabelSmall);
text(value)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant))
.into()
}
/// 固定宽宿主(量宽度用:宿主宽写死,组件内部的占比/夹取才有确定值)。
fn host<'a>(
width: f32,
height: Option<f32>,
content: impl Into<Element<'a, DemoMessage>>,
) -> Element<'a, DemoMessage> {
let mut box_ = container(content).width(Length::Fixed(width));
if let Some(height) = height {
box_ = box_.height(Length::Fixed(height));
}
box_.into()
}
/// 一列:[进度条, 说明]。
fn track<'a>(ui: &'a Ui, label: &'a str, progress: Progress) -> Element<'a, DemoMessage> {
column![
host(TRACK_HOST, None, feedback::linear(ui, progress)),
caption(ui, label)
]
.spacing(ui.space(unlucky_design::spacing::Space::X2))
.spacing(ui.space(Space::X2))
.into()
}
/// 一列:[环, 说明]。
fn ring<'a>(ui: &'a Ui, label: &'a str, progress: Progress) -> Element<'a, DemoMessage> {
column![feedback::circular(ui, progress), caption(ui, label)]
.spacing(ui.space(Space::X2))
.into()
}
/// §12:Linear 0/50/100 + 四个相位;Circular 0/25/50/75/100 + 三个相位。
fn progress_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let determinate = row![
track(ui, "0%", Progress::determinate(0.0)),
track(ui, "50%", Progress::determinate(0.5)),
track(ui, "100%", Progress::determinate(1.0)),
]
.spacing(ui.space(Space::X3));
let indeterminate = row![
track(ui, "phase 0.15", Progress::indeterminate(0.15)),
track(ui, "phase 0.35", Progress::indeterminate(0.35)),
track(ui, "phase 0.55", Progress::indeterminate(0.55)),
]
.spacing(ui.space(Space::X3));
let rings = row![
ring(ui, "0%", Progress::determinate(0.0)),
ring(ui, "25%", Progress::determinate(0.25)),
ring(ui, "50%", Progress::determinate(0.5)),
ring(ui, "75%", Progress::determinate(0.75)),
ring(ui, "100%", Progress::determinate(1.0)),
ring(ui, "ind 0.30", Progress::indeterminate(0.30)),
]
.spacing(ui.space(Space::X4))
.align_y(iced::Alignment::Start);
column![
caption(
ui,
"Linear · determinate 0% / 50% / 100%(轨道 SecondaryContainer · 指示条 Primary)"
),
determinate,
caption(
ui,
"Linear · indeterminate(官方 1750ms 双段追逐;下面是同一周期的三个相位)"
),
indeterminate,
caption(
ui,
"Circular · 直径 40 · 线宽 4 · 弧长 = 数值 × 360°(第六格 = 不定态 phase 0.30)"
),
rings,
]
.spacing(ui.space(Space::X2))
.into()
}
/// 提示条的宿主:固定宽 + 提示条宿主高(比 68 + 2×12 略高,贴顶/贴底都量得到)。
fn bar_host<'a>(
width: f32,
content: impl Into<Element<'a, DemoMessage>>,
) -> Element<'a, DemoMessage> {
host(width, Some(HOST_TALL), content)
}
/// §14:四格 —— 贴顶·上限宽 / 双行 + action + 关闭 / 宿主 300 / 宿主 344。
fn snackbar_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let press = |id| DemoMessage::Pressed(id);
let wide = bar_host(
HOST_WIDE,
feedback::snackbar(
ui,
Snackbar::new("版本清单已刷新")
.placement(Placement::Top)
.dismiss(press("snackbar/wide")),
),
);
let two_lines = host(
HOST_WIDE,
Some(HOST_TALL * 2.0),
feedback::snackbar(
ui,
Snackbar::new(
"启动失败:找不到 Java 21。请在「设置 · Java」里指定运行时路径,或让启动器自动下载一个 21 的运行时。",
)
.two_lines()
.placement(Placement::Top)
.action("去设置", press("snackbar/settings"))
.dismiss(press("snackbar/close"))
.state(Interaction::Hovered),
),
);
let mid = host(
HOST_MIN,
Some(HOST_SHORT),
feedback::snackbar(
ui,
Snackbar::new("已删除实例")
.placement(Placement::Bottom)
.action("撤销", press("snackbar/undo")),
),
);
let narrow = host(
HOST_NARROW,
Some(HOST_SHORT),
feedback::snackbar(ui, Snackbar::new("宿主 324").placement(Placement::Bottom)),
);
column![
caption(
ui,
"Snackbar · 贴顶 + 上限宽(宿主 800 → 夹到 600)· 关闭键 InverseOnSurface"
),
wide,
caption(
ui,
"Snackbar · 双行 68 + action InversePrimary + 关闭键(动作强制 hover 态)"
),
two_lines,
caption(
ui,
"宿主 324 ⇒ 条 300:组件没有 min-width,宿主给多少就是多少(G17-d)"
),
narrow,
caption(ui, "宿主 368 ⇒ 条 344:最小宽的取值口径([估])"),
mid,
]
.spacing(ui.space(Space::X2))
.into()
}
/// §15:空态(项目自定;浅/深两种主题各出一张图,主题来自 `--screenshot-theme`)。
fn empty_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let press = |id| DemoMessage::Pressed(id);
host(
HOST_WIDE,
Some(HOST_EMPTY),
feedback::empty_state(
ui,
EmptyState::new(
IconKind::Instances,
"还没有任何实例",
"建一个实例,或者从整合包导入。[项目自定 · 估]",
)
.action("新建实例", press("empty/create")),
),
)
}
/// §7:三形态(无动作 / 单动作 / 双动作)+ 一屏 scrim。
///
/// `full = true` 表示**这一屏只渲染 dialog 段**(`ULL_FEEDBACK_PART=dialog` 或 `=empty` 之类
/// 单独出图):此时多摆一屏 scrim,并把三形态都摆开;全家族同屏时留高度给别的段。
fn dialog_block<'a>(ui: &'a Ui, full: bool) -> Element<'a, DemoMessage> {
let press = |id| DemoMessage::Pressed(id);
let plain = feedback::dialog(ui, Dialog::new("已是最新版本", "当前没有可用更新。"));
let single = feedback::dialog(
ui,
Dialog::new("已导入整合包", "共 42 个模组,缺失 0 个。")
.confirm("知道了", press("dialog/ok")),
);
// 强制 hover:动作件的状态层必须来自 `interaction`(不是组件里自己乘 8%)。
let double = feedback::dialog(
ui,
Dialog::new("删除实例「1.20.1 生存」?", "存档与模组会一起删除。")
.double(
("取消", press("dialog/cancel")),
("删除", press("dialog/delete")),
)
.state(Interaction::Hovered),
);
let mut body = column![caption(
ui,
"Dialog · R28 · SurfaceContainerHigh + L3 · pad 24(标题下 16 / 正文下 24)· 动作 Primary 间距 8",
)]
.spacing(ui.space(Space::X2));
body = body.push(row![plain, single].spacing(ui.space(Space::X6)));
if full {
body = body.push(double);
body = body.push(caption(
ui,
"Dialog · scrim = 黑@0.32 叠 canvas 的实色(D27 预合成;详见 dialog.rs 模块文档)",
));
body = body.push(host(
HOST_WIDE,
Some(HOST_EMPTY),
feedback::scrimmed(
ui,
feedback::dialog(ui, Dialog::new("已是最新版本", "当前没有可用更新。")),
),
));
}
body.into()
}