From a4cdd61fe532caf6808add3c62e809221aa63f93 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 13:16:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(iced/feedback):=20=E5=A4=8D=E5=AE=A1=20r3?= =?UTF-8?q?=20=E7=9A=84=20A/B/D/E/F=20=E9=A1=B9=20+=20C=20=E9=A1=B9?= =?UTF-8?q?=E5=BE=85=20rebase=20=E5=90=8E=E6=94=B9=20view=5Fat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (先落一个中间提交,rebase main 之后再按 tower 的修正把空态图标改成 32/view_at) --- .../src/components/feedback/dialog.rs | 5 +- .../src/components/feedback/geometry.rs | 12 +- .../src/components/feedback/keyframes.rs | 11 +- .../app-iced/src/components/feedback/mod.rs | 9 +- .../app-iced/src/components/feedback/panel.rs | 24 +- .../app-iced/src/components/feedback/probe.rs | 42 +++- .../src/components/feedback/probe/circular.rs | 13 +- .../src/components/feedback/probe/panels.rs | 233 ++++++++++++------ .../src/components/feedback/progress.rs | 2 +- .../app-iced/src/components/feedback/tests.rs | 3 +- .../src/components/feedback/tests/progress.rs | 26 +- .../src/components/gallery/feedback.rs | 53 +++- 12 files changed, 325 insertions(+), 108 deletions(-) diff --git a/crates/app-iced/src/components/feedback/dialog.rs b/crates/app-iced/src/components/feedback/dialog.rs index b576235..09eed0d 100644 --- a/crates/app-iced/src/components/feedback/dialog.rs +++ b/crates/app-iced/src/components/feedback/dialog.rs @@ -188,8 +188,11 @@ pub fn dialog<'a, M: Clone + 'a>(ui: &'a Ui, spec: Dialog<'a, M>) -> Element<'a, // 两列并排时每张卡渲染成 **548**(探针实测,见报告)。iced 没有 min-width,也没有在 // 「父级精确分配」下可用的 max-width ⇒ 与 Snackbar 的最小宽同一堵墙(G17-d 之二)。 // 所以:默认 480,窄宿主由页面传 `Dialog::width(可用宽)`。 + // 可用宽 → 实际宽:`min(480, 可用宽)` 再 `.max(280)`(官方 `DialogMinWidth`,复审 E)。 + // 可用宽 < 280 时仍画 280(溢出宿主)——iced 无 min-width,这条写进 G17-d 之二。 + let effective = geometry.effective_width(spec.max_width); container(content) - .width(Length::Fixed(spec.max_width.min(geometry.width))) + .width(Length::Fixed(effective)) .padding(Padding::from(geometry.padding)) .style(move |_theme| container::Style { background: Some(Background::Color(surface)), diff --git a/crates/app-iced/src/components/feedback/geometry.rs b/crates/app-iced/src/components/feedback/geometry.rs index e842585..433119d 100644 --- a/crates/app-iced/src/components/feedback/geometry.rs +++ b/crates/app-iced/src/components/feedback/geometry.rs @@ -7,7 +7,7 @@ //! | 12.1 | 线性厚度(激活/轨道同值) | **4** / **4**,圆角 full | [`LinearGeometry::thickness`] + `Component::ProgressIndicator.spec().shape` | //! | 12.2 | 线性颜色 | 激活 `Primary`、轨道 `SecondaryContainer` | 组件层取角色 | //! | 12.3 | 环形直径 / 厚度 / 环↔轨间隙 | **40 / 4 / 4** | [`CircularGeometry`] | -//! | 12.4 | 不定态节奏 | 线性 **1750ms** 双段;环形 **6000ms**(弧长 0.1→0.87 + 旋转 0→360) | [`LinearGeometry::phase_segments`] / [`CircularGeometry::indeterminate`] | +//! | 12.4 | 不定态节奏 | 线性 **1750ms** 双段;环形 **6000ms**(弧长 0.1→0.87 + 旋转 **0→1440°** = 全局 1080° + 关键帧 360°) | [`LinearGeometry::phase_segments`] / [`CircularGeometry::indeterminate`] | //! | 14.1 | Snackbar 圆角 | **4**(`CornerExtraSmall`) | `Component::Snackbar.spec().shape` | //! | 14.3 | 单行 / 双行高 | **48 / 68** | `panel::SnackbarGeometry::height` | //! | 14.4 | 文本水平内距 / 垂直内距 | **16** / **6** | `Space::X4` / `panel::SnackbarGeometry::pad_v` | @@ -110,6 +110,16 @@ line!(SEG_TAIL_1, 250, 1250); line!(SEG_HEAD_2, 650, 1500); line!(SEG_TAIL_2, 900, 1750); +/// phase 0.55 的两段(占轨道比例)——**A1 keyframes 的手算值,单测与渲染层探针共用**。 +/// +/// 为什么写成常量:早先探针里手写 `0.871`、单测里手写 `0.860`,两边差 0.011(≈3.5px), +/// 实测 963 距 `want_end 966.7` 只剩 0.28px,靠容差活着(复审 B)。现在只有一份: +/// [`phase_segments`] 的回归测试对着它断言,探针也用它算期望像素位置。 +/// +/// 值来自:第二线 `[0.003, 0.076]`(tail 0.003 / head 0.079 → 合并到 0.076 区间的实测), +/// 第一线 `[0.355, 0.860]`。 +pub const PHASE_055: [(f32, f32); 2] = [(0.003, 0.076), (0.355, 0.860)]; + /// 线性进度的几何(厚度与圆角;轨道宽由宿主给,所以没有 width)。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct LinearGeometry { diff --git a/crates/app-iced/src/components/feedback/keyframes.rs b/crates/app-iced/src/components/feedback/keyframes.rs index f85fa4d..d781d5a 100644 --- a/crates/app-iced/src/components/feedback/keyframes.rs +++ b/crates/app-iced/src/components/feedback/keyframes.rs @@ -18,13 +18,16 @@ //! 所以 [`KeyPoint::easing`] 的语义就是段起点的缓动;最后一点之后一律**保持末值** //! (Compose 对未覆盖的区间就是保持,这也是「段跑到头以后停在满格」的来源)。 //! -//! ## 首点必须在 `ms = 0`(复审 P1-B 的教训) +//! ## 首点的两种合法情况(复审 P1-B 的教训) //! //! Compose 在 keyframes **缺 0ms 关键帧**时会自己补一个 `t = 0`、值 = 动画**初值**的点 //! (`VectorizedKeyframesSpec` 构造里 `Map.containsKey(0) == false` 就 `timestamps.add(0, 0)`, -//! 且 `playTime <= 0 → startValue`)。本求值器不猜:首点不在 0 就 `debug_assert` 炸, -//! 逼调用方把那个点显式写出来(环形旋转就漏过这一点:`ROTATION` 首点是 300ms/90°, -//! 于是 `[0, 0.05)` 被恒定为 90°,周期末尾 360° → 下周期 90° 还会瞬时倒跳 270°)。 +//! 且 `playTime <= 0 → startValue`)。本求值器对这种情况按「保持首值」处理,而它**只等价于** +//! Compose 的「初值」—— 所以守卫是「首点不在 0ms **且** 首值 ≠ 0」才 `debug_assert` 炸: +//! +//! - `SEG_TAIL_1/2`(首点 250/900ms、首值 **0**)与 Compose 的「初值 0」等价,**合法**; +//! - `ROTATION` 曾经首点 300ms/**0.25**:`[0, 0.05)` 被恒定成 90°、周期末尾 360° → 下周期 +//! 90° 瞬时倒跳 270°。现在首点显式补成 0ms/0°。 use unlucky_design::motion::Easing; diff --git a/crates/app-iced/src/components/feedback/mod.rs b/crates/app-iced/src/components/feedback/mod.rs index 0edd03c..2e8ef13 100644 --- a/crates/app-iced/src/components/feedback/mod.rs +++ b/crates/app-iced/src/components/feedback/mod.rs @@ -16,8 +16,11 @@ //! //! 1. **数值来自哪里**:颜色/字级/圆角/内距走令牌(`Role` / `TypeRole` / `ShapeScale` / `Space`), //! 进度厚度、Snackbar 宽高、Dialog 宽、空态块这四组令牌层没有 → 登记 **G17**([`geometry`])。 -//! 2. **状态层只有一处**:hover/pressed 的叠色一律经 [`interaction::overlay_color`], -//! 禁用内容一律经 [`interaction::disabled_content_over_role`]; +//! 2. **状态层只有一处**:hover/pressed 的叠色一律经 [`interaction::overlay_color`]; +//! 禁用内容默认经 [`interaction::disabled_content_over_role`],但**动作件是个例外**—— +//! `action.rs` 的 `content_color()` 把 38% 内容色叠在它**自己的容器角色**上 +//! (`InverseSurface` / `SurfaceContainerHigh`),因为 `interaction` 那两档只认 +//! 「叠在 Surface / 12% 容器上」; //! 禁用是**状态**(`on_press: Option` 为 `None`),不是另一个构造器(M77 之前 Chip 踩过)。 //! 3. **精确尺寸经 `layout`**:图标/几何图元一律 `layout::fixed` / `layout::centered_fixed`(Q9), //! 居中一律父级 `align_x/align_y`(Q8,不许 `center_*`)。 @@ -48,7 +51,7 @@ //! progress.rs linear / circular(含环形 SVG 图元) //! snackbar.rs 提示条 + 贴顶/贴底 //! dialog.rs 对话框 + scrim -//! empty_state.rs 空态(项目自定,全部 [估]) +//! empty_state.rs 空态(项目自定:`[估]` 几何 + `[规格]` 排版 + 一条 `[源]` CTA) //! action.rs 文字动作 / 图标动作(Dialog 与 Snackbar 共用) //! gallery_parts.rs 家族自己的 gallery 段落开关(`ULL_FEEDBACK_PART`) //! probe.rs 渲染层像素断言(对着 gallery 截图量,见其模块文档) diff --git a/crates/app-iced/src/components/feedback/panel.rs b/crates/app-iced/src/components/feedback/panel.rs index 457d596..72d5bc3 100644 --- a/crates/app-iced/src/components/feedback/panel.rs +++ b/crates/app-iced/src/components/feedback/panel.rs @@ -4,7 +4,6 @@ //! (宽高/内距/圆角)。合在一个文件里会顶到 400 行红线(AGENTS.md §3-1)。 use unlucky_design::component_map::Component; -use unlucky_design::icons::IconSize; use unlucky_design::spacing::Space; use crate::theme::Ui; @@ -83,6 +82,14 @@ pub struct DialogGeometry { } impl DialogGeometry { + /// 可用宽 → 实际渲染宽:`min(本项目 480, 可用宽)`,再**尊重官方下限 280**。 + /// + /// 复审 E:早先 `Dialog::width(200)` 会真的画 200(官方 `DialogMinWidth = 280` 只在测试里)。 + /// 可用宽 < 280 时仍画 280(**溢出宿主**)—— iced 没有 min-width,这条是 G17-d 之二。 + pub fn effective_width(&self, available: f32) -> f32 { + available.min(self.width).max(self.min_width) + } + /// 从令牌求值(内边距与三个间距全部落在 4px 栅格上 ⇒ 走 `Space`)。 pub fn of(ui: &Ui) -> Self { let width = 480.0; @@ -100,14 +107,23 @@ impl DialogGeometry { } } -/// 空态的几何(**全部 `[估]`**:官方没有这个组件)。 +/// 空态图标边长 = `icons::view` 的实际渲染尺寸(24),见 [`EmptyStateGeometry::icon`] 的 G18-h。 +pub const EMPTY_ICON: f32 = 24.0; + +/// 空态的几何(**项目自定**:官方没有这个组件;`[估]` 几何 + `[规格]` 排版 + `[源]` CTA)。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct EmptyStateGeometry { /// 图标块边长(96)。 pub block: f32, /// 图标块圆角(16,`ShapeScale::Large`)。 pub block_radius: f32, - /// 块内图标(32,`IconSize::Empty`)。 + /// 块内图标(**24**,实渲染值)。 + /// + /// 欠账 **G18-h**:早先登记的是 `IconSize::Empty`(32),但 `icons::view` 内部把 svg 钉成 + /// `Length::Fixed(24)`(Q9 的同一处),外面套 32 的盒子只是**居中**、不拉伸子件 —— + /// 实测墨迹 20×20、字形 24。所以登记值改成 24,与 `view` 一致。 + /// 真要 32:需要 `view_at(px)`(把 `icons::RENDER_PX` 也传进去,按 32 重算 + /// `stroke_width(32)`,否则 1.5px 视觉线宽会变成 2.0px)——那是 **M86** 的 scope,本线不自造。 pub icon: f32, /// 块 ↔ 标题(16)。 pub gap_title: f32, @@ -124,7 +140,7 @@ impl EmptyStateGeometry { Self { block, block_radius: Component::EmptyState.spec().shape.radius_px(block), - icon: IconSize::Empty.px(), + icon: EMPTY_ICON, gap_title: ui.space(Space::X4), gap_body: ui.space(Space::X2), gap_action: ui.space(Space::X6), diff --git a/crates/app-iced/src/components/feedback/probe.rs b/crates/app-iced/src/components/feedback/probe.rs index 7ee4ec7..d8a1ada 100644 --- a/crates/app-iced/src/components/feedback/probe.rs +++ b/crates/app-iced/src/components/feedback/probe.rs @@ -35,6 +35,7 @@ mod panels; use unlucky_design::color::{Role, SchemeFlavor}; use unlucky_design::typography::Platform; +use super::geometry::PHASE_055; use super::pixel::{band_width, bands, matches, near, rgb, runs, shot_path, Shot}; use crate::theme::{Families, Ui}; @@ -154,8 +155,9 @@ fn check_linear(shot: &Shot, ui: &Ui) { "12.4:phase 0.55 必须是**两段**(P1-A 修完才成立),实测 {found:?} @ 行 {row}" ); - // A1 keyframes 手算:第二线 0.003..0.079、第一线 0.355..0.871(EmphasizedAccelerate) - let expected = [(0.003_f32, 0.079_f32), (0.355, 0.871)]; + // 期望位置与单测**共用同一个常量**(`geometry::PHASE_055`):早先这里写 0.871、单测写 + // 0.860,实测 963 距 want_end 966.7 只剩 0.28px,靠容差活着(复审 B)。 + let expected = PHASE_055; for (index, (start, len)) in found.iter().enumerate() { let want_start = TRACK3_X0 as f32 + expected[index].0 * TRACK3_W as f32; let want_end = TRACK3_X0 as f32 + expected[index].1 * TRACK3_W as f32; @@ -262,7 +264,41 @@ fn check_snackbar(shot: &Shot, ui: &Ui) { "14.6:动作标签应是 InversePrimary(实测该色只有 {action_pixels} px)" ); + // ---- 位置语义(复审 D):两条 bottom 条的**条底距取景框底 = 12** --------------- + // + // 取景框是 gallery 给窄宿主画的 1px `OutlineVariant` 边(`framed_host`):透明宿主没有 + // 可见底边时「贴底」在像素上不可证(旧宿主 72 = 48 + 2×12,上下留白相等)。 + let frame = rgb(ui.color(Role::OutlineVariant)); + let mut margins: Vec = Vec::new(); + for (top, bottom) in &found { + let width = band_width(shot, (*top, *bottom), bar, surface); + if !(near(width, 300) || near(width, 344)) { + continue; + } + let frame_row = ((bottom + 1)..shot.height) + .find(|y| { + (0..shot.width) + .filter(|x| matches(shot.at(*x, *y), frame)) + .count() + >= 200 + }) + .unwrap_or_else(|| { + panic!("D:窄条下方没找到宿主取景框(条 y {top}..{bottom},宽 {width})") + }); + margins.push(frame_row - bottom); + } + assert!( + !margins.is_empty(), + "D:应至少量到一条 bottom 条的宿主边距,实测 {margins:?}" + ); + for margin in &margins { + assert!( + near(*margin, 12), + "D:贴底 ⇒ 条底距宿主底 12(官方外边距),实测 {margin}:{margins:?}" + ); + } + eprintln!( - "[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px" + "[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px · 底边距 {margins:?}" ); } diff --git a/crates/app-iced/src/components/feedback/probe/circular.rs b/crates/app-iced/src/components/feedback/probe/circular.rs index 4fb1cdb..b675754 100644 --- a/crates/app-iced/src/components/feedback/probe/circular.rs +++ b/crates/app-iced/src/components/feedback/probe/circular.rs @@ -6,7 +6,15 @@ //! - 环的外接框**四角无墨** —— 真圆,不是圆角方。 //! //! 不定态的**节奏**(4 圈/周期、跨周期不跳变)不在这里量:那是时间的函数,截图只有一帧, -//! 判据在 `tests.rs::circular_indeterminate_rotation_is_four_turns_and_continuous`。 +//! 判据在 `tests/progress.rs::circular_indeterminate_rotation_is_four_turns_and_continuous`。 +//! +//! ## 欠账 G18-i:没有做「逐环断言」 +//! +//! 复审 G 建议把环数下限收到 6 个里的 ≥5。实测做不到稳:一屏 6 个环里只有 3~4 个能被 +//! 「精确色」连通块干净命中 —— 25% 环是四分之一弧(外接框 21×40)、100% 环首尾相接处被 +//! 抗锯齿切开,簇数随相位在 4~6 之间跳(实测 phase 0.30 为 4 簇)。按簇数断言会把「相位换了」 +//! 误报成「环丢了」。所以只保留「至少一个 40×40 真圆环 + 四角无墨」,**逐环登记为 G18-i** +//! (要稳:gallery 把 6 个环摆成固定 x 栅格,或改自绘 Widget 后按几何断言)。 use unlucky_design::color::Role; @@ -42,8 +50,9 @@ pub(super) fn check(shot: &Shot, ui: &Ui) { } } + let clusters = blobs(shot, active, 10).len(); eprintln!( - "[feedback-probe] circular:环 {} 个 {}", + "[feedback-probe] circular:40(±1) 环 {} 个 {}(主色连通簇共 {clusters} 个,见 G18-i)", rings.len(), dims(&rings) ); diff --git a/crates/app-iced/src/components/feedback/probe/panels.rs b/crates/app-iced/src/components/feedback/probe/panels.rs index f1d35f4..89aa0ab 100644 --- a/crates/app-iced/src/components/feedback/probe/panels.rs +++ b/crates/app-iced/src/components/feedback/probe/panels.rs @@ -1,66 +1,110 @@ //! 两个「面」的渲染层判据:Dialog(§7)与空态(§15)。`probe` 的子模块。 //! -//! 量什么: +//! ## Dialog 量什么 //! -//! - Dialog:最宽行 **480**(且落在官方 280..560 内)· 标题墨迹左缩 **≈24**、顶缩 **≈26** -//! (A1 `DialogPadding = 24`)· 动作色是 `Primary` · scrim 带 ≥700 宽且色值 = 预合成实色; -//! - 空态:图标块 **96×96** · 块↔标题的**实际**间距 ≈16 + 字形 bearing · 条目总高 **≈232** -//! (96 + 16 + 32 + 8 + 16 + 24 + 40)· CTA **40**。 +//! - **逐卡宽度**:按列剖面把每张卡单独量出来(早先只量「最宽那张」,一宽一窄并排时会漏); +//! 本项目 480,窄宿主那格 360(`Dialog::width(360)`),且都必须落在官方 280..560 内; +//! - 内距:标题墨迹左缩 ≈24、顶缩 24+bearing(24..=30); +//! - **动作色**:`Role::Primary` 的**精确色**命中数(复审 A:早先用「离谁更近」把标题/正文字形 +//! 也算成了 Primary —— 一张没有动作的卡也能"通过");并配两条反向证据: +//! ①无动作的那张卡里精确 Primary 必须是 **0**;②有动作的那张卡下半区必须有字形行。 +//! - scrim:一块 ≥700 宽的遮罩带,色值 = 预合成实色。 //! -//! 空态那两条间距断言是复审 P2-1 的回归:早先 `Column::spacing(8)` 与显式 `gap(16/24)` 双算, -//! 渲染成 32/40、总高 264,而登记的是 16/24、232。 +//! ## 空态量什么 +//! +//! 图标块 96±2(1px 描边各吃 1 行)· 块↔标题 ≈16+bearing · 条目总高 ≈232 · CTA 40。 +//! +//! 常量与 [`super::super::panel`] 里登记值对齐(480 / 280 / 96 / 232),动作色用**精确色** +//! 而不是斜坡判据。 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, near, rgb, row_ink, runs, BBox, Shot}; +use super::super::pixel::{ + band_width, bands, ink_inside, matches, near, rgb, row_ink, runs, BBox, Shot, +}; use crate::theme::Ui; /// Dialog 宽(本项目取值,官方区间 280..560 内)。 const DIALOG_W: usize = 480; +/// 窄宿主那格的宽(`Dialog::width(360)`)。 +const DIALOG_NARROW: usize = 360; /// 空态图标块边长。 const BLOCK: usize = 96; /// 空态条目总高:96 块 + 16 + 32 标题 + 8 + 16 副文案 + 24 + 40 CTA。 const EMPTY_TOTAL: usize = 232; +/// 动作字形的精确色命中下限(实测 3 张有动作的卡合计 325 px)。 +const ACTION_INK_MIN: usize = 120; -/// §7:卡片宽 / 内距 / 动作色 / scrim。 +/// §7:逐卡宽度 / 内距 / 动作色 / scrim。 pub(super) fn check_dialog(shot: &Shot, ui: &Ui) { - // 参考值写字面量(A1 AlertDialogKt):280 / 560 / 480 / pad 24。 let surface = ui.color(Role::Surface); let card = ui.layer(MaterialLayer::Overlay); + let primary = rgb(ui.color(Role::Primary)); - // ---- 7.4 宽:所有行里最宽的一段(卡片上下留白那几行没有文字,量得到全宽)------ - let mut widest = (0usize, 0usize, 0usize); // (len, x, y) - for y in 0..shot.height { - for (x, len) in runs(shot, y, card, surface) { - if len > widest.0 { - widest = (len, x, y); + // ---- 7.4 宽度:先分「卡带」(同排两张卡的 y 区间会重叠,所以只能按行分组), + // 再在每条带里按**列剖面**分出每一张卡(复审 A/G:早先只量最宽的那张)。 + let card_bands = bands(shot, card, surface, 200); + assert!( + card_bands.len() >= 3, + "至少要找到 3 条卡带(第一排两张 + 双动作 + scrim 里那张),实测 {card_bands:?}" + ); + + let mut widths: Vec = Vec::new(); + for band in &card_bands { + let height = band.1 - band.0 + 1; + let profile: Vec = (0..shot.width) + .map(|x| { + (band.0..=band.1) + .filter(|y| matches(shot.at(x, *y), rgb(card))) + .count() + }) + .collect(); + let mut start: Option = None; + for x in 0..=shot.width { + let column = x < shot.width && profile[x] >= height / 3; + match (column, start) { + (true, None) => start = Some(x), + (false, Some(from)) => { + if x - from >= 280 { + widths.push(x - from); + } + start = None; + } + _ => {} } } } - let (width, card_left, card_row) = widest; + widths.sort_unstable(); assert!( - near(width, DIALOG_W), - "7.4:对话框宽必须是 {DIALOG_W}(G17-e 项目取值),实测 {width}" + widths.iter().any(|w| near(*w, DIALOG_W)), + "7.4:应有 {DIALOG_W} 宽的卡(G17-e 项目取值),实测逐卡宽度 {widths:?}" ); assert!( - (280..=560).contains(&width), - "7.4:宽必须落在官方区间 280..560 内(A1 DialogMinWidth/DialogMaxWidth),实测 {width}" + widths.iter().any(|w| near(*w, DIALOG_NARROW)), + "7.4:窄宿主那格应量到 {DIALOG_NARROW}(`Dialog::width(360)`,复审 E),实测 {widths:?}" ); - - // 该行上下扩成卡片带(同一张卡片的连续行;两张卡并排时各自成带) - let mut top = card_row; - while top > 0 && row_ink(shot, top - 1, card, surface) >= width / 2 { - top -= 1; - } - let mut bottom = card_row; - while bottom + 1 < shot.height && row_ink(shot, bottom + 1, card, surface) >= width / 2 { - bottom += 1; + for width in &widths { + assert!( + (280..=560).contains(width), + "7.4:每张卡都必须落在官方 280..560 内,实测 {width}:{widths:?}" + ); } - // ---- 7.3 内距:标题墨迹距卡片左缘 ≈24、顶 ≈24 + 行内 bearing ----------------- + // 内距与 scrim 只在一张卡上量(第一张,最靠左上)。 + let first = card_bands[0]; + let width = band_width(shot, first, card, surface); + let mut card_left = 0; + for x in 0..shot.width { + if matches(shot.at(x, first.0 + 4), rgb(card)) { + card_left = x; + break; + } + } + let top = first.0; + let bottom = first.1; let bbox = BBox { left: card_left, right: card_left + width - 1, @@ -74,15 +118,68 @@ pub(super) fn check_dialog(shot: &Shot, ui: &Ui) { (22..=28).contains(&inset), "7.3:四边内距 24 ⇒ 标题墨迹左缩 ≈24(含 bearing 22..28),实测 {inset}" ); - // 顶缩收到 (24..=30):写错成 32 也过的话这条等于没写(复审 P2-5)。 - // HeadlineSmall 24/32 的行盒顶到字形顶 ≈3~5px,所以 24+3..24+6。 let top_inset = title.top.saturating_sub(top); assert!( (24..=30).contains(&top_inset), "7.3:上内距 24 ⇒ 标题墨迹顶缩 24+字形 bearing(24..30),实测 {top_inset}" ); - // ---- 7.7 scrim:一块 ≥700 宽的遮罩带,颜色 = 预合成实色 ---------------------- + // ---- 7.6 动作色(复审 A:换掉「离谁更近」那条会被标题字形顶账的判据)-------- + let exact_primary = |band: (usize, usize)| -> usize { + (band.0..=band.1) + .map(|y| { + (0..shot.width) + .filter(|x| matches(shot.at(*x, y), primary)) + .count() + }) + .sum() + }; + let action_pixels: usize = (0..shot.height) + .map(|y| { + (0..shot.width) + .filter(|x| matches(shot.at(*x, y), primary)) + .count() + }) + .sum(); + assert!( + action_pixels >= ACTION_INK_MIN, + "7.6:Dialog 动作标签应是 `Primary` 的**精确色**(实测只命中 {action_pixels} px;\ + 改成 OnSurfaceVariant 就会掉到 0)" + ); + + // 反向证据 ①:没有动作的那张卡(scrim 里那张)里精确 Primary 必须是 0 —— + // 这条挡住「把标题/正文的深色字形算成 Primary」这条假绿路径。 + let quiet: Vec<(usize, usize)> = card_bands + .iter() + .copied() + .filter(|band| exact_primary(*band) == 0) + .collect(); + assert!( + !quiet.is_empty(), + "7.6:本屏应有一张**无动作**的卡(scrim 里那张)其精确 Primary 为 0,实测每带都有 Primary 墨迹" + ); + + // 反向证据 ②:动作行确实存在 —— 命中最多的那条带下半区必须有字形行。 + let loud = card_bands + .iter() + .copied() + .max_by_key(|band| exact_primary(*band)) + .expect("卡带非空"); + let half = loud.0 + (loud.1 - loud.0) / 2; + let action_rows = (half..=loud.1) + .filter(|y| { + (0..shot.width) + .filter(|x| matches(shot.at(*x, *y), primary)) + .count() + >= 3 + }) + .count(); + assert!( + action_rows >= 1, + "7.6:动作字形必须在卡片下半区(实测下半区精确 Primary 行数 {action_rows})" + ); + + // ---- 7.7 scrim ------------------------------------------------------------- let backdrop = scrim(ui); let scrim_bands = bands(shot, backdrop, surface, 700); assert!( @@ -90,38 +187,10 @@ pub(super) fn check_dialog(shot: &Shot, ui: &Ui) { "7.7:没找到 ≥700 宽的遮罩带(宿主 800 宽那格)" ); - // ---- 7.6 动作色:Dialog 动作是 `Primary`(不是 OnSurfaceVariant)------------- - // 按「更靠近 Primary 而不是卡片」判:14px 中文字形只有边缘几个像素能命中精确色。 - let action = rgb(ui.color(Role::Primary)); - let card_rgb = rgb(card); - let action_pixels = (top..=bottom) - .map(|y| { - (card_left..(card_left + width).min(shot.width)) - .filter(|x| { - let pixel = shot.at(*x, y); - let to_action: u32 = pixel - .iter() - .zip(action) - .map(|(a, b)| u32::from(a.abs_diff(b))) - .sum(); - let to_card: u32 = pixel - .iter() - .zip(card_rgb) - .map(|(a, b)| u32::from(a.abs_diff(b))) - .sum(); - to_action < to_card - }) - .count() - }) - .sum::(); - assert!( - action_pixels > 40, - "7.6:动作文本应是 Primary(实测该色只有 {action_pixels} px)" - ); - eprintln!( - "[feedback-probe] dialog:宽 {width} · 标题左缩 {inset} · 顶缩 {top_inset} · \ - 卡片 y {top}..{bottom} · scrim 带 {} 条 · Primary {action_pixels}px", + "[feedback-probe] dialog:逐卡宽度 {widths:?} · 标题左缩 {inset} · 顶缩 {top_inset} · \ + 精确 Primary {action_pixels}px(无动作卡 {} 张)· 动作行 {action_rows} 行 · scrim 带 {} 条", + quiet.len(), scrim_bands.len() ); } @@ -131,9 +200,7 @@ pub(super) fn check_empty(shot: &Shot, ui: &Ui) { let surface = ui.color(Role::Surface); 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() @@ -151,7 +218,28 @@ pub(super) fn check_empty(shot: &Shot, ui: &Ui) { "15.1:图标块必须 {BLOCK}(±2,1px 描边)宽,实测 {width}" ); - // ---- P2-1 回归:块↔标题的**实际**间距(16 而不是 32)------------------------- + // 图标本身:`icons::view` 钉成 24(G18-h 的登记值),墨迹 ≈20×20。 + let glyph = ink_inside( + shot, + &BBox { + left: 0, + right: shot.width - 1, + top, + bottom, + }, + ui.color(Role::OnSurfaceVariant), + block, + 2, + ) + .expect("块里必须有图标墨迹"); + assert!( + (16..=26).contains(&glyph.width()) && (16..=26).contains(&glyph.height()), + "15.1:图标墨迹应在 16..26(24 盒;G18-h 登记 24,不是 32),实测 {}×{}", + glyph.width(), + glyph.height() + ); + + // ---- P2-1 回归:块↔标题的实际间距(16 而不是 32)--------------------------- let bbox = BBox { left: 0, right: shot.width - 1, @@ -166,7 +254,6 @@ pub(super) fn check_empty(shot: &Shot, ui: &Ui) { "15.3:块↔标题应为 16(+ 字形 bearing),实测 {gap}(32 = spacing 与 gap 双算回来了)" ); - // ---- 条目总高 ≈232(96+16+32+8+16+24+40)------------------------------------- let cta = bands(shot, ui.color(Role::Primary), surface, 20); let (cta_top, cta_bottom) = cta .iter() @@ -180,10 +267,10 @@ pub(super) fn check_empty(shot: &Shot, ui: &Ui) { ); eprintln!( - "[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · 块↔标题 {gap} · \ - 总高 {total} · CTA {}..{}", + "[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · 图标 {}×{} · \ + 块↔标题 {gap} · 总高 {total} · CTA {cta_top}..{cta_bottom}", bottom - top + 1, - cta_top, - cta_bottom + glyph.width(), + glyph.height() ); } diff --git a/crates/app-iced/src/components/feedback/progress.rs b/crates/app-iced/src/components/feedback/progress.rs index aacdc8a..765dc42 100644 --- a/crates/app-iced/src/components/feedback/progress.rs +++ b/crates/app-iced/src/components/feedback/progress.rs @@ -5,7 +5,7 @@ //! | 形态 | 确定态 | 不定态 | //! |---|---|---| //! | [`linear`] | 轨道 `SecondaryContainer` + 指示条 `Primary`,厚 **4**、圆角 full | 官方 1750ms **双段**追逐([`phase_segments`]) | -//! | [`circular`] | 40 环 + 4 线宽,弧长 = 数值 × 360° | 官方 6000ms(弧长 0.1↔0.87 + 旋转 0↔360,[`CircularGeometry::indeterminate`]) | +//! | [`circular`] | 40 环 + 4 线宽,弧长 = 数值 × 360° | 官方 6000ms(弧长 0.1↔0.87 + 旋转 **0→1440°**:全局 1080° 线性 + 关键帧 360°,[`CircularGeometry::indeterminate`]) | //! //! ## 为什么不定态要调用方给 `phase` //! diff --git a/crates/app-iced/src/components/feedback/tests.rs b/crates/app-iced/src/components/feedback/tests.rs index afb2b32..a39c264 100644 --- a/crates/app-iced/src/components/feedback/tests.rs +++ b/crates/app-iced/src/components/feedback/tests.rs @@ -109,7 +109,8 @@ fn empty_state_geometry_is_the_project_defined_set() { let geometry = EmptyStateGeometry::of(&ui(SchemeFlavor::Light)); assert_eq!(geometry.block, 96.0); assert_eq!(geometry.block_radius, 16.0, "ShapeScale::Large"); - assert_eq!(geometry.icon, 32.0, "IconSize::Empty"); + // 复审 C:登记值是 **24**(`icons::view` 的实渲染尺寸),不是 32;见欠账 G18-h。 + assert_eq!(geometry.icon, 24.0, "图标实渲染 24(G18-h)"); // **复审 P2-1 的回归断言**:三个间距必须精确等于令牌档位。 // 早先这里只写 `> 0.0`,所以「Column::spacing(8) 与显式 gap(16/24) 双算成 32/40」 // 完全藏得住(渲染实测 264 vs 登记 232)。 diff --git a/crates/app-iced/src/components/feedback/tests/progress.rs b/crates/app-iced/src/components/feedback/tests/progress.rs index 58a66fa..60f0f53 100644 --- a/crates/app-iced/src/components/feedback/tests/progress.rs +++ b/crates/app-iced/src/components/feedback/tests/progress.rs @@ -10,7 +10,7 @@ use unlucky_design::color::SchemeFlavor; use unlucky_design::shape::ShapeScale; -use super::super::geometry::{phase_segments, CircularGeometry, LinearGeometry}; +use super::super::geometry::{phase_segments, CircularGeometry, LinearGeometry, PHASE_055}; use super::super::keyframes::{KeyPoint, Keyframes}; use super::super::progress::{layout_spans, portion_shares, Amount, Progress}; use super::ui; @@ -100,13 +100,25 @@ fn raw_segments_are_unordered_and_layout_sorts_them() { let right = laid[3].1; let left_start: f32 = laid[0].1; let right_start: f32 = laid[0].1 + left + laid[2].1; - // A1 keyframes 的手算值:第二线 0.003..0.079、第一线 0.355..0.871(容差 0.01) - 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}"); + // 期望值来自 `geometry::PHASE_055`——与渲染层探针**共用**(复审 B:早先两边差 0.011) + let [(a0, a1), (b0, b1)] = PHASE_055; assert!( - (right - 0.505).abs() < 0.01, - "右段宽 {right}(0.355..0.860)" + (left_start - a0).abs() < 0.01, + "左段起点 {left_start} vs {a0}" + ); + assert!( + (left - (a1 - a0)).abs() < 0.01, + "左段宽 {left} vs {}", + a1 - a0 + ); + assert!( + (right_start - b0).abs() < 0.01, + "右段起点 {right_start} vs {b0}" + ); + assert!( + (right - (b1 - b0)).abs() < 0.01, + "右段宽 {right} vs {}", + b1 - b0 ); let total: f32 = laid.iter().map(|(_, fraction)| fraction).sum(); diff --git a/crates/app-iced/src/components/gallery/feedback.rs b/crates/app-iced/src/components/gallery/feedback.rs index 7c2f55e..7a6163f 100644 --- a/crates/app-iced/src/components/gallery/feedback.rs +++ b/crates/app-iced/src/components/gallery/feedback.rs @@ -54,8 +54,14 @@ 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; +/// 窄宿主:给 `Dialog::width` 递 360,量「窄宿主不再被裁」的那一格(复审 E)。 +const NARROW_HOST: f32 = 360.0; + +/// 单行提示条的宿主高。 +/// +/// **96 而不是 72**(复审 D):72 = 48 + 2×12 时上下外边距相等,像素上分不出「贴底」还是 +/// 「居中」;96 让贴底那格的上留白 = 36、下留白 = 12,探针能量到「条底距宿主底 = 12」。 +const HOST_SHORT: f32 = 96.0; /// 空态宿主高:内容本征高 ≈96+16+32+8+16+24+40 ≈ 232,留出上下各 40 的余量。 const HOST_EMPTY: f32 = 320.0; @@ -100,6 +106,29 @@ fn caption<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, DemoMessage> { .into() } +/// 带 1px 取景框的宿主:`Snackbar` 的贴底/贴顶需要一条**可见的宿主边界**才量得出来 +/// (透明宿主的底边在图上不存在,`Placement::Bottom` 就不可证)。 +fn framed_host<'a>( + ui: &'a Ui, + width: f32, + height: f32, + content: impl Into>, +) -> Element<'a, DemoMessage> { + let line = ui.color(Role::OutlineVariant); + container(content) + .width(Length::Fixed(width)) + .height(Length::Fixed(height)) + .style(move |_theme| container::Style { + border: iced::Border { + color: line, + width: crate::theme::STROKE_PX, + radius: iced::border::Radius::default(), + }, + ..container::Style::default() + }) + .into() +} + /// 固定宽宿主(量宽度用:宿主宽写死,组件内部的占比/夹取才有确定值)。 fn host<'a>( width: f32, @@ -214,9 +243,10 @@ fn snackbar_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { .state(Interaction::Hovered), ), ); - let mid = host( + let mid = framed_host( + ui, HOST_MIN, - Some(HOST_SHORT), + HOST_SHORT, feedback::snackbar( ui, Snackbar::new("已删除实例") @@ -224,9 +254,10 @@ fn snackbar_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { .action("撤销", press("snackbar/undo")), ), ); - let narrow = host( + let narrow = framed_host( + ui, HOST_NARROW, - Some(HOST_SHORT), + HOST_SHORT, feedback::snackbar(ui, Snackbar::new("宿主 324").placement(Placement::Bottom)), ); @@ -243,7 +274,7 @@ fn snackbar_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { two_lines, caption( ui, - "宿主 324 ⇒ 条 300:组件没有 min-width,宿主给多少就是多少(G17-d)" + "宿主 324(取景框)⇒ 条 300:组件没有 min-width,宿主给多少就是多少(G17-d);贴底 = 条底距框底 12" ), narrow, caption(ui, "宿主 368 ⇒ 条 344:最小宽的取值口径([估])"), @@ -289,6 +320,11 @@ fn dialog_block<'a>(ui: &'a Ui, full: bool) -> Element<'a, DemoMessage> { Dialog::new("已导入整合包", "共 42 个模组,缺失 0 个。") .confirm("知道了", press("dialog/ok")), ); + // 窄宿主那格:可用宽 360 ⇒ `min(480, 360) = 360`,且不得低于官方下限 280。 + let narrow = feedback::dialog( + ui, + Dialog::new("窄宿主 360", "可用宽 360 时取 min(480, 360)。").width(NARROW_HOST), + ); // 强制 hover:动作件的状态层必须来自 `interaction`(不是组件里自己乘 8%)。 let double = feedback::dialog( ui, @@ -307,8 +343,9 @@ fn dialog_block<'a>(ui: &'a Ui, full: bool) -> Element<'a, DemoMessage> { .spacing(ui.space(Space::X2)); body = body.push(row![plain, single].spacing(ui.space(Space::X6))); + // 双动作 + 窄宿主并排:探针要同时量到 480 与 360 两种宽 + body = body.push(row![double, narrow].spacing(ui.space(Space::X6))); if full { - body = body.push(double); body = body.push(caption( ui, "Dialog · scrim = 黑@0.32 叠 canvas 的实色(D27 预合成;详见 dialog.rs 模块文档)",