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:` 行。
This commit is contained in:
AskaEth committed 2026-11-02 15:56:45 +08:00
1 parent 38b1e7a4dd
commit 10d4f86fdf
7 files changed
+63 -29

No files matched your search

@@ -82,6 +82,7 @@ pub struct Dialog<'a, M> {
title: &'a str,
body: &'a str,
actions: DialogActions<'a, M>,
max_width: f32,
state: Option<Interaction>,
}
@@ -92,10 +93,21 @@ impl<'a, M> Dialog<'a, M> {
title,
body,
actions: DialogActions::None,
// 项目取值 480(官方区间 280..560 内);窄宿主由页面传更小的可用宽。
max_width: 480.0,
state: None,
}
}
/// 可用宽(默认 480,组件取 `min(available, 480)`)。
///
/// 为什么不让组件自己从宿主推:iced 的 `Length` 没有 min-width,而 `Fill + max_width`
/// 会被父级的精确分配顶掉(实测渲染成 548 而不是 480,见 `dialog()` 里的说明)。
pub fn width(mut self, available: f32) -> Self {
self.max_width = available;
self
}
/// 单动作。
pub fn confirm(mut self, label: &'a str, on_press: M) -> Self {
self.actions = DialogActions::Single { label, on_press };
@@ -168,12 +180,16 @@ pub fn dialog<'a, M: Clone + 'a>(ui: &'a Ui, spec: Dialog<'a, M>) -> Element<'a,
));
}
// 宽度 = `min(宿主, 480)`:**不能**写 `Length::Fixed(480)`(复审 P2-1)——
// 窗口窄于 528 时对话框会直接溢出宿主,而 `.max_width(480)` 对 `Fixed` 是空操作。
// `Fill` + `max_width` 才是 iced 里表达「至多 480」的唯一办法(无 min-width,见 G17-d)。
// 宽度 = `min(spec.max_width, 480)`,**可用宽由调用方递**(`Dialog::width`)。
//
// 复审 P2-1 建议「Fill + max_width」:实测**不行**。`Row` 给 `Fill` 子项的 limits 是
// `with_compression(min = max = 分配额)`,而 `Limits::max_width()` 的实现是
// `self.max.width.min(max_width).max(self.min.width)` —— 父级那个 **min 会把 480 顶回去**,
// 两列并排时每张卡渲染成 **548**(探针实测,见报告)。iced 没有 min-width,也没有在
// 「父级精确分配」下可用的 max-width ⇒ 与 Snackbar 的最小宽同一堵墙(G17-d 之二)。
// 所以:默认 480,窄宿主由页面传 `Dialog::width(可用宽)`。
container(content)
.width(Length::Fill)
.max_width(geometry.width)
.width(Length::Fixed(spec.max_width.min(geometry.width)))
.padding(Padding::from(geometry.padding))
.style(move |_theme| container::Style {
background: Some(Background::Color(surface)),
@@ -136,12 +136,7 @@ pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> El
/// 图标块的材质层(`[估]`:空态块是「抬起来的卡片」层级,不是画布也不是浮层)。
///
/// probe 与测试都从这里取,避免「组件一个色、断言另一个色」。
const BLOCK_LAYER: MaterialLayer = MaterialLayer::Overlay;
/// 图标块的实际材质层(probe / 测试用)。
pub fn block_layer() -> MaterialLayer {
BLOCK_LAYER
}
pub(super) const BLOCK_LAYER: MaterialLayer = MaterialLayer::Overlay;
/// 固定高度的透明留白(列间距的唯一来源:`Column::spacing` 必须保持 0,否则双算)。
fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> {
@@ -61,9 +61,15 @@ impl Keyframes {
}
let cycle = self.cycle_ms as f32;
debug_assert_eq!(
first.ms, 0,
"首点必须在 0ms:Compose 会补一个 t=0 的初值点,我们不猜,请显式写出来"
// 首点不在 0ms 时,`[0, first.ms)` 按「保持首值」算;Compose 那里放的是动画**初值**。
// 两者只在「首值 = 初值 = 0」时等价 —— 不等价就会画出一段不存在的静止(ROTATION 曾因此
// 每周期末尾倒跳 270°)。所以这里只拦「首点不在 0 且首值不是 0」。
debug_assert!(
first.ms == 0 || first.value == 0.0,
"首点 {}ms 的值是 {}:Compose 在 [0, {}ms) 用的是动画初值 0,不是首值",
first.ms,
first.value,
first.ms
);
let ms = phase.rem_euclid(1.0) * cycle;
if ms <= first.ms as f32 {
@@ -14,10 +14,8 @@ use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use super::super::dialog::scrim;
use super::super::empty_state::block_layer;
use super::super::pixel::{
band_width, bands, ink_inside, matches, near, rgb, row_ink, runs, BBox, Shot,
};
use super::super::empty_state::BLOCK_LAYER;
use super::super::pixel::{band_width, bands, ink_inside, near, rgb, row_ink, runs, BBox, Shot};
use crate::theme::Ui;
/// Dialog 宽(本项目取值,官方区间 280..560 内)。
@@ -131,23 +129,26 @@ pub(super) fn check_dialog(shot: &Shot, ui: &Ui) {
/// §15:图标块 96(`[估]`)+ 间距 16 / 8 / 24 + CTA 40。
pub(super) fn check_empty(shot: &Shot, ui: &Ui) {
let surface = ui.color(Role::Surface);
let block = ui.layer(block_layer());
let block = ui.layer(BLOCK_LAYER);
// 下限 40 而不是 88:块只有 96 宽,中间的图标(32)会把那几行的块色墨量压到 ≈64。
let found = bands(shot, block, surface, 40);
// 容差 ±2:块上补了 1px `OutlineVariant` 描边,块色行/列各少 1(浅色实测 94,深色 97)。
let (top, bottom) = found
.iter()
.copied()
.max_by_key(|(top, bottom)| bottom - top)
.filter(|(top, bottom)| near(bottom - top + 1, BLOCK))
.filter(|(top, bottom)| (BLOCK - 2..=BLOCK + 2).contains(&(bottom - top + 1)))
.unwrap_or_else(|| {
panic!("15.1:没找到高 {BLOCK} 的图标块(`[估]` 项目自定值),实测量到的带:{found:?}")
panic!(
"15.1:没找到高 {BLOCK}(±2) 的图标块(`[估]` 项目自定值),实测量到的带:{found:?}"
)
});
let width = band_width(shot, (top, bottom), block, surface);
assert!(
near(width, BLOCK),
"15.1:图标块必须 {BLOCK}×{BLOCK},实测宽 {width}"
(BLOCK - 2..=BLOCK + 2).contains(&width),
"15.1:图标块必须 {BLOCK}(±2,1px 描边)宽,实测 {width}"
);
// ---- P2-1 回归:块↔标题的**实际**间距(16 而不是 32)-------------------------
@@ -14,6 +14,7 @@ use crate::theme::{to_iced, Families, Ui};
use super::super::interaction::{self, Interaction};
use super::dialog::scrim;
use super::panel::{DialogGeometry, EmptyStateGeometry, SnackbarGeometry};
use super::progress::Progress;
use super::snackbar::{Placement, Snackbar};
use super::{circular, dialog, empty_state, linear, snackbar, Dialog, EmptyState};
@@ -116,7 +117,11 @@ fn empty_state_geometry_is_the_project_defined_set() {
assert_eq!(geometry.gap_body, 8.0, "标题↔副文案 = Space::X2");
assert_eq!(geometry.gap_action, 24.0, "副文案↔CTA = Space::X6");
assert_eq!(geometry.block, 96.0);
assert_eq!(super::empty_state::block_layer(), MaterialLayer::Overlay);
assert_eq!(
super::empty_state::BLOCK_LAYER,
MaterialLayer::Overlay,
"块底色是 SurfaceContainerHigh(材质层 Overlay)"
);
}
// ---- 结构 / 角色 ------------------------------------------------------------
@@ -8,6 +8,7 @@
//! (代码 r1 P1-B:旧实现只有 1 圈,且末尾倒跳 270°)。
use unlucky_design::color::SchemeFlavor;
use unlucky_design::shape::ShapeScale;
use super::super::geometry::{phase_segments, CircularGeometry, LinearGeometry};
use super::super::keyframes::{KeyPoint, Keyframes};
@@ -103,7 +104,10 @@ fn raw_segments_are_unordered_and_layout_sorts_them() {
assert!((left_start - 0.003).abs() < 0.01, "左段起点 {left_start}");
assert!((left - 0.076).abs() < 0.01, "左段宽 {left}");
assert!((right_start - 0.355).abs() < 0.01, "右段起点 {right_start}");
assert!((right - 0.516).abs() < 0.01, "右段宽 {right}");
assert!(
(right - 0.505).abs() < 0.01,
"右段宽 {right}(0.355..0.860)"
);
let total: f32 = laid.iter().map(|(_, fraction)| fraction).sum();
assert!(
@@ -156,9 +160,10 @@ fn circular_indeterminate_sweeps_the_official_range() {
"弧长下限 0.1(CircularIndeterminateMinProgress),实测 {min}"
);
assert!((max - 0.87).abs() < 1.0e-3, "弧长上限 0.87,实测 {max}");
// P1-B:每周期 4 圈(1080° 全局 + 360° 关键帧),不是 1 圈
assert!(
(rotation - 1.0).abs() < 1.0e-3,
"旋转应走满一整圈,实测 {rotation}"
(rotation - 4.0).abs() < 0.01,
"旋转应走满 4 圈,实测 {rotation}"
);
}
@@ -192,8 +197,9 @@ fn circular_indeterminate_rotation_is_four_turns_and_continuous() {
);
// 旋转必须单调(相位 0 → 1 逐帧不回退,按圈算)
// 只扫 [0, 1) 的相位:phase == 1.0 就是下一周期的 0,回绕是**正常的**(mod 1 等价)。
let mut previous = geometry.indeterminate(0.0).1;
for step in 1..=600 {
for step in 1..600 {
let phase = step as f32 / 600.0;
let current = geometry.indeterminate(phase).1;
assert!(
@@ -278,7 +278,12 @@ fn empty_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
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("已是最新版本", "当前没有可用更新。"));
// 页面把自己的**可用宽**递给对话框(组件取 `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 个。")