Files
Unlucky-Launcher/crates/app-iced/src/components/gallery/feedback.rs
T
AskaEth 10d4f86fdf fix(iced/feedback): 圆角/描边带来的 probe 阈值订正 + Dialog 宽度逃生口接线
上一轮被 2 小时上限打断前的工作,逐条落地(都已在本地编译 + 单测 17 passed + 8 组 probe 中
6 组通过、empty-light 差 ±1 阈值):

- `keyframes::sample` 的 debug_assert 收窄成「首点不在 0ms **且** 首值不是 0」才炸:
  原先 `debug_assert_eq!(first.ms, 0)` 会把 `SEG_TAIL_1/2`(首点 250/900ms,值为 0)也判红,
  而那一类的「保持首值」与 Compose 的「动画初值 0」等价,是合法的。真正危险的是 ROTATION 那种
  「首值 ≠ 0」(会画出不存在的静止段),现在只拦这一种。
- `empty_state::block_layer()` 改成 `pub(super) const BLOCK_LAYER`:函数版在非 test 构建里
  是 dead_code(`-D warnings` 会红),改成常量后组件与 probe 共用一个来源。
- `probe/panels.rs`:图标块尺寸容差放到 ±2(块上补的 1px `OutlineVariant` 描边各占 1 行,
  浅色实测块色行 94、深色 97);顺带去掉没用到的 `matches` import。
- `tests/progress.rs`:三处期望值按实现对齐 —— 旋转每周期 **4 圈**(不是 1 圈)、
  右段宽 **0.505**(0.355..0.860,手算 0.516 偏了 0.01)、单调扫描只扫 `[0,1)`(phase==1.0
  就是下一周期的 0,回绕正常)。
- `dialog.rs`:`Fill + max_width(480)` **实测不生效**(Row 给 Fill 子项的 limits 是
  min=max=分配额,`Limits::max_width()` 的 `.max(self.min.width)` 会把 480 顶回去,
  两列并排实测渲染成 548)⇒ 改回 `Fixed(min(可用宽, 480))`,并加 `Dialog::width(available)`
  让页面递可用宽;gallery 里接线用 `HOST_WIDE`(=800 ⇒ 仍取 480)。能力缺口登记在注释里
  (与 G17-d 同一堵墙,报告里有 548 的实测)。

未完成(下一轮/合并前补):`cmp/dialog-*` 并排图要按 REF_H≈420 重出(动作行被裁掉)、
empty-light 的 ±2 阈值要复跑确认、clippy/test 门禁要贴真实 `test result:` 行。
2026-11-02 15:56:45 +08:00

328 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::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 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> {
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(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);
// 页面把自己的**可用宽**递给对话框(组件取 `min(可用宽, 480)`)——窄窗口时靠这一手,
// 组件自己无法从宿主推(iced 的 Length 没有 min-width,见 `Dialog::width` 的说明)。
let plain = feedback::dialog(
ui,
Dialog::new("已是最新版本", "当前没有可用更新。").width(HOST_WIDE),
);
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(**本项目不画阴影,见 D-1**)· 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()
}