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 个。")