From 10d4f86fdf037b19f66b481a80e7f6b9a9586218 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 12:19:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(iced/feedback):=20=E5=9C=86=E8=A7=92/?= =?UTF-8?q?=E6=8F=8F=E8=BE=B9=E5=B8=A6=E6=9D=A5=E7=9A=84=20probe=20?= =?UTF-8?q?=E9=98=88=E5=80=BC=E8=AE=A2=E6=AD=A3=20+=20Dialog=20=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6=E9=80=83=E7=94=9F=E5=8F=A3=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮被 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:` 行。 --- .../src/components/feedback/dialog.rs | 26 +++++++++++++++---- .../src/components/feedback/empty_state.rs | 7 +---- .../src/components/feedback/keyframes.rs | 12 ++++++--- .../src/components/feedback/probe/panels.rs | 19 +++++++------- .../app-iced/src/components/feedback/tests.rs | 7 ++++- .../src/components/feedback/tests/progress.rs | 14 +++++++--- .../src/components/gallery/feedback.rs | 7 ++++- 7 files changed, 63 insertions(+), 29 deletions(-) diff --git a/crates/app-iced/src/components/feedback/dialog.rs b/crates/app-iced/src/components/feedback/dialog.rs index 705f7f6..b576235 100644 --- a/crates/app-iced/src/components/feedback/dialog.rs +++ b/crates/app-iced/src/components/feedback/dialog.rs @@ -82,6 +82,7 @@ pub struct Dialog<'a, M> { title: &'a str, body: &'a str, actions: DialogActions<'a, M>, + max_width: f32, state: Option, } @@ -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)), diff --git a/crates/app-iced/src/components/feedback/empty_state.rs b/crates/app-iced/src/components/feedback/empty_state.rs index 6f72391..d173cc0 100644 --- a/crates/app-iced/src/components/feedback/empty_state.rs +++ b/crates/app-iced/src/components/feedback/empty_state.rs @@ -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> { diff --git a/crates/app-iced/src/components/feedback/keyframes.rs b/crates/app-iced/src/components/feedback/keyframes.rs index d20b703..f85fa4d 100644 --- a/crates/app-iced/src/components/feedback/keyframes.rs +++ b/crates/app-iced/src/components/feedback/keyframes.rs @@ -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 { diff --git a/crates/app-iced/src/components/feedback/probe/panels.rs b/crates/app-iced/src/components/feedback/probe/panels.rs index 8758ca2..f1d35f4 100644 --- a/crates/app-iced/src/components/feedback/probe/panels.rs +++ b/crates/app-iced/src/components/feedback/probe/panels.rs @@ -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)------------------------- diff --git a/crates/app-iced/src/components/feedback/tests.rs b/crates/app-iced/src/components/feedback/tests.rs index d0bd523..afb2b32 100644 --- a/crates/app-iced/src/components/feedback/tests.rs +++ b/crates/app-iced/src/components/feedback/tests.rs @@ -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)" + ); } // ---- 结构 / 角色 ------------------------------------------------------------ diff --git a/crates/app-iced/src/components/feedback/tests/progress.rs b/crates/app-iced/src/components/feedback/tests/progress.rs index 1e725ef..84b9622 100644 --- a/crates/app-iced/src/components/feedback/tests/progress.rs +++ b/crates/app-iced/src/components/feedback/tests/progress.rs @@ -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!( diff --git a/crates/app-iced/src/components/gallery/feedback.rs b/crates/app-iced/src/components/gallery/feedback.rs index 2ce10fd..7c2f55e 100644 --- a/crates/app-iced/src/components/gallery/feedback.rs +++ b/crates/app-iced/src/components/gallery/feedback.rs @@ -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 个。")