From 36f58d7904d9b40713225fd914e6a3f0c6a54c92 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 08:51:57 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(iced):=20=E5=8F=8D=E9=A6=88=E7=B1=BB?= =?UTF-8?q?=E5=AE=B6=E6=97=8F=E5=9B=9B=E4=BB=B6=EF=BC=88Progress=20/=20Sna?= =?UTF-8?q?ckbar=20/=20Dialog=20/=20EmptyState=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D39 五家族并行的反馈类线(M78 / COMPFEED)。全部对消息类型泛型、不 import app::Message, 数值来自令牌或登记为 G17(令牌层没有反馈类的 ComponentMetrics 与骨架位)。 - Progress:linear(确定态 FillPortion 占比 + 官方 1750ms 双段不定态 keyframes)与 circular(40/4 环,弧长 = 数值×360°,不定态走官方 6000ms 弧长+旋转 keyframes)。 环形用几何 SVG(canvas feature 没开),组件不计时,相位由调用方给。 - Snackbar:48/68 两种高、上限宽 600(A1 ContainerMaxWidth)、12 外边距、贴顶/贴底、 action(InversePrimary)+ 关闭键(InverseOnSurface);**组件不计时**,超时归调用方。 iced 的 Length 没有 min-width ⇒ 344 的最小宽由宿主保证(登记 G17-d,与按钮 G15 同墙)。 - Dialog:scrim(黑@0.32 预合成实色,D27)+ R28 + SurfaceContainerHigh + pad 24 + 标题/正文/动作三区 + 无动作/单动作/双动作;官方 280..560 区间内取 480。 - EmptyState:**官方没有这个组件**,96 图标块/32 图标/HeadlineSmall 全部标 [估]。 - action.rs:Dialog/Snackbar 共用的文字/图标动作(内容角色作参数,状态层仍走 interaction)。 - gallery/feedback.rs:状态矩阵;家族内再切一段 ULL_FEEDBACK_PART(all|progress|snackbar| dialog|empty),开关本身住在 feedback/gallery_parts.rs,不动 gallery/mod.rs。 - feedback/probe.rs:对着真截图量像素(渲染层断言),四类几何各逐项钉住。 - feedback/tests.rs:几何/节奏/角色/scrim 的纯函数断言。 gates: fmt --all --check · clippy -p unlucky-app-iced --all-targets -D warnings · test -p unlucky-app-iced(144 通过 / 0 失败) --- .../src/components/feedback/action.rs | 157 ++++++++ .../src/components/feedback/dialog.rs | 235 +++++++++++ .../src/components/feedback/empty_state.rs | 134 +++++++ .../src/components/feedback/gallery_parts.rs | 97 +++++ .../src/components/feedback/geometry.rs | 255 ++++++++++++ .../src/components/feedback/keyframes.rs | 76 ++++ .../app-iced/src/components/feedback/mod.rs | 105 +++-- .../app-iced/src/components/feedback/panel.rs | 133 +++++++ .../app-iced/src/components/feedback/pixel.rs | 280 +++++++++++++ .../app-iced/src/components/feedback/probe.rs | 369 ++++++++++++++++++ .../src/components/feedback/progress.rs | 253 ++++++++++++ .../src/components/feedback/snackbar.rs | 192 +++++++++ .../app-iced/src/components/feedback/tests.rs | 331 ++++++++++++++++ .../src/components/gallery/feedback.rs | 322 ++++++++++++++- 14 files changed, 2900 insertions(+), 39 deletions(-) create mode 100644 crates/app-iced/src/components/feedback/action.rs create mode 100644 crates/app-iced/src/components/feedback/dialog.rs create mode 100644 crates/app-iced/src/components/feedback/empty_state.rs create mode 100644 crates/app-iced/src/components/feedback/gallery_parts.rs create mode 100644 crates/app-iced/src/components/feedback/geometry.rs create mode 100644 crates/app-iced/src/components/feedback/keyframes.rs create mode 100644 crates/app-iced/src/components/feedback/panel.rs create mode 100644 crates/app-iced/src/components/feedback/pixel.rs create mode 100644 crates/app-iced/src/components/feedback/probe.rs create mode 100644 crates/app-iced/src/components/feedback/progress.rs create mode 100644 crates/app-iced/src/components/feedback/snackbar.rs create mode 100644 crates/app-iced/src/components/feedback/tests.rs diff --git a/crates/app-iced/src/components/feedback/action.rs b/crates/app-iced/src/components/feedback/action.rs new file mode 100644 index 0000000..14d35c1 --- /dev/null +++ b/crates/app-iced/src/components/feedback/action.rs @@ -0,0 +1,157 @@ +//! 反馈家族里的两个**小动作件**:文字动作与图标动作。 +//! +//! ## 为什么不复用 `buttons` +//! +//! 这两处的内容角色都不在按钮家族的三档里:Dialog 动作 = `Primary`,Snackbar action = +//! `InversePrimary`(A1 `SnackbarTokens.ActionLabelTextColor`),Snackbar 关闭图标 = +//! `InverseOnSurface`。`buttons::text` 固定 `OnSurfaceVariant`(12b §1.9 的 Text 变体口径), +//! 拿它画 Dialog 动作就是颜色角色错位(rubric 判 P1)。 +//! +//! 所以这里只做**一份**最小实现:内容/容器角色作参数,尺寸/字级仍走令牌,状态层仍走 +//! [`interaction`]——与按钮家族同一条产线,只是角色由调用点给。 +//! +//! ## 禁用是状态不是构造器 +//! +//! 照 `selection/chip.rs` 修好的模式:`on_press: Option`,`None` = 禁用;文字色降到 +//! `OnSurface@38%`([`interaction::disabled_content_over_role`]),标签本身**不删**。 + +use iced::widget::{button, text}; +use iced::{Background, Border, Element, Length, Padding, Shadow}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::icons; +use crate::theme::Ui; + +use super::super::interaction::{self, Interaction}; +use super::super::layout; + +/// 一个文字动作(Dialog 的动作区、Snackbar 的 action 共用)。 +/// +/// - `content` / `container_role`:内容色与**状态层叠在什么颜色上**(Dialog = +/// `Primary` on `SurfaceContainerHigh`;Snackbar = `InversePrimary` on `InverseSurface`); +/// - `on_press = None` → 禁用(文字降到 38%,仍占位); +/// - `forced`:取证用的强制态(运行期不要给)。 +pub(super) fn text_action<'a, M: Clone + 'a>( + ui: &'a Ui, + label: &'a str, + content: Role, + container_role: Role, + on_press: Option, + forced: Option, +) -> Element<'a, M> { + let style = ui.emphasized(TypeRole::LabelLarge); + let height = ui.metrics().control_height; + let pad = ui.space(Space::X3); + let shape = Component::TextButton.spec().shape; + let radius = ui.radius(shape, height); + let disabled = on_press.is_none(); + + let ink = if disabled { + interaction::disabled_content_over_role(ui, Role::OnSurface, false) + } else { + ui.color(content) + }; + + let element = button( + text(label) + .size(style.size_px) + .font(ui.font_of(style)) + .color(ink), + ) + .height(Length::Fixed(height)) + .padding(Padding { + top: 0.0, + right: pad, + bottom: 0.0, + left: pad, + }) + .style(move |_theme, status| { + let state = forced.unwrap_or_else(|| { + if disabled { + Interaction::Disabled + } else { + Interaction::from_status(status) + } + }); + button::Style { + background: interaction::overlay_color(ui, state, content, container_role) + .map(Background::Color), + text_color: ink, + border: Border { + radius, + ..Border::default() + }, + // 浮层的阴影只在 Dialog 容器上(rubric K3 单层),动作件本身不投影。 + shadow: Shadow::default(), + snap: true, + } + }); + + match on_press { + Some(message) => element.on_press(message).into(), + None => element.into(), + } +} + +/// 一个图标动作(Snackbar 的关闭键)。命中区 = `touch_target`,字形经 `layout::centered_fixed` +/// 锁在 24(Q9:不锁的话字体会被 Button 的 Fill 约束撑满)。 +pub(super) fn icon_action<'a, M: Clone + 'a>( + ui: &'a Ui, + kind: IconKind, + content: Role, + container_role: Role, + on_press: Option, + forced: Option, +) -> Element<'a, M> { + let metrics = ui.metrics(); + let side = metrics.touch_target; + let glyph_side = unlucky_design::icons::IconSize::Toolbar.px(); + let shape = Component::IconButton.spec().shape; + let radius = ui.radius(shape, side); + let disabled = on_press.is_none(); + + let ink = if disabled { + interaction::disabled_content_over_role(ui, Role::OnSurface, false) + } else { + ui.color(content) + }; + + let element = button(layout::centered_fixed( + glyph_side, + glyph_side, + icons::view::(kind, ink), + )) + .width(Length::Fixed(side)) + .height(Length::Fixed(side)) + .padding(Padding::default()) + .style(move |_theme, status| { + let state = forced.unwrap_or_else(|| { + if disabled { + Interaction::Disabled + } else { + Interaction::from_status(status) + } + }); + button::Style { + background: interaction::overlay_color(ui, state, content, container_role) + .map(Background::Color), + text_color: ink, + border: Border { + radius, + ..Border::default() + }, + shadow: Shadow::default(), + snap: true, + } + }); + + match on_press { + Some(message) => element.on_press(message).into(), + None => element.into(), + } +} diff --git a/crates/app-iced/src/components/feedback/dialog.rs b/crates/app-iced/src/components/feedback/dialog.rs new file mode 100644 index 0000000..72d5832 --- /dev/null +++ b/crates/app-iced/src/components/feedback/dialog.rs @@ -0,0 +1,235 @@ +//! Dialog(`research/12b` §7):scrim + 容器 + 标题/正文/动作三区。 +//! +//! ## 官方值(`[源]` = A1,`DialogTokens` + `AlertDialogKt`,`javap` 核对) +//! +//! | # | 项 | 值 | 落点 | +//! |---|---|---|---| +//! | 7.1 | 圆角 | **28**(`CornerExtraLarge`) | `Component::Dialog.spec().shape` | +//! | 7.2 | 容器 | **`SurfaceContainerHigh`**(`DialogTokens.ContainerColor`) | `Ui::layer(MaterialLayer::Overlay)` | +//! | 7.3 | 内距 | 四边 **24**;标题下 **16**;正文下 **24** | [`DialogGeometry`](`Space` 档位) | +//! | 7.4 | 宽 | 官方 **280..560**;本项目 **480** | [`DialogGeometry::width`] | +//! | 7.5 | 字级 | 标题 `HeadlineSmall 24/32`、正文 `BodyMedium 14/20`、动作 `LabelLarge 14/20 w500` | 组件层取角色 | +//! | 7.6 | 动作区 | 文本 **`Primary`**;主轴 **8** / 交叉 **12**;右对齐 | [`DialogGeometry`] | +//! | 7.7 | 遮罩 | `scrim` = 纯黑 @ **0.32**(`SCRIM_ALPHA`) | [`scrim`] | +//! | 7.8 | 数量 | 一屏 **1 个** modal | 页面责任(组件不计数) | +//! +//! ## 三种动作形态 +//! +//! [`DialogActions::None`](纯告知)/ [`DialogActions::Single`](「知道了」)/ +//! [`DialogActions::Double`](「取消」+「删除」,右边是主操作)。双动作的 `dismiss` 在左、 +//! `confirm` 在右(M3 的阅读序;本项目**不**做「危险操作反色」——官方 Dialog 动作只有 `Primary`)。 +//! +//! ## scrim 为什么是**不透明预合成** +//! +//! 官方是「纯黑 @0.32 盖在内容上」。D27 要求 CPU 档零半透明(否则 CPU/GPU 两图不一致), +//! 所以 [`scrim`] 返回的是 `黑@0.32` **叠在 `MaterialLayer::Canvas` 上**之后的实色: +//! 逐像素与「真半透明铺在 surface 上」等价(遮罩下通常就是底色),且两档渲染一致。 +//! 代价:遮罩下若是图片/卡片,合成目标不是 surface —— 这条登记为偏离 D-2。 +//! +//! ## 不画阴影(偏离 D-1) +//! +//! 官方 L3 有阴影,但 D27(CPU 禁模糊)+ 令牌层 `ShadowSpec` 仍是 M63 占位值 ⇒ 先不画, +//! 层级由 `SurfaceContainerHigh` + scrim 表达。 + +use iced::widget::{column, container, row, text, Space}; +use iced::{Alignment, Background, Border, Element, Length, Padding}; + +use unlucky_design::color::{Role, Scheme}; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::typography::TypeRole; + +use crate::theme::{to_iced, Ui}; + +use super::super::interaction::Interaction; +use super::action; +use super::panel::DialogGeometry; + +/// 动作区的三种形态(「无动作」「单动作」「双动作」)。 +#[derive(Debug, Clone)] +pub enum DialogActions<'a, M> { + /// 无动作:只有标题与正文(纯告知型,例如「已是最新版本」)。 + None, + /// 单动作:确认/知道了。 + Single { + /// 动作文案。 + label: &'a str, + /// 按下后的消息。 + on_press: M, + }, + /// 双动作:左侧 dismiss、右侧 confirm。 + Double { + /// 左(次要)动作。 + dismiss: (&'a str, M), + /// 右(主)动作。 + confirm: (&'a str, M), + }, +} + +impl<'a, M> DialogActions<'a, M> { + /// 是否为空(图例/测试用)。 + pub fn is_empty(&self) -> bool { + matches!(self, DialogActions::None) + } +} + +/// 一个对话框的装配描述(**不持有任何状态所有权**)。 +#[derive(Debug, Clone)] +pub struct Dialog<'a, M> { + title: &'a str, + body: &'a str, + actions: DialogActions<'a, M>, + state: Option, +} + +impl<'a, M> Dialog<'a, M> { + /// 标题 + 正文,默认无动作。 + pub fn new(title: &'a str, body: &'a str) -> Self { + Self { + title, + body, + actions: DialogActions::None, + state: None, + } + } + + /// 单动作。 + pub fn confirm(mut self, label: &'a str, on_press: M) -> Self { + self.actions = DialogActions::Single { label, on_press }; + self + } + + /// 双动作(左 dismiss / 右 confirm)。 + pub fn double(mut self, dismiss: (&'a str, M), confirm: (&'a str, M)) -> Self { + self.actions = DialogActions::Double { dismiss, confirm }; + self + } + + /// 强制动作件的视觉态(gallery 取证用;运行期不要调)。 + pub fn state(mut self, state: Interaction) -> Self { + self.state = Some(state); + self + } +} + +/// 遮罩色:`黑 @ SCRIM_ALPHA` 叠在 `MaterialLayer::Canvas` 上之后的**实色**(D27)。 +pub fn scrim(ui: &Ui) -> iced::Color { + let scheme = Scheme::baseline(ui.flavor()); + let canvas = scheme.get(MaterialLayer::Canvas.surface_role()); + to_iced(scheme.scrim().over(canvas)) +} + +/// 把内容放进一块遮罩里(模态的「背后压暗」)。页面把它压在自己的内容上面即可。 +pub fn scrimmed<'a, M: 'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, M> { + let backdrop = scrim(ui); + container(content) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .style(move |_theme| container::Style { + background: Some(Background::Color(backdrop)), + ..container::Style::default() + }) + .into() +} + +/// 画一个对话框(不含遮罩;遮罩用 [`scrimmed`])。 +pub fn dialog<'a, M: Clone + 'a>(ui: &'a Ui, spec: Dialog<'a, M>) -> Element<'a, M> { + let geometry = DialogGeometry::of(ui); + let surface = ui.layer(MaterialLayer::Overlay); + let radius = iced::border::Radius::new(geometry.radius); + + let title_style = ui.style(TypeRole::HeadlineSmall); + let body_style = ui.style(TypeRole::BodyMedium); + + let mut content = column![].width(Length::Fill).spacing(0.0).push( + text(spec.title) + .size(title_style.size_px) + .font(ui.font_of(title_style)) + .color(ui.color(Role::OnSurface)), + ); + content = content.push(gap(geometry.title_gap)).push( + text(spec.body) + .size(body_style.size_px) + .font(ui.font_of(body_style)) + .color(ui.color(Role::OnSurfaceVariant)), + ); + + if !spec.actions.is_empty() { + content = content.push(gap(geometry.body_gap)).push(actions_row( + ui, + &geometry, + spec.actions, + spec.state, + )); + } + + container(content) + .width(Length::Fixed(geometry.width)) + .max_width(geometry.max_width) + .padding(Padding::from(geometry.padding)) + .style(move |_theme| container::Style { + background: Some(Background::Color(surface)), + border: Border { + radius, + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +/// 两区之间的固定留白(A1 的「标题下 16 / 正文下 24」)。 +fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> { + Space::new() + .width(Length::Fill) + .height(Length::Fixed(height)) + .into() +} + +/// 动作区:右对齐,主轴间距 8(A1 `ButtonsMainAxisSpacing`)。 +fn actions_row<'a, M: Clone + 'a>( + ui: &'a Ui, + geometry: &DialogGeometry, + actions: DialogActions<'a, M>, + state: Option, +) -> Element<'a, M> { + let controls = match actions { + DialogActions::None => row![], + DialogActions::Single { label, on_press } => row![action::text_action( + ui, + label, + Role::Primary, + MaterialLayer::Overlay.surface_role(), + Some(on_press), + state, + )], + DialogActions::Double { dismiss, confirm } => row![ + action::text_action( + ui, + dismiss.0, + Role::Primary, + MaterialLayer::Overlay.surface_role(), + Some(dismiss.1), + state, + ), + action::text_action( + ui, + confirm.0, + Role::Primary, + MaterialLayer::Overlay.surface_role(), + Some(confirm.1), + state, + ), + ], + }; + + container( + controls + .spacing(geometry.action_gap) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .align_x(Alignment::End) + .into() +} diff --git a/crates/app-iced/src/components/feedback/empty_state.rs b/crates/app-iced/src/components/feedback/empty_state.rs new file mode 100644 index 0000000..e5ea4df --- /dev/null +++ b/crates/app-iced/src/components/feedback/empty_state.rs @@ -0,0 +1,134 @@ +//! 空态 EmptyState(`research/12b` §15):图标块 + 标题 + 说明 + 可选动作。 +//! +//! ## ⚠️ 这是**项目自定**组件,官方 Material 3 没有它 +//! +//! `research/14`(官方组件清单)里**没有** EmptyState 这一项,Compose `material3` 也没有 +//! 对应 token。所以本文件的数值**全部是 `[估]`**(`research/12b §15` + `11b R14` + M53 的空态锚点), +//! 不得对外声称「按官方规格实现」: +//! +//! | # | 项 | 值 | 等级 | +//! |---|---|---|---| +//! | 15.1 | 图标块 | **96×96**,圆角 `Large(16)`,图标 **32** | `[估]`(圆角/图标档位是真令牌,96 是项目值) | +//! | 15.2 | 标题 | **HeadlineSmall 24/32**(空态是唯一允许 Headline 级的场景) | `[规格]` `11b R14` | +//! | 15.3 | 副文案 | **BodySmall 12/16** `OnSurfaceVariant`,最多 2 行 | `[规格]` | +//! | 15.4 | 垂直留白 | 内容在可用区居中(上下留白差 ≤8) | `[估]` | +//! | 15.5 | 快捷入口 | ≤ **3** 个 | `[估]`(本组件只给 1 个动作,见下) | +//! | 15.6 | CTA | 走标准 Button(40 高 / full 圆角),实心 `Primary` 占「每屏一个」名额 | `[源]`(按钮家族) | +//! +//! ## 为什么 CTA 直接用 `buttons::filled` +//! +//! 15.6 说 CTA 就是标准按钮。复用按钮家族有两个好处:空态的 CTA 与全站按钮同日升级; +//! 也顺带证明「组件库能跨家族组合」——本文件因此是唯一 import 兄弟家族的地方。 + +use iced::widget::{column, container, text}; +use iced::{Alignment, Background, Border, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::icons::IconKind; +use unlucky_design::typography::TypeRole; + +use crate::icons; +use crate::theme::Ui; + +use super::super::buttons::{self, Button}; +use super::super::layout; +use super::panel::EmptyStateGeometry; + +/// 一个空态的装配描述(**不持有任何状态所有权**)。 +#[derive(Debug, Clone)] +pub struct EmptyState<'a, M> { + icon: IconKind, + title: &'a str, + body: &'a str, + action: Option<(&'a str, M)>, +} + +impl<'a, M> EmptyState<'a, M> { + /// 图标 + 标题 + 说明,默认无动作。 + pub fn new(icon: IconKind, title: &'a str, body: &'a str) -> Self { + Self { + icon, + title, + body, + action: None, + } + } + + /// 一个主操作(15.6:标准 Filled 按钮)。 + pub fn action(mut self, label: &'a str, on_press: M) -> Self { + self.action = Some((label, on_press)); + self + } +} + +/// 画一个空态(内容整体在可用区里水平 + 垂直居中)。 +pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> Element<'a, M> { + let geometry = EmptyStateGeometry::of(ui); + let title_style = ui.style(TypeRole::HeadlineSmall); + let body_style = ui.style(TypeRole::BodySmall); + + // Q9:图标必须落在固定尺寸的盒子里,否则 fill 约束会让 usvg 按 Contain 撑满 96 块。 + let glyph = layout::centered_fixed( + geometry.icon, + geometry.icon, + icons::view::(spec.icon, ui.color(Role::OnSurfaceVariant)), + ); + let block_radius = iced::border::Radius::new(geometry.block_radius); + // 块底色 `[估]`:取材质层 `Card`(`SurfaceContainerLow`)——空态块是「内容卡片」层级, + // 不是浮层也不是画布;官方没有这个组件,所以这条是我们自己的口径。 + let block_color = ui.layer(MaterialLayer::Card); + let block = container(glyph) + .width(Length::Fixed(geometry.block)) + .height(Length::Fixed(geometry.block)) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .style(move |_theme| container::Style { + background: Some(Background::Color(block_color)), + border: Border { + radius: block_radius, + ..Border::default() + }, + ..container::Style::default() + }); + + let mut content = column![] + .align_x(Alignment::Center) + .spacing(geometry.gap_body) + .push(block) + .push(gap(geometry.gap_title)) + .push( + text(spec.title) + .size(title_style.size_px) + .font(ui.font_of(title_style)) + .color(ui.color(Role::OnSurface)), + ) + .push( + text(spec.body) + .size(body_style.size_px) + .font(ui.font_of(body_style)) + .color(ui.color(Role::OnSurfaceVariant)), + ); + + if let Some((label, message)) = spec.action { + content = content + .push(gap(geometry.gap_action)) + .push(buttons::filled(ui, Button::new(label, message))); + } + + container(content) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .padding(Padding::from(geometry.gap_action)) + .into() +} + +/// 固定高度的透明留白(列间距在 `spacing` 之外还要分档时用)。 +fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> { + iced::widget::Space::new() + .width(Length::Fill) + .height(Length::Fixed(height)) + .into() +} diff --git a/crates/app-iced/src/components/feedback/gallery_parts.rs b/crates/app-iced/src/components/feedback/gallery_parts.rs new file mode 100644 index 0000000..abad7f2 --- /dev/null +++ b/crates/app-iced/src/components/feedback/gallery_parts.rs @@ -0,0 +1,97 @@ +//! 反馈家族自己的 gallery 段落开关(`ULL_FEEDBACK_PART`)。 +//! +//! 契约 §9.1/§9.2:**`gallery/` 是 harness,不属于任何家族**——家族线不得为了自己出图去改 +//! `gallery/mod.rs`;需要新能力时用「环境变量 + 默认向后兼容」,并报给 tower。 +//! 本文件走的就是这条:开关本身住在**家族自己的目录**里,`gallery/feedback.rs` 只消费它。 +//! +//! 为什么要再切一层:`ULL_GALLERY_SECTION=feedback` 只能把整个家族拎出来,而四件的高度 +//! 超过 1180×760 首屏;`probe` 的几何断言需要「这一屏只画这一件」的干净取景。 +//! +//! | 值 | 渲染 | +//! |---|---| +//! | 未设置 / 空 / `all` | 四块全渲染(prog → snack → empty → dialog),与不加开关一致 | +//! | `progress` / `snackbar` / `empty` / `dialog` | 只渲染那一块 | +//! | 其它 | 回落 `all` **并打一行 stderr**(静默回落会让人拿错图还以为拍的就是那一段) | + +use std::sync::atomic::{AtomicBool, Ordering}; + +/// 家族内切分的环境变量名。 +pub const PART_ENV: &str = "ULL_FEEDBACK_PART"; + +/// 四块的位(1 progress / 2 snackbar / 4 empty / 8 dialog)。 +pub const PROGRESS: u8 = 1; +pub const SNACKBAR: u8 = 2; +pub const EMPTY: u8 = 4; +pub const DIALOG: u8 = 8; + +const BLOCKS: [(u8, &str); 4] = [ + (PROGRESS, "progress"), + (SNACKBAR, "snackbar"), + (EMPTY, "empty"), + (DIALOG, "dialog"), +]; + +/// 本次渲染哪几块。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Part(u8); + +impl Part { + /// 四块全渲染(默认)。 + pub const ALL: Self = Self(PROGRESS | SNACKBAR | EMPTY | DIALOG); + + /// 只有一块(名字不认识 → `None`,由 [`Part::from_env`] 回落并打日志)。 + pub fn only(name: &str) -> Option { + BLOCKS + .iter() + .find(|(_, block)| *block == name) + .map(|(bit, _)| Self(*bit)) + } + + /// 读 `ULL_FEEDBACK_PART`:空 / `all` → 全渲染;未识别的值 → 回落 + stderr 一行。 + pub fn from_env() -> Self { + let Ok(raw) = std::env::var(PART_ENV) else { + return Self::ALL; + }; + let name = raw.trim().to_ascii_lowercase(); + if name.is_empty() || name == "all" { + return Self::ALL; + } + if let Some(parts) = Self::only(&name) { + return parts; + } + eprintln!( + "[feedback] {PART_ENV}={raw:?} 不是已知部分(all|progress|snackbar|dialog|empty),已回落 all" + ); + Self::ALL + } + + /// 这一块要不要画。 + pub fn has(self, bit: u8) -> bool { + self.0 & bit != 0 + } + + /// 本次渲染的块名(stderr 日志用)。 + pub fn names(self) -> Vec<&'static str> { + BLOCKS + .iter() + .filter(|(bit, _)| self.has(*bit)) + .map(|(_, name)| *name) + .collect() + } + + /// 打印本次渲染了哪几块(首帧一次)。 + pub fn log_once(self) { + if PART_LOGGED.swap(true, Ordering::Relaxed) { + return; + } + let raw = std::env::var(PART_ENV).unwrap_or_else(|_| "all".to_owned()); + eprintln!( + "[feedback] part={raw} → 渲染 {} 块:{}", + self.names().len(), + self.names().join(", ") + ); + } +} + +/// `[feedback] ...` 每进程只打一行(`view` 每帧都跑)。 +static PART_LOGGED: AtomicBool = AtomicBool::new(false); diff --git a/crates/app-iced/src/components/feedback/geometry.rs b/crates/app-iced/src/components/feedback/geometry.rs new file mode 100644 index 0000000..0fb691e --- /dev/null +++ b/crates/app-iced/src/components/feedback/geometry.rs @@ -0,0 +1,255 @@ +//! 反馈家族的**几何与节奏**:值要么来自令牌,要么在此登记 **G17**(不散落在组件里)。 +//! +//! ## 参考值 → 落点(`[源]` = A1,已用 `javap` 逐字段核对,命令见文末) +//! +//! | # | 量 | 参考值 | 落点 | +//! |---|---|---|---| +//! | 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`] | +//! | 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` | +//! | 14.4b | 外边距(距窗口边) | **12**(`[规格]`:AAR 里**没有**这个常数,见 G17-c) | `panel::SnackbarGeometry::margin` | +//! | 14.x | 宽度上限 | **600**(`SnackbarKt.ContainerMaxWidth`) | `panel::SnackbarGeometry::max_width` | +//! | 7.1 | Dialog 圆角 | **28**(`CornerExtraLarge`) | `Component::Dialog.spec().shape` | +//! | 7.3 | Dialog 内边距 / 标题下 / 正文下 | **24 / 16 / 24** | `Space::X6` / `Space::X4` / `Space::X6` | +//! | 7.4 | Dialog 宽 | 官方 **280..560**;本项目取 **480** | `panel::DialogGeometry` | +//! | 7.6 | 动作区间距 | 主轴 **8** / 交叉 **12** | `Space::X2` / `Space::X3` | +//! | 15.1 | 空态图标块 | **96×96**,圆角 `Large(16)`,图标 **32** | `panel::EmptyStateGeometry`(`[估]`) | +//! +//! ## G17 登记(续 `research/15` §25 的 G13、按钮 G14/G15、选择类 G16) +//! +//! 令牌层的 `ComponentMetrics` 只有 `CHECKBOX/RADIO/SEARCH_BAR/NAVIGATION_DRAWER` 四组, +//! `LayoutMetrics` 也没有进度条厚度 / 弹窗宽 / 提示条宽高。本文件把这些量收成**命名常量**, +//! 每条都带出处等级,等令牌层补档后整段删除、改走 `Component::X.metrics()`: +//! +//! | 编号 | 量 | 值 | 等级 | 说明 | +//! |---|---|---|---|---| +//! | G17-a | 线性进度厚度 / 环形直径·厚度 | 4 / 40·4 | `[源]` | `LinearProgressIndicatorTokens.Height`、`CircularProgressIndicatorTokens.Size` | +//! | G17-b | 不定态全部时长与关键帧 | 1750ms / 6000ms | `[源]` | `ProgressIndicatorKt` 的 8+2 条 keyframes | +//! | G17-c | Snackbar 外边距 | 12 | `[规格]` | `12b §14.4` 标 `[源 Snackbar.kt:238]`,但 1.4.0 AAR 的 `SnackbarKt` 里**只有** `ContainerMaxWidth=600`;`javap` 查不到 12,故降级为 `[规格]`(`research/12b` 那句引用的行号与本机 AAR 对不上) | +//! | G17-d | Snackbar 最小宽度 | 344 | `[估]` | AAR 无此常数(同上);M2 时代口径,本项目沿用以保观感连续 | +//! | G17-e | Dialog 宽 | 480 | 项目取值 | 官方区间 280..560 (`[源]` `AlertDialogKt.DialogMinWidth/DialogMaxWidth`),取 480 让 1180 窗下两侧留白均衡 | +//! | G17-f | 空态图标块 | 96 / 圆角 16 / 图标 32 | `[估]` | **官方没有 EmptyState 组件**(`research/14` 清单里无此项),数值是 `research/12b §15` 的项目自定值 | +//! +//! ## 复现命令(A1 取数) +//! +//! ```text +//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.LinearProgressIndicatorTokens +//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.CircularProgressIndicatorTokens +//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.SnackbarTokens +//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.DialogTokens +//! javap -p -c -cp classes.jar androidx.compose.material3.ProgressIndicatorKt # 节奏 +//! javap -p -c -cp classes.jar androidx.compose.material3.AlertDialogKt # 280/560/24/16/8/12 +//! javap -p -c -cp classes.jar androidx.compose.material3.SnackbarKt # 600 / 6 / 16 / 8 +//! ``` + +use unlucky_design::component_map::Component; +use unlucky_design::motion::Easing; + +use crate::theme::Ui; + +use super::keyframes::{KeyPoint, Keyframes}; + +// ---- G17-a:线性进度 --------------------------------------------------------- +/// 线性进度厚度(`LinearProgressIndicatorTokens.Height/ActiveThickness/TrackThickness` 同为 4)。 +const LINEAR_THICKNESS: f32 = 4.0; +/// 不定态周期(`ProgressIndicatorKt.LinearAnimationDuration = 1750`)。 +const LINEAR_CYCLE_MS: u32 = 1750; + +/// 相位 0..1 → 两段运行条 `[(起点, 终点); 2]`(都以「占轨道比例」计)。 +/// +/// 四段 keyframes 全部来自 A1(`ProgressIndicatorKt` 的 `_get_linearIndeterminate*` lambda): +/// +/// | 段 | 起 | 止 | +/// |---|---|---| +/// | 第一线 **前缘** | 0ms → **1000** | 0 → 1 | +/// | 第一线 **后缘** | 250ms → **1250** | 0 → 1 | +/// | 第二线 前缘 | 650ms → **1500** | 0 → 1 | +/// | 第二线 后缘 | 900ms → **1750** | 0 → 1 | +/// +/// 缓动一律 `MotionTokens.EasingEmphasizedAccelerateCubicBezier`。 +/// +/// **前缘 > 后缘**(前缘抢跑 250ms),所以画出来的区间是 `[后缘, 前缘]`:先长后缩、向右跑。 +/// 若照字面写成 `[前缘, 后缘]` 会得到负宽度(一段都不画)——这是本函数唯一容易写反的地方。 +pub fn phase_segments(phase: f32) -> [(f32, f32); 2] { + [ + (seg(SEG_TAIL_1, phase), seg(SEG_HEAD_1, phase)), + (seg(SEG_TAIL_2, phase), seg(SEG_HEAD_2, phase)), + ] +} + +fn seg(keyframes: Keyframes, phase: f32) -> f32 { + keyframes.sample(phase).clamp(0.0, 1.0) +} + +macro_rules! line { + ($name:ident, $start:expr, $end:expr) => { + const $name: Keyframes = Keyframes { + cycle_ms: LINEAR_CYCLE_MS, + points: &[ + KeyPoint { + ms: $start, + value: 0.0, + easing: Easing::EmphasizedAccelerate, + }, + KeyPoint { + ms: $end, + value: 1.0, + easing: Easing::Linear, + }, + ], + }; + }; +} + +line!(SEG_HEAD_1, 0, 1000); +line!(SEG_TAIL_1, 250, 1250); +line!(SEG_HEAD_2, 650, 1500); +line!(SEG_TAIL_2, 900, 1750); + +/// 线性进度的几何(厚度与圆角;轨道宽由宿主给,所以没有 width)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LinearGeometry { + /// 条厚(4)。 + pub thickness: f32, + /// 圆角(full ⇒ 厚的 1/2 = 2)。 + pub radius: f32, + /// 不定态周期(1750ms,调用方按它驱动 `phase`)。 + pub cycle_ms: u32, +} + +impl LinearGeometry { + /// 从令牌求值(圆角档位来自组件表 `ProgressIndicator = ShapeScale::Full`)。 + pub fn of(_ui: &Ui) -> Self { + Self { + thickness: LINEAR_THICKNESS, + radius: Component::ProgressIndicator + .spec() + .shape + .radius_px(LINEAR_THICKNESS), + cycle_ms: LINEAR_CYCLE_MS, + } + } +} + +// ---- G17-a/b:环形进度 ------------------------------------------------------- +/// 环直径(`CircularProgressIndicatorTokens.Size = 40`)。 +const CIRCULAR_DIAMETER: f32 = 40.0; +/// 环线宽(`CircularProgressIndicatorTokens.ActiveThickness = 4`)。 +const CIRCULAR_THICKNESS: f32 = 4.0; +/// 不定态弧长下限(`ProgressIndicatorKt.CircularIndeterminateMinProgress = 0.1f`)。 +const CIRCULAR_MIN: f32 = 0.1; +/// 不定态弧长上限(`CircularIndeterminateMaxProgress = 0.87f`)。 +const CIRCULAR_MAX: f32 = 0.87; +/// 环形不定态周期(`CircularAnimationProgressDuration = 6000`)。 +const CIRCULAR_CYCLE_MS: u32 = 6000; + +/// 弧长:0.1 → 0.87(3000ms,`CircularProgressEasing`=Standard),再回 0.1(3000ms)。 +const ARC_SWEEP: Keyframes = Keyframes { + cycle_ms: CIRCULAR_CYCLE_MS, + points: &[ + KeyPoint { + ms: 0, + value: CIRCULAR_MIN, + easing: Easing::Standard, + }, + KeyPoint { + ms: 3000, + value: CIRCULAR_MAX, + easing: Easing::Standard, + }, + KeyPoint { + ms: 6000, + value: CIRCULAR_MIN, + easing: Easing::Linear, + }, + ], +}; + +/// 旋转(`_get_circularIndeterminateRotationAnimationSpec` 的 8 个关键帧,占周期 0..360°)。 +const ROTATION: Keyframes = Keyframes { + cycle_ms: CIRCULAR_CYCLE_MS, + points: &[ + KeyPoint { + ms: 300, + value: 0.25, + easing: Easing::EmphasizedDecelerate, + }, + KeyPoint { + ms: 1500, + value: 0.25, + easing: Easing::Linear, + }, + KeyPoint { + ms: 1800, + value: 0.5, + easing: Easing::Linear, + }, + KeyPoint { + ms: 3000, + value: 0.5, + easing: Easing::Linear, + }, + KeyPoint { + ms: 3300, + value: 0.75, + easing: Easing::Linear, + }, + KeyPoint { + ms: 4500, + value: 0.75, + easing: Easing::Linear, + }, + KeyPoint { + ms: 4800, + value: 1.0, + easing: Easing::Linear, + }, + KeyPoint { + ms: 6000, + value: 1.0, + easing: Easing::Linear, + }, + ], +}; + +/// 环形进度的几何。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CircularGeometry { + /// 直径(40)。 + pub diameter: f32, + /// 线宽(4)。 + pub thickness: f32, + /// 圆角(full ⇒ 20;环本身是圆的,这里只用于对表)。 + pub radius: f32, + /// 不定态周期(6000ms)。 + pub cycle_ms: u32, +} + +impl CircularGeometry { + /// 从令牌求值。 + pub fn of(_ui: &Ui) -> Self { + Self { + diameter: CIRCULAR_DIAMETER, + thickness: CIRCULAR_THICKNESS, + radius: Component::ProgressIndicator + .spec() + .shape + .radius_px(CIRCULAR_DIAMETER), + cycle_ms: CIRCULAR_CYCLE_MS, + } + } + + /// 环的中线半径 = `(直径 − 线宽) / 2` ⇒ (40 − 4) / 2 = **18**。 + pub fn arc_radius(&self) -> f32 { + (self.diameter - self.thickness) / 2.0 + } + + /// 不定态:相位 → `(弧长占比, 起始旋转占整圈的比例)`。 + pub fn indeterminate(&self, phase: f32) -> (f32, f32) { + (ARC_SWEEP.sample(phase), ROTATION.sample(phase)) + } +} diff --git a/crates/app-iced/src/components/feedback/keyframes.rs b/crates/app-iced/src/components/feedback/keyframes.rs new file mode 100644 index 0000000..72b35c9 --- /dev/null +++ b/crates/app-iced/src/components/feedback/keyframes.rs @@ -0,0 +1,76 @@ +//! 不定态的**节奏**:把官方的 keyframes 摊成纯函数 `phase → 进度`。 +//! +//! ## 为什么组件不计时 +//! +//! 契约 §2:组件不持有时钟/状态所有权。`indeterminate` 的相位由**调用方**给(`phase` 0..1, +//! 由页面的 `Subscription` 每帧/每 tick 推进)。好处不只「符合契约」:截图 harness 也才能拍到 +//! 确定的中间帧——否则每张图都是一次抓阄。 +//! +//! ## 数据来源(A1,`javap` 读本机 AAR 得到,不是凭记忆) +//! +//! ```text +//! ~/.gradle/caches/.../material3-android/1.4.0/.../material3.aar → classes.jar +//! javap -p -c -cp classes.jar androidx.compose.material3.ProgressIndicatorKt +//! ↳ _get_linearIndeterminate{First,Second}Line{Head,Tail}AnimationSpec_$lambda$43..46 +//! ``` +//! +//! Compose 的 `KeyframesSpec.at(value, time).using(easing)` 是「**从该点出发的那一段**用这条缓动」, +//! 所以 [`KeyPoint::easing`] 的语义就是段起点的缓动;第一点之前 / 最后一点之后一律**保持端点值** +//! (Compose 对未覆盖的区间就是保持,这也是「段跑到头以后停在满格」的来源)。 + +use unlucky_design::motion::Easing; + +/// 一个关键帧:`ms` 时刻的值 + **从它出发那一段**的缓动。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct KeyPoint { + /// 相对周期起点的毫秒数。 + pub ms: u32, + /// 该时刻的进度(0..1,环形是「弧长占比」,旋转是「度数 / 360」)。 + pub value: f32, + /// 从本点出发到下一个点的那一段使用的缓动。 + pub easing: Easing, +} + +/// 一条 keyframes 曲线(一个周期内可采样任意相位)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Keyframes { + /// 周期长度(毫秒)。`phase` 按它折算成时间,再对周期取模(相位可越界/可循环)。 + pub cycle_ms: u32, + /// 关键帧(按 `ms` 升序)。 + pub points: &'static [KeyPoint], +} + +impl Keyframes { + /// 采样:`phase`(任意实数,按周期取模)→ 进度。 + /// + /// 空列表给 0;`cycle_ms == 0` 时直接给首点的值(没有可循环的时间轴)。 + pub fn sample(&self, phase: f32) -> f32 { + let Some(first) = self.points.first() else { + return 0.0; + }; + if self.cycle_ms == 0 || !phase.is_finite() { + return first.value; + } + + let cycle = self.cycle_ms as f32; + let ms = phase.rem_euclid(1.0) * cycle; + if ms <= first.ms as f32 { + return first.value; + } + + for pair in self.points.windows(2) { + let (from, to) = (pair[0], pair[1]); + if ms > to.ms as f32 { + continue; + } + let span = to.ms as f32 - from.ms as f32; + if span <= 0.0 { + return to.value; + } + let t = (ms - from.ms as f32) / span; + return from.value + (to.value - from.value) * from.easing.sample(t); + } + + self.points[self.points.len() - 1].value + } +} diff --git a/crates/app-iced/src/components/feedback/mod.rs b/crates/app-iced/src/components/feedback/mod.rs index 36244a6..0089586 100644 --- a/crates/app-iced/src/components/feedback/mod.rs +++ b/crates/app-iced/src/components/feedback/mod.rs @@ -1,34 +1,83 @@ -//! **反馈类家族(桩)**:由后续 mission 填充(§12 Progress / §14 Snackbar / §7 Dialog / §15 EmptyState)。 +//! **反馈类家族**:Progress(§12)/ Snackbar(§14)/ Dialog(§7)/ EmptyState(§15)。 //! -//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**: -//! 家族模块已经在 `mod.rs` 里声明好,`gallery/feedback.rs` 也已经在 `gallery/mod.rs` 里声明好, -//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。 +//! ## 接口(契约 §2 + §8:官方名的小写形式,对消息类型泛型,不 import `app::Message`) //! -//! 填充时请照抄按钮家族的分层: +//! | 组件 | 入口 | 消息要求 | +//! |---|---|---| +//! | 线性进度 | [`linear(ui, Progress::determinate(0.5))`](linear) | **无消息**(进度不可点) | +//! | 环形进度 | [`circular(ui, Progress::indeterminate(phase))`](circular) | **无消息** | +//! | 提示条 | [`snackbar(ui, Snackbar::new("…").action("撤销", msg))`](snackbar) | 有动作/关闭时才要 `M` | +//! | 对话框 | [`dialog(ui, Dialog::new("标题", "正文").double(..))`](dialog) | 有动作时要 `M` | +//! | 空态 | [`empty_state(ui, EmptyState::new(icon, "标题", "说明"))`](empty_state) | 有 CTA 时要 `M` | //! -//! - 几何进 `feedback/geometry.rs`(数值从 `Space` / `ShapeScale` / `LayoutMetrics` 取, -//! 缺令牌登记 G 编号——Dialog 的 280/560 宽、Snackbar 的 48/68 高目前都还没有令牌); -//! - 状态层只能走 [`crate::app::components::interaction`]; -//! - Dialog/Snackbar 是**浮层**:阴影只在这两处允许(rubric K3),且单层。 +//! 全部 `M: Clone + 'a`(与按钮家族同一口径),`Ui` 一律 `&'a Ui` 传入、组件内不 `Ui::new`。 +//! +//! ## 三条硬口径(每个组件文件开头各有一套细则表) +//! +//! 1. **数值来自哪里**:颜色/字级/圆角/内距走令牌(`Role` / `TypeRole` / `ShapeScale` / `Space`), +//! 进度厚度、Snackbar 宽高、Dialog 宽、空态块这四组令牌层没有 → 登记 **G17**([`geometry`])。 +//! 2. **状态层只有一处**:hover/pressed 的叠色一律经 [`interaction::overlay_color`], +//! 禁用内容一律经 [`interaction::disabled_content_over_role`]; +//! 禁用是**状态**(`on_press: Option` 为 `None`),不是另一个构造器(M77 之前 Chip 踩过)。 +//! 3. **精确尺寸经 `layout`**:图标/几何图元一律 `layout::fixed` / `layout::centered_fixed`(Q9), +//! 居中一律父级 `align_x/align_y`(Q8,不许 `center_*`)。 +//! +//! ## CPU 降级(D27)与「不画阴影」 +//! +//! 本家族零模糊、零运行时半透明:进度条是实色、Dialog 的 scrim 是**预合成实色** +//! ([`scrim`]),两个浮层都不画阴影(`ShadowSpec` 还是 M63 占位值 + D27 禁模糊)。 +//! 三条偏离逐条登记在报告里(D-1 无阴影 / D-2 scrim 预合成 / D-3 波浪未实现)。 +//! +//! ## 逐项偏离登记(提交报告里同一份表) +//! +//! | 编号 | 参考 | 我们 | 理由 | 可修 | +//! |---|---|---|---|---| +//! | D-1 | Dialog L3 阴影 | 无阴影 | D27 CPU 禁模糊 + `ShadowSpec` 为 M63 占位 | 是(等 M63 阴影表 + 双档渲染) | +//! | D-2 | scrim 真半透明 @0.32 | 预合成实色 | D27 CPU 零半透明;遮罩下是底色时逐像素等价 | 是(GPU 档可切真 α) | +//! | D-3 | 线性波浪 3/40、环形 1.6/15 | 直线段 / 等弧长 | `12b §12.4` 明确允许,登记后用匀速替代 | 是 | +//! | D-4 | Snackbar L3 阴影 | 无阴影 | 同 D-1 | 是 | +//! | G17-d | Snackbar 最小宽 344 | 组件不强制(宿主保证) | iced `Length` 无 min-width(同按钮 G15) | 是(自定义 Widget) | +//! +//! ## 文件地图 +//! +//! ```text +//! geometry.rs G17 常量 + 五组几何(含不定态 keyframes) +//! keyframes.rs 相位 → 进度 的纯函数求值器(组件不计时) +//! progress.rs linear / circular(含环形 SVG 图元) +//! snackbar.rs 提示条 + 贴顶/贴底 +//! dialog.rs 对话框 + scrim +//! empty_state.rs 空态(项目自定,全部 [估]) +//! action.rs 文字动作 / 图标动作(Dialog 与 Snackbar 共用) +//! gallery_parts.rs 家族自己的 gallery 段落开关(`ULL_FEEDBACK_PART`) +//! probe.rs 渲染层像素断言(对着 gallery 截图量,见其模块文档) +//! ``` +//! +//! ## 桩期的 `demo()` 已删除(不是签名被改) +//! +//! M75 的占位 `demo()` 只画一行「pending」文字,没有任何验收价值;真组件的验收画面就是 +//! `gallery/feedback.rs` 的四段矩阵。契约 §9.3「家族 `demo()` 签名是共享契约」约束的是 +//! **别改别人的调用点**——`feedback::demo` 自始至终只有 `gallery/feedback.rs` 一个调用点, +//! 而那个文件就在本 mission 的 scope 内,所以这里连同调用点一起换成了真组件(不留死代码: +//! 一个没人调用的 `pub fn` 会直接触发 `dead_code`,`-D warnings` 门禁会红)。 -use iced::widget::{container, text}; -use iced::Element; +mod action; +pub mod dialog; +pub mod empty_state; +pub mod gallery_parts; +pub mod geometry; +pub mod keyframes; +pub mod panel; +pub mod progress; +pub mod snackbar; -use unlucky_design::color::Role; -use unlucky_design::spacing::Space; -use unlucky_design::typography::TypeRole; +#[cfg(test)] +mod pixel; +#[cfg(test)] +mod probe; +#[cfg(test)] +mod tests; -use crate::theme::Ui; - -/// 占位 demo(ASCII 文案,理由同 `selection::demo`)。 -pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> { - let style = ui.style(TypeRole::BodySmall); - container( - text("feedback · progress / snackbar / dialog / empty state — pending") - .size(style.size_px) - .font(ui.font_of(style)) - .color(ui.color(Role::OnSurfaceVariant)), - ) - .padding(ui.space(Space::X2)) - .into() -} +pub use dialog::{dialog, scrimmed, Dialog}; +pub use empty_state::{empty_state, EmptyState}; +pub use progress::{circular, linear, Progress}; +pub use snackbar::{snackbar, Placement, Snackbar}; diff --git a/crates/app-iced/src/components/feedback/panel.rs b/crates/app-iced/src/components/feedback/panel.rs new file mode 100644 index 0000000..457d596 --- /dev/null +++ b/crates/app-iced/src/components/feedback/panel.rs @@ -0,0 +1,133 @@ +//! 三件「面」的几何:Snackbar / Dialog / EmptyState(数值与出处见 [`super::geometry`] 的 G17 表)。 +//! +//! 为什么与进度分家:进度那两个是**带节奏的图形**(keyframes 与相位),这三件是**静态盒子** +//! (宽高/内距/圆角)。合在一个文件里会顶到 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; + +/// Snackbar 的几何(G17-c/d + A1 高/内距/上限)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SnackbarGeometry { + /// 最小宽度(344,`[估]`;iced 无 min-width,见组件文档)。 + pub min_width: f32, + /// 最大宽度(600,`[源]` `SnackbarKt.ContainerMaxWidth`)。 + pub max_width: f32, + /// 单行高(48)。 + pub single_height: f32, + /// 双行高(68)。 + pub two_height: f32, + /// 距宿主边距(12,`[规格]`)。 + pub margin: f32, + /// 文本水平内距(16)。 + pub pad_h: f32, + /// 文本垂直内距(6)。 + pub pad_v: f32, + /// 文本 ↔ 动作间距(8)。 + pub gap: f32, + /// 圆角(4)。 + pub radius: f32, +} + +impl SnackbarGeometry { + /// 从令牌求值。 + pub fn of(ui: &Ui) -> Self { + let single_height = 48.0; + Self { + min_width: 344.0, + max_width: 600.0, + single_height, + two_height: 68.0, + margin: ui.space(Space::X3), + pad_h: ui.space(Space::X4), + pad_v: 6.0, + gap: ui.space(Space::X2), + radius: Component::Snackbar.spec().shape.radius_px(single_height), + } + } + + /// 按行数取高:`lines >= 2` → 68,否则 48(官方 `heightIn(min=...)` 的等价口径)。 + pub fn height_for(&self, lines: u8) -> f32 { + if lines >= 2 { + self.two_height + } else { + self.single_height + } + } +} + +/// Dialog 的几何。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DialogGeometry { + /// 本项目宽度(480,落在官方 280..560 内)。 + pub width: f32, + /// 官方下限(280)。 + pub min_width: f32, + /// 官方上限(560)。 + pub max_width: f32, + /// 圆角(28)。 + pub radius: f32, + /// 四边内边距(24)。 + pub padding: f32, + /// 标题下间距(16)。 + pub title_gap: f32, + /// 正文下间距(24)。 + pub body_gap: f32, + /// 动作主轴间距(8)。 + pub action_gap: f32, + /// 动作交叉间距(12)。 + pub action_cross: f32, +} + +impl DialogGeometry { + /// 从令牌求值(内边距与三个间距全部落在 4px 栅格上 ⇒ 走 `Space`)。 + pub fn of(ui: &Ui) -> Self { + let width = 480.0; + Self { + width, + min_width: 280.0, + max_width: 560.0, + radius: Component::Dialog.spec().shape.radius_px(width), + padding: ui.space(Space::X6), + title_gap: ui.space(Space::X4), + body_gap: ui.space(Space::X6), + action_gap: ui.space(Space::X2), + action_cross: ui.space(Space::X3), + } + } +} + +/// 空态的几何(**全部 `[估]`**:官方没有这个组件)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EmptyStateGeometry { + /// 图标块边长(96)。 + pub block: f32, + /// 图标块圆角(16,`ShapeScale::Large`)。 + pub block_radius: f32, + /// 块内图标(32,`IconSize::Empty`)。 + pub icon: f32, + /// 块 ↔ 标题(16)。 + pub gap_title: f32, + /// 标题 ↔ 副文案(8)。 + pub gap_body: f32, + /// 副文案 ↔ 动作(24)。 + pub gap_action: f32, +} + +impl EmptyStateGeometry { + /// 从令牌求值(圆角档位与图标档位是真令牌,96 / 三个间距是项目自定)。 + pub fn of(ui: &Ui) -> Self { + let block = 96.0; + Self { + block, + block_radius: Component::EmptyState.spec().shape.radius_px(block), + icon: IconSize::Empty.px(), + 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/pixel.rs b/crates/app-iced/src/components/feedback/pixel.rs new file mode 100644 index 0000000..704de58 --- /dev/null +++ b/crates/app-iced/src/components/feedback/pixel.rs @@ -0,0 +1,280 @@ +//! 像素取证的小工具(**仅测试**):读 PNG、按「底色 → 墨色」的覆盖率找连通块与长条。 +//! +//! 为什么不复用 `gallery/scan.rs`:那个模块的可见性是 +//! `pub(in crate::app::components::gallery)`,从家族目录拿不到(`selection/pixel.rs` 也是 +//! 同样的理由自己写了一份)。这里只重写本家族真正用到的几个纯函数。 +//! +//! **判据口径 1:覆盖率而不是精确色。** 4px 厚的进度条、28 圆角的卡片,边缘全是抗锯齿; +//! 按「精确色」连通会把它们切碎(实测进度条只剩中间 1~2 行)。 +//! **判据口径 2:覆盖率 ≥50%。** 50% 是几何边界(30% 会把半格抗锯齿也算进去,4px 量成 6)。 + +use std::path::PathBuf; + +use iced::Color; + +/// 8bit RGB。 +pub(super) fn rgb(color: Color) -> [u8; 3] { + [ + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + ] +} + +/// 容差比较(抗锯齿/取整)。 +pub(super) fn matches(pixel: [u8; 3], expected: [u8; 3]) -> bool { + pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 10) +} + +/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。 +pub(super) fn shot_path() -> Option { + let path = PathBuf::from(std::env::var_os("ULL_GALLERY_SHOT")?); + if path.is_absolute() || path.exists() { + return Some(path); + } + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + Some(root.join(path)) +} + +/// 一张图的像素访问器。 +pub(super) struct Shot { + pub(super) width: usize, + pub(super) height: usize, + pixels: Vec<[u8; 3]>, +} + +impl Shot { + pub(super) fn load(path: &std::path::Path) -> Result { + let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?; + let image = image::load_from_memory(&bytes) + .map_err(|error| format!("解 PNG 失败:{error}"))? + .to_rgb8(); + let (width, height) = (image.width() as usize, image.height() as usize); + Ok(Self { + width, + height, + pixels: image.pixels().map(|p| p.0).collect(), + }) + } + + pub(super) fn at(&self, x: usize, y: usize) -> [u8; 3] { + self.pixels[y * self.width + x] + } +} + +/// 一个外接框(闭区间)。 +#[derive(Debug, Clone, Copy)] +pub(super) struct BBox { + pub(super) left: usize, + pub(super) right: usize, + pub(super) top: usize, + pub(super) bottom: usize, +} + +impl BBox { + pub(super) fn new(x: usize, y: usize) -> Self { + Self { + left: x, + right: x, + top: y, + bottom: y, + } + } + + pub(super) fn include(&mut self, x: usize, y: usize) { + self.left = self.left.min(x); + self.right = self.right.max(x); + self.top = self.top.min(y); + self.bottom = self.bottom.max(y); + } + + pub(super) fn width(&self) -> usize { + self.right - self.left + 1 + } + + pub(super) fn height(&self) -> usize { + self.bottom - self.top + 1 + } +} + +/// 尺寸容差 ±1(rubric §6 的默认容差:控件常落在半像素上,实测会多一行抗锯齿)。 +pub(super) fn near(value: usize, expected: usize) -> bool { + value.abs_diff(expected) <= 1 +} + +pub(super) fn dims(boxes: &[BBox]) -> String { + boxes + .iter() + .map(|bbox| format!("{}×{}", bbox.width(), bbox.height())) + .collect::>() + .join(", ") +} + +/// `pixel` 在「`under` → `fill`」这条混色线上的**覆盖率**(0..1;≥0.5 视为「算这块墨」)。 +/// +/// 投影到混色向量上取比例:抗锯齿像素正好落在 0..1 之间,另一种墨(文字、别的容器色) +/// 会落在线的两侧偏远处,比例被夹到 0 或 1 之外 → 用「偏离线距离」再筛一道。 +pub(super) fn coverage(pixel: [u8; 3], fill: [u8; 3], under: [u8; 3]) -> f32 { + let axis = [0, 1, 2].map(|i| f32::from(fill[i]) - f32::from(under[i])); + let offset = [0, 1, 2].map(|i| f32::from(pixel[i]) - f32::from(under[i])); + let square: f32 = axis.iter().map(|a| a * a).sum(); + if square <= f32::EPSILON { + return 0.0; + } + let t: f32 = (0..3).map(|i| axis[i] * offset[i]).sum::() / square; + let distance = (0..3) + .map(|i| (offset[i] - t * axis[i]).powi(2)) + .sum::() + .sqrt(); + // 8.0 而不是 16.0:深色填充(如 InverseSurface)与浅底之间的混色轴很长,深色**文字** + // (OnSurfaceVariant)投影上去残差只有 ≈9 —— 16 的阈值会把文字行算成填充,于是 48 高的 + // 提示条被切成好几段(实测 46 + 1 + 1)。8.0 只排除文字,保留抗锯齿边(残差 0~3)。 + if distance > 8.0 { + return 0.0; + } + t.clamp(0.0, 1.0) +} + +/// 第 `y` 行上所有「算 `fill` 那块墨」的连续段 `(起点, 长度)`。 +pub(super) fn runs(shot: &Shot, y: usize, fill: Color, under: Color) -> Vec<(usize, usize)> { + let (ink, base) = (rgb(fill), rgb(under)); + let mut found = Vec::new(); + let mut start: Option = None; + for x in 0..shot.width { + let hit = coverage(shot.at(x, y), ink, base) >= 0.5; + match (hit, start) { + (true, None) => start = Some(x), + (false, Some(from)) => { + found.push((from, x - from)); + start = None; + } + _ => {} + } + } + if let Some(from) = start { + found.push((from, shot.width - from)); + } + found +} + +/// 一行里有多少「算 `fill` 那块墨」的像素(按覆盖率 ≥50% 判)。 +/// +/// 用**计数**而不是「最长段」:一条 344 宽的提示条,里面一行中文字会把它切成几段, +/// 按最长段判会把中间那些行漏掉(实测 48 高被切成 6 + 6 两段)。 +pub(super) fn row_ink(shot: &Shot, y: usize, fill: Color, under: Color) -> usize { + let (ink, base) = (rgb(fill), rgb(under)); + (0..shot.width) + .filter(|x| coverage(shot.at(*x, y), ink, base) >= 0.5) + .count() +} + +/// 连续行里「墨量 ≥ `min_ink`」的那些行 → 聚成**竖带** `(top, bottom)`。 +/// +/// 用途:一条 4px 的进度条在图上就是「4 个连续行、每行都有 ~320 个墨点」; +/// 一个 48 高的提示条就是「48 个连续行」。比按精确色找 bbox 稳得多。 +pub(super) fn bands(shot: &Shot, fill: Color, under: Color, min_ink: usize) -> Vec<(usize, usize)> { + let mut bands: Vec<(usize, usize)> = Vec::new(); + let mut current: Option = None; + for y in 0..shot.height { + let hit = row_ink(shot, y, fill, under) >= min_ink; + match (hit, current) { + (true, None) => current = Some(y), + (false, Some(top)) => { + bands.push((top, y - 1)); + current = None; + } + _ => {} + } + } + if let Some(top) = current { + bands.push((top, shot.height - 1)); + } + bands +} + +/// 竖带里最宽的一行的段长:取「每行最长连通段」的最大值。 +/// +/// 为什么要取最大而不是某一行:矩形控件上下有留白行(没文字),只有那几行量得到全宽; +/// 文字会把中间行切成几段。两条同色矩形并排时,`runs` 分别返回各自的长段,所以也不会串。 +pub(super) fn band_width(shot: &Shot, band: (usize, usize), fill: Color, under: Color) -> usize { + (band.0..=band.1) + .map(|y| { + runs(shot, y, fill, under) + .into_iter() + .map(|(_, len)| len) + .max() + .unwrap_or(0) + }) + .max() + .unwrap_or(0) +} + +/// 某种颜色的**精确色**连通块(只用于「找着一大块实心」这种粗定位)。 +pub(super) fn blobs(shot: &Shot, color: Color, min_side: usize) -> Vec { + let target = rgb(color); + let mut seen = vec![false; shot.width * shot.height]; + let mut found = Vec::new(); + for y in 0..shot.height { + for x in 0..shot.width { + if seen[y * shot.width + x] || !matches(shot.at(x, y), target) { + continue; + } + let mut stack = vec![(x, y)]; + seen[y * shot.width + x] = true; + let mut bbox = BBox::new(x, y); + while let Some((cx, cy)) = stack.pop() { + bbox.include(cx, cy); + for (nx, ny) in [ + (cx + 1, cy), + (cx.saturating_sub(1), cy), + (cx, cy + 1), + (cx, cy.saturating_sub(1)), + ] { + let inside = nx < shot.width && ny < shot.height; + if inside && !seen[ny * shot.width + nx] && matches(shot.at(nx, ny), target) { + seen[ny * shot.width + nx] = true; + stack.push((nx, ny)); + } + } + } + if bbox.width() >= min_side && bbox.height() >= min_side { + found.push(bbox); + } + } + } + found +} + +/// 在 `bbox` 内找「最接近 `ink`(而不是 `under`)」的像素外接框(量文字/图标的内缩)。 +pub(super) fn ink_inside( + shot: &Shot, + bbox: &BBox, + ink: Color, + under: Color, + margin: usize, +) -> Option { + let (target, base) = (rgb(ink), rgb(under)); + let mut found: Option = None; + for y in (bbox.top + margin)..=(bbox.bottom.saturating_sub(margin)) { + for x in (bbox.left + margin)..=(bbox.right.saturating_sub(margin)) { + let pixel = shot.at(x, y); + let to_ink = pixel + .iter() + .zip(target) + .map(|(a, b)| u32::from(a.abs_diff(b))) + .sum::(); + let to_base = pixel + .iter() + .zip(base) + .map(|(a, b)| u32::from(a.abs_diff(b))) + .sum::(); + if to_ink < to_base { + match &mut found { + Some(bbox) => bbox.include(x, y), + None => found = Some(BBox::new(x, y)), + } + } + } + } + found +} diff --git a/crates/app-iced/src/components/feedback/probe.rs b/crates/app-iced/src/components/feedback/probe.rs new file mode 100644 index 0000000..60c00d6 --- /dev/null +++ b/crates/app-iced/src/components/feedback/probe.rs @@ -0,0 +1,369 @@ +//! 反馈家族的**渲染层**几何断言:对着真截图量像素(M68 的教训:纯函数对、图可能是错的)。 +//! +//! 为什么住在 `feedback/` 而不是 `gallery/`:`gallery/feedback.rs` 只负责把矩阵摆进 harness, +//! 断言加上去会把它顶到 400 行红线(与 `selection/probe.rs` 同一处置)。像素工具在同级 +//! [`super::pixel`](`cfg(test)`)。 +//! +//! ## 量什么(每条对一个具体错误,打红它的是「变异」那一列) +//! +//! | 类 | 判据 | 打红它的变异 | +//! |---|---|---| +//! | 线性厚度 / 圆角 | 轨道带**恰好 4 行**;条的四角**透出底色** | 厚度 4→6 / `Full`→`None` | +//! | Snackbar 宽上下限 | 宿主 800→**600**、344→**344**、300→**300**(无下限 G17-d) | 上限 600→560 | +//! | Snackbar 高 | 单行带 **48** / 双行带 **68** | 48→40 | +//! | Dialog 宽 / 内距 | 最宽行 **480**(在 280..560 内)· 标题墨迹左缩 **≈24** | 480→560 / pad 24→16 | +//! | 环形 / 空态块 / scrim | 环 40 且四角无墨 · 块 **96×96** · 遮罩带颜色 = 预合成实色 | 40→48 / 96→88 | +//! +//! ## 复现(worktree 根目录;四个部分各一张图,浅/深各一轮) +//! +//! ```text +//! for part in progress snackbar dialog empty; do +//! ULL_GALLERY_SECTION=feedback ULL_FEEDBACK_PART=$part \ +//! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery \ +//! --screenshot target/m78/$part-light.png --screenshot-theme=light +//! ULL_FEEDBACK_PART=$part ULL_GALLERY_SHOT=target/m78/$part-light.png \ +//! cargo test -p unlucky-app-iced feedback::probe +//! done +//! ``` + +use unlucky_design::color::{Role, SchemeFlavor}; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::typography::Platform; + +use super::dialog::scrim; +use super::pixel::{ + band_width, bands, blobs, dims, ink_inside, matches, near, rgb, row_ink, runs, shot_path, BBox, + Shot, +}; +use crate::theme::{Families, Ui}; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) +} + +/// 一条测试覆盖当前截图对应的那一段(段由 `ULL_FEEDBACK_PART` 指定,默认 `all`)。 +#[test] +fn feedback_geometry_matches_the_reference() { + let Some(path) = shot_path() else { + eprintln!( + "[feedback-probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\ + 需要先出图(命令见本文件模块文档,注意 ULL_GALLERY_SECTION=feedback + ULL_FEEDBACK_PART)。" + ); + return; + }; + let shot = Shot::load(&path).expect("读 feedback 截图"); + assert_eq!( + (shot.width, shot.height), + (1180, 760), + "验收图必须 1180×760 @100% DPI" + ); + + let flavor = if matches( + shot.at(2, 2), + rgb(ui(SchemeFlavor::Light).color(Role::Surface)), + ) { + SchemeFlavor::Light + } else { + SchemeFlavor::Dark + }; + let ui = ui(flavor); + let part = std::env::var("ULL_FEEDBACK_PART").unwrap_or_else(|_| "all".to_owned()); + let part = part.trim().to_ascii_lowercase(); + + match part.as_str() { + "progress" => check_progress(&shot, &ui), + "snackbar" => check_snackbar(&shot, &ui), + "dialog" => check_dialog(&shot, &ui), + "empty" => check_empty(&shot, &ui), + other => { + // 整屏时只查最不容易被折叠线切掉的那一类;分图才是逐项判据的场合。 + check_progress(&shot, &ui); + eprintln!("[feedback-probe] part={other:?} 只查了 progress 那一类;其余请在分图里查"); + } + } + + eprintln!("[feedback-probe] OK flavor={flavor:?} part={part}"); +} + +/// §12:线性厚度 4 / 圆角 full;环形 40 且真圆。 +fn check_progress(shot: &Shot, ui: &Ui) { + // 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后 + // 「实测 6 == 期望 6」会照样绿,等于没有断言(复审 P2-4 的同一类坑)。 + let surface = ui.color(Role::Surface); + let track = ui.color(Role::SecondaryContainer); + let active = ui.color(Role::Primary); + + // ---- 12.1 厚度:轨道带恰好 4 行(宿主 320 宽 ⇒ 每行 ~320 个轨道墨点)-------- + let bar_bands = bands(shot, track, surface, 300); + assert!( + !bar_bands.is_empty(), + "没找到线性进度轨道(宿主宽 320,应有 ≥300 个墨点的行)" + ); + for (top, bottom) in &bar_bands { + let height = bottom - top + 1; + assert!( + near(height, 4), + "12.1:线性进度厚必须 4(A1 Height;实测 {height} @ 行 {top})" + ); + } + + // ---- 12.1 圆角 full:条的四角必须透出底色(直角会把角填满)------------------ + let (top, _) = bar_bands[0]; + let (left, len) = runs(shot, top + 1, track, surface) + .into_iter() + .find(|(_, len)| *len >= 300) + .expect("轨道那一行必须有 ≥300 的长段"); + assert!( + !matches(shot.at(left, top), rgb(track)), + "12.1:圆角 full(2) 的左上角必须透出底色(直角会填满)" + ); + assert!( + matches(shot.at(left + len / 2, top + 1), rgb(track)), + "条的中间必须有墨(否则是没画出来,不是圆角)" + ); + + // ---- 12.3 环形:40 的外接框,且四角无墨(真圆)------------------------------ + let rings: Vec = blobs(shot, active, 30) + .into_iter() + .filter(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40)) + .collect(); + assert!( + !rings.is_empty(), + "12.3:没找到 40(±1) 的环形激活层;候选:{}", + dims(&blobs(shot, active, 10)) + ); + for ring in &rings { + for (x, y) in [ + (ring.left, ring.top), + (ring.right, ring.top), + (ring.left, ring.bottom), + (ring.right, ring.bottom), + ] { + assert!( + !matches(shot.at(x, y), rgb(active)), + "12.3:环必须是真圆(外接框四角不该有墨)@ ({x},{y})" + ); + } + } + + eprintln!( + "[feedback-probe] progress:轨道带 {} 条(高 {:?})· 环 {} 个 40(±1)", + bar_bands.len(), + bar_bands + .iter() + .map(|(top, bottom)| bottom - top + 1) + .collect::>(), + rings.len() + ); +} + +/// §14:宽度上下限 + 单/双行高。 +fn check_snackbar(shot: &Shot, ui: &Ui) { + // 参考值写字面量(A1):600 = SnackbarKt.ContainerMaxWidth,48/68 = SnackbarTokens。 + let surface = ui.color(Role::Surface); + let bar = ui.color(Role::InverseSurface); + + // 行墨量下限 200:最窄的那条只有 300 宽,里面还有 ~60px 的文字 ⇒ 每行 ~240 个实心点。 + // 高度 ≥40 才算提示条(标题行偶有一两行落在这条混色线上,那类 1 行的带要滤掉)。 + let found: Vec<(usize, usize)> = bands(shot, bar, surface, 200) + .into_iter() + .filter(|(top, bottom)| bottom - top + 1 >= 40) + .collect(); + assert!( + found.len() >= 3, + "14.x:应有 ≥3 条提示条(上限宽 / 双行 / 两条窄的),实测 {}", + found.len() + ); + + // 宽度集 = 每条带里最宽的那一行(留白行没有文字,量得到全宽)。 + // **注意**:并排的两条窄条落在同一组行里,所以这一屏的宽度集只能证「存在 344 与 300」; + // 要把其中一条单独改坏,变异必须同时改所有实例(报告「变异」一节里写明)。 + let mut widths: Vec = found + .iter() + .map(|band| band_width(shot, *band, bar, surface)) + .collect(); + let mut heights: Vec = found.iter().map(|(top, bottom)| bottom - top + 1).collect(); + widths.sort_unstable(); + heights.sort_unstable(); + + assert!( + !widths.iter().any(|w| *w > 601), + "没有一条可以超过上限 600:实测 {widths:?}" + ); + assert!( + widths.iter().any(|w| near(*w, 600)), + "上限宽必须是 600(SnackbarKt.ContainerMaxWidth,宿主 800 时夹取),实测宽度集 {widths:?}" + ); + // 最小宽的取值口径 344,以及「没有下限」的反证 300 + assert!( + widths.iter().any(|w| near(*w, 344)), + "宿区 344 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}" + ); + assert!( + widths.iter().any(|w| near(*w, 300)), + "宿区 300 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}" + ); + // 高度 48 / 68 + assert!( + heights.iter().any(|h| near(*h, 48)), + "14.3:单行高 48,实测高度集 {heights:?}" + ); + assert!( + heights.iter().any(|h| near(*h, 68)), + "14.3:双行高 68,实测高度集 {heights:?}" + ); + + // 14.6 动作色:`InversePrimary` 必须出现在提示条内(不是按钮家族的 OnSurfaceVariant) + let action = rgb(ui.color(Role::InversePrimary)); + let action_pixels: usize = (0..shot.height) + .map(|y| { + (0..shot.width) + .filter(|x| matches(shot.at(*x, y), action)) + .count() + }) + .sum(); + assert!( + action_pixels > 40, + "14.6:动作标签应是 InversePrimary(实测该色只有 {action_pixels} px)" + ); + + eprintln!( + "[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px" + ); +} + +/// §7:卡片宽 480(官方 280..560 内)+ 内距 24 + scrim 毯。 +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); + + // ---- 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); + } + } + } + let (width, card_left, card_row) = widest; + assert!( + near(width, 480), + "7.4:对话框宽必须是 480(G17-e 项目取值),实测 {width}" + ); + + // 该行上下扩成卡片带(同一张卡片的连续行;两张卡并排时各自成带) + 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; + } + + // ---- 7.3 左右内距:标题墨迹距卡片左缘 ≈ 24(外加 24px 字形的 bearing,22..28)---- + let bbox = BBox { + left: card_left, + right: card_left + width - 1, + top, + bottom, + }; + let title = + ink_inside(shot, &bbox, ui.color(Role::OnSurface), card, 2).expect("卡片里必须有标题墨迹"); + let inset = title.left.saturating_sub(card_left); + assert!( + (22..=28).contains(&inset), + "7.3:四边内距 24 ⇒ 标题墨迹左缩 ≈24(含 bearing 22..28),实测 {inset}" + ); + let top_inset = title.top.saturating_sub(top); + assert!( + (24..=40).contains(&top_inset), + "7.3:上内距 24 ⇒ 标题墨迹顶缩 ≈24+行内 bearing,实测 {top_inset}" + ); + + // ---- 7.7 scrim:一块 ≥700 宽的遮罩带,颜色 = 预合成实色 ---------------------- + let backdrop = scrim(ui); + let scrim_bands = bands(shot, backdrop, surface, 700); + assert!( + !scrim_bands.is_empty(), + "7.7:没找到 ≥700 宽的遮罩带(宿主 800 宽那格)" + ); + + // ---- 7.6 动作色:Dialog 动作是 `Primary`(不是 OnSurfaceVariant)------------- + // 按「更靠近 Primary 而不是卡片」判,不按精确色:14px 的中文字形只有边缘几个像素能命中 + // 精确色(实测 0),而 Primary 与卡片色的距离很大,这个判据又稳又不误判。 + 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", + scrim_bands.len() + ); +} + +/// §15:空态图标块 96(`[估]`)+ CTA 40 高。 +fn check_empty(shot: &Shot, ui: &Ui) { + // 参考值写字面量:96/16/32 全是 `[估]`(项目自定,官方无此组件)。 + let surface = ui.color(Role::Surface); + let block = ui.layer(MaterialLayer::Card); + + // 下限 40 而不是 88:块只有 96 宽,中间的图标(32)会把那几行的块色墨量压到 ≈64。 + let found = bands(shot, block, surface, 40); + let (top, bottom) = found + .iter() + .copied() + .max_by_key(|(top, bottom)| bottom - top) + .filter(|(top, bottom)| near(bottom - top + 1, 96)) + .unwrap_or_else(|| { + panic!("15.1:没找到高 96 的图标块(`[估]` 项目自定值),实测量到的带:{found:?}") + }); + + let width = band_width(shot, (top, bottom), block, surface); + assert!(near(width, 96), "15.1:图标块必须 96×96,实测宽 {width}"); + + // 15.6 CTA:标准 Filled 按钮 40 高(`Primary` 实心带)。 + // 下限 20 而不是 60:按钮约 100 宽,文字那几行只剩 ≈44 个实心像素(文字不是实心色)。 + let cta = bands(shot, ui.color(Role::Primary), surface, 20); + assert!( + cta.iter().any(|(top, bottom)| near(bottom - top + 1, 40)), + "15.6:CTA 应是标准按钮(可见高 40),实测带 {cta:?}" + ); + + eprintln!( + "[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · CTA 带 {cta:?}", + bottom - top + 1 + ); +} diff --git a/crates/app-iced/src/components/feedback/progress.rs b/crates/app-iced/src/components/feedback/progress.rs new file mode 100644 index 0000000..0125be6 --- /dev/null +++ b/crates/app-iced/src/components/feedback/progress.rs @@ -0,0 +1,253 @@ +//! 进度指示器(`research/12b` §12):`linear` 与 `circular`,各有确定态与不定态。 +//! +//! ## 两种形态、四种态 +//! +//! | 形态 | 确定态 | 不定态 | +//! |---|---|---| +//! | [`linear`] | 轨道 `SecondaryContainer` + 指示条 `Primary`,厚 **4**、圆角 full | 官方 1750ms **双段**追逐([`phase_segments`]) | +//! | [`circular`] | 40 环 + 4 线宽,弧长 = 数值 × 360° | 官方 6000ms(弧长 0.1↔0.87 + 旋转 0↔360,[`CircularGeometry::indeterminate`]) | +//! +//! ## 为什么不定态要调用方给 `phase` +//! +//! 契约 §2:组件不持有时钟。`phase`(0..1 的一个周期)由页面按 `Subscription` 推进; +//! harness 因此能拍**确定**的中间帧(否则每张图都是抓阄)。周期时长在几何里 +//! ([`LinearGeometry::cycle_ms`] / [`CircularGeometry::cycle_ms`]),调用方按它换算。 +//! +//! ## CPU 降级路径(D27) +//! +//! 本组件**零半透明、零模糊**:轨道/指示条都是不透明实色,环形是 `stroke` 实色。 +//! 波浪形态(线性振幅 3 / 波长 40,环形振幅 1.6 / 波长 15,`[源]`)**未实现**, +//! 用直线段/等弧长替代——这是 `12b §12.4` 明确允许的登记项(见报告「偏离登记」)。 +//! +//! ## 环形为什么手搓 SVG +//! +//! iced 的 `canvas` feature 没开(`Cargo.toml` 不归本 mission),而圆弧用容器边框画不出来。 +//! 令牌图标的做法(24 网格 SVG path + `currentColor` + 内容哈希缓存)同样适用于几何图元: +//! 一份 handle 渲染两层(轨道一层、弧一层),相位不变时 iced 侧命中缓存。 + +use iced::border::Radius; +use iced::widget::{container, row, svg, Space}; +use iced::{Background, Border, Element, Length}; + +use unlucky_design::color::Role; + +use crate::theme::Ui; + +use super::super::layout; +use super::geometry::{phase_segments, CircularGeometry, LinearGeometry}; + +/// `FillPortion` 的比例分辨率:0..1 的占比 × 1000 取整(1/1000 像素级,够用且不溢出 u16)。 +const PORTIONS: f32 = 1000.0; + +/// 弧长小于这个角度就不画激活层(圆头在 0 弧长上会画出一个点,看着像「已完成」)。 +const MIN_SWEEP_DEG: f32 = 1.0; + +/// 100% 时弧首尾重合,`A` 命令画不出整圆 ⇒ 超过这个角度改画整环。 +const FULL_SWEEP_DEG: f32 = 359.9; + +/// 进度的取值:确定(0..1)或不定(相位 0..1)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Amount { + /// 确定态:0..1。 + Determinate(f32), + /// 不定态:一个周期内的相位(0..1,可越界,采样时按周期取模)。 + Indeterminate(f32), +} + +/// 一个进度指示器的取值(**不持有任何状态**)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Progress { + amount: Amount, +} + +impl Progress { + /// 确定态:`value` 越界会被夹到 0..1(0% 只画轨道)。 + pub fn determinate(value: f32) -> Self { + Self { + amount: Amount::Determinate(value.clamp(0.0, 1.0)), + } + } + + /// 不定态:`phase` 是周期内的相位(调用方按 `cycle_ms` 推进)。 + pub fn indeterminate(phase: f32) -> Self { + Self { + amount: Amount::Indeterminate(phase), + } + } + + /// 取值(断言用;生产路径不读它,内部直接 match 字段)。 + #[cfg(test)] + pub fn amount(self) -> Amount { + self.amount + } + + /// 本帧要画的占轨道比例区间(0..1)。 + /// + /// 确定态 1 段、不定态 2 段(官方双线);区间可能为空(相位在「两段都在屏幕外」的时刻), + /// 调用方按 `end > start` 过滤。 + pub fn spans(self) -> Vec<(f32, f32)> { + match self.amount { + Amount::Determinate(value) => vec![(0.0, value.clamp(0.0, 1.0))], + Amount::Indeterminate(phase) => phase_segments(phase).to_vec(), + } + } +} + +/// 线性进度条(轨道 + 指示条,厚 4、圆角 full)。 +/// +/// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**, +/// 所以 50% 在任何宿主宽度下都精确是半条。 +pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> { + let geometry = LinearGeometry::of(ui); + let track_color = ui.color(Role::SecondaryContainer); + let active_color = ui.color(Role::Primary); + + let mut bar = row![] + .width(Length::Fill) + .height(Length::Fixed(geometry.thickness)); + let mut cursor = 0.0_f32; + for (start, end) in progress.spans() { + let (start, end) = (start.clamp(0.0, 1.0), end.clamp(0.0, 1.0)); + if end <= start { + continue; + } + if start > cursor { + bar = bar.push(spacer(start - cursor)); + } + bar = bar.push( + container(Space::new().width(Length::Fill).height(Length::Fill)) + .width(Length::FillPortion(portion(end - start))) + .height(Length::Fill) + .style(move |_theme| container::Style { + background: Some(Background::Color(active_color)), + border: Border { + radius: Radius::new(geometry.radius), + ..Border::default() + }, + ..container::Style::default() + }), + ); + cursor = end; + } + if cursor < 1.0 { + bar = bar.push(spacer(1.0 - cursor)); + } + + container(bar) + .width(Length::Fill) + .height(Length::Fixed(geometry.thickness)) + .style(move |_theme| container::Style { + background: Some(Background::Color(track_color)), + border: Border { + radius: Radius::new(geometry.radius), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +/// 环形进度(40 直径 / 4 线宽;确定态画弧,不定态按官方节奏扫)。 +pub fn circular<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> { + let geometry = CircularGeometry::of(ui); + let (sweep, rotation) = match progress.amount { + Amount::Determinate(value) => (value.clamp(0.0, 1.0) * 360.0, 0.0), + Amount::Indeterminate(phase) => { + let (sweep_fraction, rotation_fraction) = geometry.indeterminate(phase); + (sweep_fraction * 360.0, rotation_fraction * 360.0) + } + }; + + let track = glyph( + &geometry, + ring_source(&geometry), + ui.color(Role::SecondaryContainer), + ); + + // 0% 只画轨道(不画弧,也不画圆头小点);100% 画整环(`A` 命令的首尾重合画不出整圆) + let active = if sweep < MIN_SWEEP_DEG { + None + } else if sweep >= FULL_SWEEP_DEG { + Some(glyph( + &geometry, + ring_source(&geometry), + ui.color(Role::Primary), + )) + } else { + Some(glyph( + &geometry, + arc_source(&geometry, sweep, rotation), + ui.color(Role::Primary), + )) + }; + + let mut layers = vec![track]; + layers.extend(active); + + // Q9 的同族:几何图元也必须落在**固定尺寸**的盒子里,否则 usvg 会按 Contain 撑满父级。 + layout::fixed( + geometry.diameter, + geometry.diameter, + iced::widget::stack(layers), + ) +} + +/// 两段之间/两端的透明占位。 +fn spacer<'a, M: 'a>(fraction: f32) -> Element<'a, M> { + Space::new() + .width(Length::FillPortion(portion(fraction))) + .height(Length::Fill) + .into() +} + +/// 0..1 的占比 → `FillPortion`(≥1,避免 0 段;调用方已过滤空段)。 +fn portion(fraction: f32) -> u16 { + (fraction.clamp(0.0, 1.0) * PORTIONS).round().max(1.0) as u16 +} + +/// 一层几何图元(固定尺寸 + `currentColor` 上色)。 +fn glyph<'a, M: 'a>( + geometry: &CircularGeometry, + source: String, + color: iced::Color, +) -> Element<'a, M> { + svg(svg::Handle::from_memory(source.into_bytes())) + .width(Length::Fixed(geometry.diameter)) + .height(Length::Fixed(geometry.diameter)) + .style(move |_theme, _status| svg::Style { color: Some(color) }) + .into() +} + +/// 整环(轨道用,也用作 100% 的激活层)。 +fn ring_source(geometry: &CircularGeometry) -> String { + let (size, thickness) = (geometry.diameter, geometry.thickness); + let center = size / 2.0; + let radius = geometry.arc_radius(); + format!( + "" + ) +} + +/// 一段圆弧(`rotation` 起、扫 `sweep_deg`;0° = 12 点、顺时针 = M3 口径)。 +fn arc_source(geometry: &CircularGeometry, sweep_deg: f32, rotation_deg: f32) -> String { + let (size, thickness) = (geometry.diameter, geometry.thickness); + let center = size / 2.0; + let radius = geometry.arc_radius(); + let (x0, y0) = point(center, radius, rotation_deg); + let (x1, y1) = point(center, radius, rotation_deg + sweep_deg); + let large = u8::from(sweep_deg > 180.0); + format!( + "" + ) +} + +/// 极角(0° = 12 点,顺时针)→ 画布坐标(y 向下,所以向上取负)。 +fn point(center: f32, radius: f32, deg: f32) -> (f32, f32) { + let rad = deg.to_radians(); + (center + radius * rad.sin(), center - radius * rad.cos()) +} diff --git a/crates/app-iced/src/components/feedback/snackbar.rs b/crates/app-iced/src/components/feedback/snackbar.rs new file mode 100644 index 0000000..489a3b5 --- /dev/null +++ b/crates/app-iced/src/components/feedback/snackbar.rs @@ -0,0 +1,192 @@ +//! Snackbar(`research/12b` §14):底/顶部提示条 + 动作 + 关闭。 +//! +//! ## 官方值(`[源]` = A1,`SnackbarTokens` + `SnackbarKt`,`javap` 核对) +//! +//! | # | 项 | 值 | 落点 | +//! |---|---|---|---| +//! | 14.1 | 圆角 | **4**(`CornerExtraSmall`) | `Component::Snackbar.spec().shape` | +//! | 14.2 | 容器色 | **`InverseSurface`**(高程 L3 是设计口头径,见下「不画阴影」) | [`Role::InverseSurface`] | +//! | 14.3 | 高 | 单行 **48** / 双行 **68** | [`SnackbarGeometry::height`] | +//! | 14.4 | 内距 | 水平 **16** / 垂直 **6** / 文本↔动作 **8** | `Space::X4` / 6 / `Space::X2` | +//! | 14.6 | 颜色 | 文本 `InverseOnSurface`、动作 **`InversePrimary`**、图标 24 `InverseOnSurface` | 组件层取角色 | +//! | — | 宽度上限 | **600**(`SnackbarKt.ContainerMaxWidth`) | `max_width` | +//! +//! ## 宽度上下限(一条**真实**限制,登记 G17-d) +//! +//! 官方只有上限 600,没有下限常数;本项目取 **344 `[估]`**(M2 时代口径,观感连续)。 +//! iced 的 `Length` **没有 min-width**(与按钮家族 G15「最小宽 58 不施加」同一堵墙), +//! 所以:**上限在组件里强制**(`container::max_width`),**下限由宿主保证**——页面给提示条 +//! 的区域至少 344 宽。gallery 用三个宿主宽度把这条限制摊开(300 → 300、344 → 344、800 → 600), +//! 渲染层断言量的是这三种实测值,不假装下限被组件强制住了。 +//! +//! ## 超时与 dismiss:状态归调用方 +//! +//! 组件**不计时**:显示时长、自动消失、`on_timeout` 全在调用方(页面)手里。 +//! 组件的语义只有三条:`placement` 决定贴顶还是贴底(并给出 12 的外边距)、 +//! `action` 给一个文字动作、`dismiss` 给关闭键。消息要不要触发「撤销」由调用方决定。 +//! +//! ## 不画阴影 +//! +//! 官方是 L3,但 D27(CPU 档禁模糊)与令牌层 `ShadowSpec` 仍是 M63 占位值这两条都指向 +//! 「先不画」——层级靠 `InverseSurface` 与页面的 scrim/留白表达。登记为偏离 D-3。 + +use iced::widget::{container, row, text}; +use iced::{Alignment, Background, Border, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +use super::super::interaction::Interaction; +use super::action; +use super::panel::SnackbarGeometry; + +/// 贴底还是贴顶(官方两种都有;本项目默认贴底)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Placement { + /// 贴底(桌面默认:不挡页头,也不挡主操作)。 + Bottom, + /// 贴顶(页头有刷新按钮时用于「结果在页头附近」的场景)。 + Top, +} + +impl Placement { + /// 垂直对齐:贴底 = `End`,贴顶 = `Start`。 + fn vertical(self) -> Alignment { + match self { + Placement::Bottom => Alignment::End, + Placement::Top => Alignment::Start, + } + } +} + +/// 一条提示条的装配描述(**不持有任何状态所有权**)。 +#[derive(Debug, Clone)] +pub struct Snackbar<'a, M> { + text: &'a str, + lines: u8, + placement: Placement, + action: Option<(&'a str, M)>, + dismiss: Option, + state: Option, +} + +impl<'a, M> Snackbar<'a, M> { + /// 单行提示条(高 48)。 + pub fn new(text: &'a str) -> Self { + Self { + text, + lines: 1, + placement: Placement::Bottom, + action: None, + dismiss: None, + state: None, + } + } + + /// 双行(高 68)。**行数由调用方声明**:iced 没有 `heightIn(min=…)`,官方那两条高度值 + /// 只能显式给(文本自己换行与否不改变容器高,这是 M3 的口径)。 + pub fn two_lines(mut self) -> Self { + self.lines = 2; + self + } + + /// 贴底 / 贴顶。 + pub fn placement(mut self, placement: Placement) -> Self { + self.placement = placement; + self + } + + /// 右侧文字动作(`InversePrimary`)。 + pub fn action(mut self, label: &'a str, on_press: M) -> Self { + self.action = Some((label, on_press)); + self + } + + /// 关闭键(`InverseOnSurface`)。 + pub fn dismiss(mut self, on_press: M) -> Self { + self.dismiss = Some(on_press); + self + } + + /// 强制动作件的视觉态(gallery 取证用;运行期不要调)。 + pub fn state(mut self, state: Interaction) -> Self { + self.state = Some(state); + self + } +} + +/// 画一条提示条。 +/// +/// 返回的元素是 **`Fill` 高度的容器**:贴底/贴顶由它内部的对齐决定,调用方只要给它一块 +/// 可用区(`stack` 在页面上、或 gallery 的宿主盒),它就是那个位置上的居中提示条。 +pub fn snackbar<'a, M: Clone + 'a>(ui: &'a Ui, spec: Snackbar<'a, M>) -> Element<'a, M> { + let geometry = SnackbarGeometry::of(ui); + let style = ui.style(TypeRole::BodyMedium); + let height = geometry.height_for(spec.lines); + let surface = ui.color(Role::InverseSurface); + let radius = iced::border::Radius::new(geometry.radius); + + let mut content = row![] + .align_y(Alignment::Center) + .spacing(geometry.gap) + .push( + text(spec.text) + .size(style.size_px) + .font(ui.font_of(style)) + .color(ui.color(Role::InverseOnSurface)) + .width(Length::Fill), + ); + + if let Some((label, message)) = spec.action { + content = content.push(action::text_action( + ui, + label, + Role::InversePrimary, + Role::InverseSurface, + Some(message), + spec.state, + )); + } + + if let Some(message) = spec.dismiss { + content = content.push(action::icon_action( + ui, + IconKind::Close, + Role::InverseOnSurface, + Role::InverseSurface, + Some(message), + spec.state, + )); + } + + let bar = container(content) + .width(Length::Fill) + .max_width(geometry.max_width) + .height(Length::Fixed(height)) + .padding(Padding { + top: geometry.pad_v, + right: geometry.pad_h, + bottom: geometry.pad_v, + left: geometry.pad_h, + }) + .style(move |_theme| container::Style { + background: Some(Background::Color(surface)), + border: Border { + radius, + ..Border::default() + }, + ..container::Style::default() + }); + + // 位置层:`Fill` 高度 + 外边距 12 + 水平居中;贴底/贴顶只改 `align_y`(Q8:不用 center_*)。 + container(bar) + .width(Length::Fill) + .height(Length::Fill) + .padding(Padding::from(geometry.margin)) + .align_x(Alignment::Center) + .align_y(spec.placement.vertical()) + .into() +} diff --git a/crates/app-iced/src/components/feedback/tests.rs b/crates/app-iced/src/components/feedback/tests.rs new file mode 100644 index 0000000..da32ee0 --- /dev/null +++ b/crates/app-iced/src/components/feedback/tests.rs @@ -0,0 +1,331 @@ +//! 纯函数级断言:几何对令牌、节奏对 A1、角色对变体。 +//! +//! 这里**只测数值与结构**(M68 的教训:函数对不代表图对)。对着真截图量像素的是 +//! [`super::probe`],两者缺一不可——本文件保证「数字对」,probe 保证「画出来是那个数字」。 + +use iced::Element; + +use unlucky_design::color::{Role, Scheme, SchemeFlavor, SCRIM_ALPHA}; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; +use unlucky_design::typography::Platform; + +use crate::theme::{to_iced, Families, Ui}; + +use super::super::interaction::{self, Interaction}; +use super::dialog::scrim; +use super::geometry::{phase_segments, CircularGeometry, LinearGeometry}; +use super::keyframes::{KeyPoint, Keyframes}; +use super::panel::{DialogGeometry, EmptyStateGeometry, SnackbarGeometry}; +use super::progress::{Amount, Progress}; +use super::snackbar::{Placement, Snackbar}; +use super::{circular, dialog, empty_state, linear, snackbar, Dialog, EmptyState}; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) +} + +// ---- §12 进度 --------------------------------------------------------------- + +/// 12.1:线性厚度 **4**、圆角 full ⇒ 2(`ShapeScale::Full.radius_px(4)`)。 +#[test] +fn linear_thickness_and_radius_come_from_the_tokens() { + let geometry = LinearGeometry::of(&ui(SchemeFlavor::Light)); + assert_eq!(geometry.thickness, 4.0, "12.1 线性厚度 = 4(A1 Height)"); + assert_eq!( + geometry.radius, + ShapeScale::Full.radius_px(4.0), + "12.1 圆角 full = 厚的一半" + ); + assert_eq!(geometry.radius, 2.0); + assert_eq!( + geometry.cycle_ms, 1750, + "不定态周期 = LinearAnimationDuration" + ); +} + +/// 12.3:环形 **40 / 4**,环中线半径 (40 − 4) / 2 = **18**。 +#[test] +fn circular_geometry_matches_the_tokens() { + let geometry = CircularGeometry::of(&ui(SchemeFlavor::Dark)); + assert_eq!( + geometry.diameter, 40.0, + "A1 CircularProgressIndicatorTokens.Size" + ); + assert_eq!(geometry.thickness, 4.0, "A1 ActiveThickness"); + assert_eq!(geometry.radius, 20.0, "圆角 full = 直径的一半"); + assert_eq!(geometry.arc_radius(), 18.0); + assert_eq!( + geometry.cycle_ms, 6000, + "A1 CircularAnimationProgressDuration" + ); +} + +/// 12.4:不定态两段**始终非负**且真的在跑(相位扫一圈,段中心单调右移)。 +#[test] +fn indeterminate_segments_stay_ordered_and_travel_right() { + let mut travelled = false; + let mut previous_end = 0.0; + for step in 0..=200 { + let phase = step as f32 / 200.0; + for (start, end) in phase_segments(phase) { + assert!( + start <= end + f32::EPSILON, + "相位 {phase:.3}:段起点 {start} 大于终点 {end}(前缘/后缘写反就会这样)" + ); + assert!( + (0.0..=1.0).contains(&start) && (0.0..=1.0).contains(&end), + "相位 {phase:.3}:段越出轨道 {start}..{end}" + ); + } + let first = phase_segments(phase)[0].1; + if phase > 0.0 && phase < 0.5 && first > previous_end { + travelled = true; + } + previous_end = first; + } + assert!(travelled, "段前沿必须随相位右移(否则就是一根不动的条)"); + + // 相位 0:两段都是空的(前沿/后缘都在 0) + assert_eq!(phase_segments(0.0), [(0.0, 0.0), (0.0, 0.0)]); + // 1000/1750 ≈ 0.5714:第一线前沿到头(= 1.0) + let (_, head) = phase_segments(1000.0 / 1750.0)[0]; + assert!((head - 1.0).abs() < 1.0e-5, "第一线前沿应在 1000ms 到 1.0"); +} + +/// 环形不定态:弧长在 0.1..0.87 之间(A1 min/max),旋转走满一圈。 +#[test] +fn circular_indeterminate_sweeps_the_official_range() { + let geometry = CircularGeometry::of(&ui(SchemeFlavor::Light)); + let mut min = f32::MAX; + let mut max = f32::MIN; + let mut rotation = f32::MIN; + for step in 0..=600 { + let (sweep, spin) = geometry.indeterminate(step as f32 / 600.0); + min = min.min(sweep); + max = max.max(sweep); + rotation = rotation.max(spin); + } + assert!( + (min - 0.1).abs() < 1.0e-3, + "弧长下限 0.1(CircularIndeterminateMinProgress),实测 {min}" + ); + assert!((max - 0.87).abs() < 1.0e-3, "弧长上限 0.87,实测 {max}"); + assert!( + (rotation - 1.0).abs() < 1.0e-3, + "旋转应走满一整圈,实测 {rotation}" + ); +} + +/// 确定态:越界值夹到 0..1(0% 只画轨道,100% 画满)。 +#[test] +fn determinate_values_are_clamped() { + assert_eq!( + Progress::determinate(1.5).amount(), + Amount::Determinate(1.0) + ); + assert_eq!( + Progress::determinate(-2.0).amount(), + Amount::Determinate(0.0) + ); + assert_eq!(Progress::determinate(0.5).spans(), vec![(0.0, 0.5)]); + assert_eq!(Progress::determinate(0.0).spans(), vec![(0.0, 0.0)]); +} + +/// keyframes 求值器:端点保持、相位循环、越界相位按周期取模。 +#[test] +fn keyframes_hold_ends_and_wrap() { + const LINE: Keyframes = Keyframes { + cycle_ms: 1000, + points: &[ + KeyPoint { + ms: 250, + value: 0.0, + easing: unlucky_design::motion::Easing::Linear, + }, + KeyPoint { + ms: 750, + value: 1.0, + easing: unlucky_design::motion::Easing::Linear, + }, + ], + }; + assert_eq!(LINE.sample(0.0), 0.0, "第一点之前保持首值"); + assert_eq!(LINE.sample(0.25), 0.0, "正好在第一点"); + assert!((LINE.sample(0.5) - 0.5).abs() < 1.0e-5, "中点线性插值"); + assert_eq!(LINE.sample(0.75), 1.0); + assert_eq!(LINE.sample(0.9), 1.0, "最后一点之后保持末值"); + assert_eq!(LINE.sample(1.25), 0.0, "相位 1.25 = 0.25(循环)"); + assert_eq!( + LINE.sample(-0.75), + 0.0, + "负相位按周期折返:-0.75 → 0.25 相位 = 250ms = 首点" + ); + assert!( + (LINE.sample(-0.6) - 0.3).abs() < 1.0e-5, + "-0.6 → 0.4 相位 = 400ms = 30%" + ); + assert_eq!(LINE.sample(f32::NAN), 0.0, "NaN 不 panic,给首值"); +} + +// ---- §14 Snackbar ----------------------------------------------------------- + +/// 14.3 / 14.1 / 宽度上限:48 / 68 / 4 / 600。 +#[test] +fn snackbar_heights_widths_and_radius_are_the_tokens() { + let geometry = SnackbarGeometry::of(&ui(SchemeFlavor::Light)); + assert_eq!(geometry.height_for(1), 48.0, "14.3 单行 48"); + assert_eq!(geometry.height_for(2), 68.0, "14.3 双行 68"); + assert_eq!(geometry.radius, 4.0, "14.1 圆角 4(CornerExtraSmall)"); + assert_eq!(geometry.max_width, 600.0, "SnackbarKt.ContainerMaxWidth"); + assert_eq!(geometry.min_width, 344.0, "G17-d [估] 最小宽"); + assert!(geometry.min_width < geometry.max_width, "上下限必须有区间"); + assert_eq!(geometry.margin, 12.0, "G17-c [规格] 外边距 12"); + assert_eq!(geometry.pad_h, 16.0, "A1 HorizontalSpacing"); + assert_eq!(geometry.pad_v, 6.0, "A1 SnackbarVerticalPadding"); +} + +// ---- §7 Dialog -------------------------------------------------------------- + +/// 7.4:官方区间 **280..560**,本项目取 **480**,且 480 落在区间内。 +#[test] +fn dialog_geometry_sits_inside_the_official_bounds() { + let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light)); + assert_eq!(geometry.min_width, 280.0, "A1 DialogMinWidth"); + assert_eq!(geometry.max_width, 560.0, "A1 DialogMaxWidth"); + assert_eq!(geometry.width, 480.0, "G17-e 本项目取值"); + assert!( + geometry.min_width <= geometry.width && geometry.width <= geometry.max_width, + "自选宽必须落在官方区间内" + ); +} + +/// 7.1 / 7.3 / 7.6:圆角 28、内距 24 / 16 / 24、动作 8 / 12。 +#[test] +fn dialog_padding_and_action_gaps_are_the_tokens() { + let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light)); + assert_eq!(geometry.radius, 28.0, "7.1 CornerExtraLarge"); + assert_eq!(geometry.padding, 24.0, "7.3 DialogPadding"); + assert_eq!(geometry.title_gap, 16.0, "7.3 TitlePadding bottom"); + assert_eq!(geometry.body_gap, 24.0, "7.3 TextPadding bottom"); + assert_eq!(geometry.action_gap, 8.0, "7.6 ButtonsMainAxisSpacing"); + assert_eq!(geometry.action_cross, 12.0, "7.6 ButtonsCrossAxisSpacing"); +} + +/// 7.7 + D27:scrim = 黑@0.32 叠在 canvas 上之后的**不透明**实色。 +#[test] +fn scrim_is_scrimalpha_precomposited_onto_the_canvas() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + let scheme = Scheme::baseline(flavor); + let canvas = scheme.get(MaterialLayer::Canvas.surface_role()); + let expected = to_iced(scheme.scrim().over(canvas)); + assert_eq!( + scrim(&ui), + expected, + "{flavor:?}:scrim 必须逐位等于「黑@0.32 叠 canvas」" + ); + assert_eq!( + scrim(&ui).a, + 1.0, + "D27:CPU 档不出现半透明(预合成后必须不透明)" + ); + assert_ne!( + scrim(&ui), + ui.layer(MaterialLayer::Canvas), + "遮罩必须与画布有可见差异" + ); + assert!((SCRIM_ALPHA - 0.32).abs() < f32::EPSILON, "令牌口径 α=0.32"); + } +} + +// ---- §15 空态(项目自定) ---------------------------------------------------- + +/// 15.1:图标块 96 / 圆角 16 / 图标 32(**全部 `[估]`**,官方没有这个组件)。 +#[test] +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"); + assert!(geometry.gap_title > 0.0 && geometry.gap_body > 0.0); +} + +// ---- 结构 / 角色 ------------------------------------------------------------ + +/// 五个入口都能在**任意**消息类型下构建(契约 §2:不 import `app::Message`)。 +#[test] +fn every_component_builds_for_a_foreign_message_type() { + #[derive(Clone, Debug, PartialEq)] + enum Other { + Yes, + No, + } + + let ui = ui(SchemeFlavor::Light); + let _: Element<'_, Other> = linear(&ui, Progress::determinate(0.5)); + let _: Element<'_, Other> = circular(&ui, Progress::indeterminate(0.3)); + let _: Element<'_, Other> = snackbar( + &ui, + Snackbar::new("已保存") + .placement(Placement::Top) + .two_lines() + .action("撤销", Other::No) + .dismiss(Other::No), + ); + let _: Element<'_, Other> = dialog( + &ui, + Dialog::new("确认删除?", "此操作不可撤销。") + .double(("取消", Other::No), ("删除", Other::Yes)), + ); + let _: Element<'_, Other> = empty_state( + &ui, + EmptyState::new( + unlucky_design::icons::IconKind::Instances, + "还没有实例", + "建一个开始游戏", + ) + .action("新建", Other::Yes), + ); + // 无动作的三种形态也要能构建(DialogActions::None / 无 action 的 Snackbar) + let _: Element<'_, Other> = dialog(&ui, Dialog::new("已是最新版本", "没有可用更新。")); + let _: Element<'_, Other> = snackbar(&ui, Snackbar::new("已复制")); + let _: Element<'_, Other> = empty_state( + &ui, + EmptyState::new( + unlucky_design::icons::IconKind::Logs, + "没有日志", + "启动一次游戏后这里会有内容", + ), + ); +} + +/// 状态层必须来自 `interaction`(不是组件里自己乘 α):hover 层不透明且与容器色不同。 +#[test] +fn snackbar_state_layers_come_from_the_interaction_module() { + let ui = ui(SchemeFlavor::Light); + let hover = interaction::overlay_color( + &ui, + Interaction::Hovered, + Role::InversePrimary, + Role::InverseSurface, + ) + .expect("hover 有状态层"); + assert_eq!(hover.a, 1.0, "状态层必须是实色(CPU/GPU 一致)"); + assert_ne!( + hover, + ui.color(Role::InverseSurface), + "叠了 8% 必须看得出来" + ); + + // 禁用内容走 token 口径(38%),不是「变灰一点」 + let disabled = interaction::disabled_content_over_role(&ui, Role::OnSurface, false); + assert_ne!(disabled, ui.color(Role::OnSurface)); + assert_eq!(disabled.a, 1.0); +} diff --git a/crates/app-iced/src/components/gallery/feedback.rs b/crates/app-iced/src/components/gallery/feedback.rs index 1ef0b98..ec65680 100644 --- a/crates/app-iced/src/components/gallery/feedback.rs +++ b/crates/app-iced/src/components/gallery/feedback.rs @@ -1,22 +1,322 @@ -//! gallery 里的反馈类小节(**桩**):后续 mission 只改本文件。 +//! 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::Element; +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 super::super::feedback; 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> { - iced::widget::column![ - heading( - ui, - "feedback · progress / snackbar / dialog / empty state", - "12b §7/§12/§14/§15 —— pending(后续 mission 填充本文件)" - ), - feedback::demo::(ui), + 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, + content: impl Into>, +) -> 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(unlucky_design::spacing::Space::X2)) + .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> { + 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); + + let plain = feedback::dialog(ui, Dialog::new("已是最新版本", "当前没有可用更新。")); + 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 · 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() +} From e9af27c8f1c279882b8d9dd3d54b0f71f7d4ba31 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 10:41:41 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(iced/feedback):=20=E4=B8=8D=E5=AE=9A?= =?UTF-8?q?=E6=80=81=E4=B8=A4=E6=9D=A1=20p1=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E7=BA=BF=E6=80=A7=E5=85=88=E6=8E=92=E5=BA=8F=E5=90=88=E5=B9=B6?= =?UTF-8?q?=EF=BC=88P1-A=EF=BC=89+=20=E7=8E=AF=E5=BD=A2=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E6=97=8B=E8=BD=AC=EF=BC=88P1-B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1 的两条 p1 都是「报告写了 ✓、图上没有」,逐条修: P1-A(视觉)线性不定态把两段画成一段: - 根因:`phase_segments` 的两段**不保证有序**(第一线前缘抢跑 250ms、第二线从 0 起), `linear()` 却按返回顺序单调铺 `FillPortion`,且份额之和在 34/100 相位上 >1(最大 1.99), iced 按总和归一 ⇒ phase 0.55 的两段被压成一段 100px 台阶。 - 修:新增 `layout_spans`(排序 + 合并重叠 + 显式铺间隙)与 `portion_shares`(累计舍入, 份额之和**恒为 1000**),`linear()` 只消费这两者。 - 断言:`raw_segments_are_unordered_and_layout_sorts_them`(原始输出不有序、排序后位置对得上 A1 keyframes)、`portion_shares_always_sum_to_the_full_track`(200 个相位全 = 1000)、 以及渲染层 `12.4:phase 0.55 必须数出两段`(旧实现会数到 1 段)。 P1-B(代码)环形不定态少一条全局旋转 + keyframes 首点语义: - 官方是两条旋转相加:`CircularGlobalRotationDegreesTarget = 1080f`(`tween(6000, LinearEasing)`, javap 偏移 5125)与 8 关键帧旋转(0→360°),绘制时 `fadd` 后 rotate ⇒ 每周期 1440°(4 圈)。 我们只做了 360°,转速是官方的 1/4。 - `ROTATION` 首点在 300ms/90°:Compose 会自己补 t=0(值 = 动画初值 0),我们没补 ⇒ `[0,0.05)` 恒 90°,且周期末尾 360° → 下周期 90° 瞬时倒跳 270°。现在显式补 `KeyPoint{ms:0, value:0}` 并在 `Keyframes::sample` 加 `debug_assert!(首点 ms == 0)`,同一类坑不再靠自觉。 - `CircularGeometry::indeterminate` 改返回 `(弧长, 圈数)`,圈数 = 3.0×phase + 关键帧(可为 >1), `progress.rs` 直接乘 360、不做 rem_euclid。 - 断言:`circular_indeterminate_rotation_is_four_turns_and_continuous`(每周期 4 圈、 跨周期跳变 ≤ 一帧 = 4/60 圈,修前是 0.75 圈 = 270°、旋转单调)。 probe 拆成 `probe.rs` + `probe/circular.rs`(新断言会把单文件顶过 400 行)。 --- .../src/components/feedback/geometry.rs | 24 +- .../src/components/feedback/keyframes.rs | 14 +- .../app-iced/src/components/feedback/probe.rs | 249 +++++------------ .../src/components/feedback/probe/circular.rs | 50 ++++ .../src/components/feedback/progress.rs | 103 +++++-- .../src/components/feedback/tests/progress.rs | 256 ++++++++++++++++++ 6 files changed, 494 insertions(+), 202 deletions(-) create mode 100644 crates/app-iced/src/components/feedback/probe/circular.rs create mode 100644 crates/app-iced/src/components/feedback/tests/progress.rs diff --git a/crates/app-iced/src/components/feedback/geometry.rs b/crates/app-iced/src/components/feedback/geometry.rs index 0fb691e..e842585 100644 --- a/crates/app-iced/src/components/feedback/geometry.rs +++ b/crates/app-iced/src/components/feedback/geometry.rs @@ -146,6 +146,13 @@ const CIRCULAR_MIN: f32 = 0.1; const CIRCULAR_MAX: f32 = 0.87; /// 环形不定态周期(`CircularAnimationProgressDuration = 6000`)。 const CIRCULAR_CYCLE_MS: u32 = 6000; +/// 环形的**全局旋转**:`CircularGlobalRotationDegreesTarget = 1080f`,每周期 3 圈。 +/// +/// `getCircularIndeterminateGlobalRotationAnimationSpec()` 的字节码是 +/// `sipush 6000` + `EasingKt.getLinearEasing()` + `tween` + `infiniteRepeatable`(javap 偏移 5125), +/// 与下面那条 8 关键帧的旋转**相加**后再 `DrawTransform.rotate`(`State.getValue`×2 → `fadd` → `rotate`)。 +/// 所以官方 6s 一共转 1080 + 360 = **1440°(4 圈)**;早先只实现了 360°,转速是官方的 1/4(P1-B)。 +const CIRCULAR_GLOBAL_TURNS: f32 = 3.0; /// 弧长:0.1 → 0.87(3000ms,`CircularProgressEasing`=Standard),再回 0.1(3000ms)。 const ARC_SWEEP: Keyframes = Keyframes { @@ -170,9 +177,17 @@ const ARC_SWEEP: Keyframes = Keyframes { }; /// 旋转(`_get_circularIndeterminateRotationAnimationSpec` 的 8 个关键帧,占周期 0..360°)。 +/// +/// **首点 0ms/0° 是必须写的**:Compose 会自己补这个点(值 = 动画初值 0),我们显式写出来, +/// 否则 `[0, 0.05)` 会恒为 90°、并且周期末尾 360° → 下周期 90° 瞬时倒跳 270°(P1-B)。 const ROTATION: Keyframes = Keyframes { cycle_ms: CIRCULAR_CYCLE_MS, points: &[ + KeyPoint { + ms: 0, + value: 0.0, + easing: Easing::Linear, + }, KeyPoint { ms: 300, value: 0.25, @@ -248,8 +263,13 @@ impl CircularGeometry { (self.diameter - self.thickness) / 2.0 } - /// 不定态:相位 → `(弧长占比, 起始旋转占整圈的比例)`。 + /// 不定态:相位 → `(弧长占比, 旋转圈数)`。 + /// + /// **圈数允许 > 1**(每周期 4 圈):全局 1080°(线性,3 圈)+ 关键帧 0→360°(1 圈)相加, + /// 与官方 `fadd` 口径一致。调用方**不要**先 `rem_euclid(1.0)` 再乘 360——那会把 3 圈丢掉, + /// 又变成 P1-B 那个「转速只有官方 1/4」的版本。 pub fn indeterminate(&self, phase: f32) -> (f32, f32) { - (ARC_SWEEP.sample(phase), ROTATION.sample(phase)) + let global = CIRCULAR_GLOBAL_TURNS * phase.rem_euclid(1.0); + (ARC_SWEEP.sample(phase), global + ROTATION.sample(phase)) } } diff --git a/crates/app-iced/src/components/feedback/keyframes.rs b/crates/app-iced/src/components/feedback/keyframes.rs index 72b35c9..d20b703 100644 --- a/crates/app-iced/src/components/feedback/keyframes.rs +++ b/crates/app-iced/src/components/feedback/keyframes.rs @@ -15,8 +15,16 @@ //! ``` //! //! Compose 的 `KeyframesSpec.at(value, time).using(easing)` 是「**从该点出发的那一段**用这条缓动」, -//! 所以 [`KeyPoint::easing`] 的语义就是段起点的缓动;第一点之前 / 最后一点之后一律**保持端点值** +//! 所以 [`KeyPoint::easing`] 的语义就是段起点的缓动;最后一点之后一律**保持末值** //! (Compose 对未覆盖的区间就是保持,这也是「段跑到头以后停在满格」的来源)。 +//! +//! ## 首点必须在 `ms = 0`(复审 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°)。 use unlucky_design::motion::Easing; @@ -53,6 +61,10 @@ impl Keyframes { } let cycle = self.cycle_ms as f32; + debug_assert_eq!( + first.ms, 0, + "首点必须在 0ms:Compose 会补一个 t=0 的初值点,我们不猜,请显式写出来" + ); let ms = phase.rem_euclid(1.0) * cycle; if ms <= first.ms as f32 { return first.value; diff --git a/crates/app-iced/src/components/feedback/probe.rs b/crates/app-iced/src/components/feedback/probe.rs index 60c00d6..7ee4ec7 100644 --- a/crates/app-iced/src/components/feedback/probe.rs +++ b/crates/app-iced/src/components/feedback/probe.rs @@ -1,18 +1,21 @@ //! 反馈家族的**渲染层**几何断言:对着真截图量像素(M68 的教训:纯函数对、图可能是错的)。 //! //! 为什么住在 `feedback/` 而不是 `gallery/`:`gallery/feedback.rs` 只负责把矩阵摆进 harness, -//! 断言加上去会把它顶到 400 行红线(与 `selection/probe.rs` 同一处置)。像素工具在同级 -//! [`super::pixel`](`cfg(test)`)。 +//! 断言加上去会把它顶到 400 行红线。像素工具在 [`super::pixel`],环形与两个「面」的判据 +//! 拆在同级子模块 [`circular`] / [`panels`](`inputs/probe/` 的同一先例)。 //! //! ## 量什么(每条对一个具体错误,打红它的是「变异」那一列) //! //! | 类 | 判据 | 打红它的变异 | //! |---|---|---| //! | 线性厚度 / 圆角 | 轨道带**恰好 4 行**;条的四角**透出底色** | 厚度 4→6 / `Full`→`None` | -//! | Snackbar 宽上下限 | 宿主 800→**600**、344→**344**、300→**300**(无下限 G17-d) | 上限 600→560 | +//! | 线性不定态 | phase 0.55 列必须**数出两段**(P1-A 回归) | 段不排序(旧实现:两段压成一段) | +//! | 环形 | 环 40 且四角无墨(真圆) | 直径 40→48 | +//! | Snackbar 宽上下限 | 宿主 800→**600**、368→**344**、324→**300**(无下限 G17-d) | 上限 600→560 | //! | Snackbar 高 | 单行带 **48** / 双行带 **68** | 48→40 | -//! | Dialog 宽 / 内距 | 最宽行 **480**(在 280..560 内)· 标题墨迹左缩 **≈24** | 480→560 / pad 24→16 | -//! | 环形 / 空态块 / scrim | 环 40 且四角无墨 · 块 **96×96** · 遮罩带颜色 = 预合成实色 | 40→48 / 96→88 | +//! | Dialog 宽 / 内距 | 最宽行 **480**(在 280..560 内)· 标题墨迹左缩 **≈24**、顶缩 **≈26** | 480→488 / pad 24→16 | +//! | 空态块 / 间距 / CTA | 块 **96×96** · 块↔标题 **≈16**+bearing · 条目总高 **≈232** · CTA **40** | 96→88 / spacing 双算回来 | +//! | scrim | 一块 ≥700 宽的遮罩带,颜色 = 预合成实色 | 遮罩改成 surface | //! //! ## 复现(worktree 根目录;四个部分各一张图,浅/深各一轮) //! @@ -26,15 +29,13 @@ //! done //! ``` +mod circular; +mod panels; + use unlucky_design::color::{Role, SchemeFlavor}; -use unlucky_design::elevation::MaterialLayer; use unlucky_design::typography::Platform; -use super::dialog::scrim; -use super::pixel::{ - band_width, bands, blobs, dims, ink_inside, matches, near, rgb, row_ink, runs, shot_path, BBox, - Shot, -}; +use super::pixel::{band_width, bands, matches, near, rgb, runs, shot_path, Shot}; use crate::theme::{Families, Ui}; const FAMILIES: Families = Families { @@ -47,6 +48,11 @@ fn ui(flavor: SchemeFlavor) -> Ui { Ui::with_platform(flavor, Platform::Windows, FAMILIES) } +/// 第三条(phase 0.55)线性轨道的左缘与宽度(gallery 的宿主尺寸:页内边距 24 + 2×332)。 +const TRACK3_X0: usize = 688; +/// 单条轨道宽(gallery 的 `TRACK_HOST`)。 +const TRACK3_W: usize = 320; + /// 一条测试覆盖当前截图对应的那一段(段由 `ULL_FEEDBACK_PART` 指定,默认 `all`)。 #[test] fn feedback_geometry_matches_the_reference() { @@ -77,13 +83,16 @@ fn feedback_geometry_matches_the_reference() { let part = part.trim().to_ascii_lowercase(); match part.as_str() { - "progress" => check_progress(&shot, &ui), + "progress" => { + check_linear(&shot, &ui); + circular::check(&shot, &ui); + } "snackbar" => check_snackbar(&shot, &ui), - "dialog" => check_dialog(&shot, &ui), - "empty" => check_empty(&shot, &ui), + "dialog" => panels::check_dialog(&shot, &ui), + "empty" => panels::check_empty(&shot, &ui), other => { // 整屏时只查最不容易被折叠线切掉的那一类;分图才是逐项判据的场合。 - check_progress(&shot, &ui); + check_linear(&shot, &ui); eprintln!("[feedback-probe] part={other:?} 只查了 progress 那一类;其余请在分图里查"); } } @@ -91,10 +100,10 @@ fn feedback_geometry_matches_the_reference() { eprintln!("[feedback-probe] OK flavor={flavor:?} part={part}"); } -/// §12:线性厚度 4 / 圆角 full;环形 40 且真圆。 -fn check_progress(shot: &Shot, ui: &Ui) { +/// §12.1 + §12.4:线性厚度 / 圆角 / **不定态两段**。 +fn check_linear(shot: &Shot, ui: &Ui) { // 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后 - // 「实测 6 == 期望 6」会照样绿,等于没有断言(复审 P2-4 的同一类坑)。 + // 「实测 6 == 期望 6」会照样绿,等于没有断言。 let surface = ui.color(Role::Surface); let track = ui.color(Role::SecondaryContainer); let active = ui.color(Role::Primary); @@ -128,41 +137,65 @@ fn check_progress(shot: &Shot, ui: &Ui) { "条的中间必须有墨(否则是没画出来,不是圆角)" ); - // ---- 12.3 环形:40 的外接框,且四角无墨(真圆)------------------------------ - let rings: Vec = blobs(shot, active, 30) + // ---- 12.4 不定态:phase 0.55 那一列必须**数出两段**(复审 P1-A 的回归断言)---- + // + // 旧实现把两段按返回顺序铺 `FillPortion`(第一线在右、第二线在左),并且份额之和 + // 在某 34/100 相位上 >1,被 iced 归一 ⇒ 图上只剩一段 100px 的台阶(x 749..848)。 + let row = indeterminate_row(shot, active, surface); + let found: Vec<(usize, usize)> = runs(shot, row, active, surface) .into_iter() - .filter(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40)) + .filter(|(start, len)| { + *start >= TRACK3_X0 && start + len <= TRACK3_X0 + TRACK3_W + 2 && *len >= 8 + }) .collect(); - assert!( - !rings.is_empty(), - "12.3:没找到 40(±1) 的环形激活层;候选:{}", - dims(&blobs(shot, active, 10)) + assert_eq!( + found.len(), + 2, + "12.4:phase 0.55 必须是**两段**(P1-A 修完才成立),实测 {found:?} @ 行 {row}" ); - for ring in &rings { - for (x, y) in [ - (ring.left, ring.top), - (ring.right, ring.top), - (ring.left, ring.bottom), - (ring.right, ring.bottom), - ] { - assert!( - !matches(shot.at(x, y), rgb(active)), - "12.3:环必须是真圆(外接框四角不该有墨)@ ({x},{y})" - ); - } + + // A1 keyframes 手算:第二线 0.003..0.079、第一线 0.355..0.871(EmphasizedAccelerate) + let expected = [(0.003_f32, 0.079_f32), (0.355, 0.871)]; + 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; + assert!( + (*start as f32 - want_start).abs() <= 4.0, + "12.4 第 {} 段左端应在 {want_start:.1}(实测 {start})", + index + 1 + ); + assert!( + ((start + len) as f32 - want_end).abs() <= 4.0, + "12.4 第 {} 段右端应在 {want_end:.1}(实测 {})", + index + 1, + start + len + ); } eprintln!( - "[feedback-probe] progress:轨道带 {} 条(高 {:?})· 环 {} 个 40(±1)", + "[feedback-probe] progress:轨道带 {} 条(高 {:?})· phase 0.55 两段 {found:?}", bar_bands.len(), bar_bands .iter() .map(|(top, bottom)| bottom - top + 1) .collect::>(), - rings.len() ); } +/// 线性不定态那一行的 y:主色墨迹在**第一条轨道**里出现、且不在确定态行的最上一组。 +/// +/// 不想硬编码坐标(gallery 一改间距就失准),所以按「主色横带 + 排除最高那条带」找。 +fn indeterminate_row(shot: &Shot, active: iced::Color, surface: iced::Color) -> usize { + let found = bands(shot, active, surface, 20); + assert!( + found.len() >= 2, + "至少要找到确定态行与不定态行两组主色横带,实测 {found:?}" + ); + // 第一组是确定态(0% 无墨、50%/100% 有墨),第二组是不定态 + let (top, bottom) = found[1]; + (top + bottom) / 2 +} + /// §14:宽度上下限 + 单/双行高。 fn check_snackbar(shot: &Shot, ui: &Ui) { // 参考值写字面量(A1):600 = SnackbarKt.ContainerMaxWidth,48/68 = SnackbarTokens。 @@ -182,8 +215,6 @@ fn check_snackbar(shot: &Shot, ui: &Ui) { ); // 宽度集 = 每条带里最宽的那一行(留白行没有文字,量得到全宽)。 - // **注意**:并排的两条窄条落在同一组行里,所以这一屏的宽度集只能证「存在 344 与 300」; - // 要把其中一条单独改坏,变异必须同时改所有实例(报告「变异」一节里写明)。 let mut widths: Vec = found .iter() .map(|band| band_width(shot, *band, bar, surface)) @@ -200,16 +231,14 @@ fn check_snackbar(shot: &Shot, ui: &Ui) { widths.iter().any(|w| near(*w, 600)), "上限宽必须是 600(SnackbarKt.ContainerMaxWidth,宿主 800 时夹取),实测宽度集 {widths:?}" ); - // 最小宽的取值口径 344,以及「没有下限」的反证 300 assert!( widths.iter().any(|w| near(*w, 344)), - "宿区 344 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}" + "宿区 368 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}" ); assert!( widths.iter().any(|w| near(*w, 300)), - "宿区 300 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}" + "宿区 324 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}" ); - // 高度 48 / 68 assert!( heights.iter().any(|h| near(*h, 48)), "14.3:单行高 48,实测高度集 {heights:?}" @@ -237,133 +266,3 @@ fn check_snackbar(shot: &Shot, ui: &Ui) { "[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px" ); } - -/// §7:卡片宽 480(官方 280..560 内)+ 内距 24 + scrim 毯。 -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); - - // ---- 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); - } - } - } - let (width, card_left, card_row) = widest; - assert!( - near(width, 480), - "7.4:对话框宽必须是 480(G17-e 项目取值),实测 {width}" - ); - - // 该行上下扩成卡片带(同一张卡片的连续行;两张卡并排时各自成带) - 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; - } - - // ---- 7.3 左右内距:标题墨迹距卡片左缘 ≈ 24(外加 24px 字形的 bearing,22..28)---- - let bbox = BBox { - left: card_left, - right: card_left + width - 1, - top, - bottom, - }; - let title = - ink_inside(shot, &bbox, ui.color(Role::OnSurface), card, 2).expect("卡片里必须有标题墨迹"); - let inset = title.left.saturating_sub(card_left); - assert!( - (22..=28).contains(&inset), - "7.3:四边内距 24 ⇒ 标题墨迹左缩 ≈24(含 bearing 22..28),实测 {inset}" - ); - let top_inset = title.top.saturating_sub(top); - assert!( - (24..=40).contains(&top_inset), - "7.3:上内距 24 ⇒ 标题墨迹顶缩 ≈24+行内 bearing,实测 {top_inset}" - ); - - // ---- 7.7 scrim:一块 ≥700 宽的遮罩带,颜色 = 预合成实色 ---------------------- - let backdrop = scrim(ui); - let scrim_bands = bands(shot, backdrop, surface, 700); - assert!( - !scrim_bands.is_empty(), - "7.7:没找到 ≥700 宽的遮罩带(宿主 800 宽那格)" - ); - - // ---- 7.6 动作色:Dialog 动作是 `Primary`(不是 OnSurfaceVariant)------------- - // 按「更靠近 Primary 而不是卡片」判,不按精确色:14px 的中文字形只有边缘几个像素能命中 - // 精确色(实测 0),而 Primary 与卡片色的距离很大,这个判据又稳又不误判。 - 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", - scrim_bands.len() - ); -} - -/// §15:空态图标块 96(`[估]`)+ CTA 40 高。 -fn check_empty(shot: &Shot, ui: &Ui) { - // 参考值写字面量:96/16/32 全是 `[估]`(项目自定,官方无此组件)。 - let surface = ui.color(Role::Surface); - let block = ui.layer(MaterialLayer::Card); - - // 下限 40 而不是 88:块只有 96 宽,中间的图标(32)会把那几行的块色墨量压到 ≈64。 - let found = bands(shot, block, surface, 40); - let (top, bottom) = found - .iter() - .copied() - .max_by_key(|(top, bottom)| bottom - top) - .filter(|(top, bottom)| near(bottom - top + 1, 96)) - .unwrap_or_else(|| { - panic!("15.1:没找到高 96 的图标块(`[估]` 项目自定值),实测量到的带:{found:?}") - }); - - let width = band_width(shot, (top, bottom), block, surface); - assert!(near(width, 96), "15.1:图标块必须 96×96,实测宽 {width}"); - - // 15.6 CTA:标准 Filled 按钮 40 高(`Primary` 实心带)。 - // 下限 20 而不是 60:按钮约 100 宽,文字那几行只剩 ≈44 个实心像素(文字不是实心色)。 - let cta = bands(shot, ui.color(Role::Primary), surface, 20); - assert!( - cta.iter().any(|(top, bottom)| near(bottom - top + 1, 40)), - "15.6:CTA 应是标准按钮(可见高 40),实测带 {cta:?}" - ); - - eprintln!( - "[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · CTA 带 {cta:?}", - bottom - top + 1 - ); -} diff --git a/crates/app-iced/src/components/feedback/probe/circular.rs b/crates/app-iced/src/components/feedback/probe/circular.rs new file mode 100644 index 0000000..4fb1cdb --- /dev/null +++ b/crates/app-iced/src/components/feedback/probe/circular.rs @@ -0,0 +1,50 @@ +//! 环形进度的渲染层判据(`probe` 的子模块,`inputs/probe/` 的同一先例)。 +//! +//! 量两件事(`research/12b` §12.3): +//! +//! - 环的**外接框 40(±1)** —— 直径是 A1 `CircularProgressIndicatorTokens.Size`; +//! - 环的外接框**四角无墨** —— 真圆,不是圆角方。 +//! +//! 不定态的**节奏**(4 圈/周期、跨周期不跳变)不在这里量:那是时间的函数,截图只有一帧, +//! 判据在 `tests.rs::circular_indeterminate_rotation_is_four_turns_and_continuous`。 + +use unlucky_design::color::Role; + +use super::super::pixel::{blobs, dims, matches, near, rgb, Shot}; +use crate::theme::Ui; + +/// 环直径(A1 `CircularProgressIndicatorTokens.Size = 40`)。 +const DIAMETER: usize = 40; + +/// 环形那一排的判据。 +pub(super) fn check(shot: &Shot, ui: &Ui) { + let active = ui.color(Role::Primary); + let rings: Vec<_> = blobs(shot, active, 30) + .into_iter() + .filter(|bbox| near(bbox.width(), DIAMETER) && near(bbox.height(), DIAMETER)) + .collect(); + assert!( + !rings.is_empty(), + "12.3:没找到 40(±1) 的环形激活层;候选:{}", + dims(&blobs(shot, active, 10)) + ); + for ring in &rings { + for (x, y) in [ + (ring.left, ring.top), + (ring.right, ring.top), + (ring.left, ring.bottom), + (ring.right, ring.bottom), + ] { + assert!( + !matches(shot.at(x, y), rgb(active)), + "12.3:环必须是真圆(外接框四角不该有墨)@ ({x},{y})" + ); + } + } + + eprintln!( + "[feedback-probe] circular:环 {} 个 {}", + rings.len(), + dims(&rings) + ); +} diff --git a/crates/app-iced/src/components/feedback/progress.rs b/crates/app-iced/src/components/feedback/progress.rs index 0125be6..aacdc8a 100644 --- a/crates/app-iced/src/components/feedback/progress.rs +++ b/crates/app-iced/src/components/feedback/progress.rs @@ -93,6 +93,71 @@ impl Progress { } } +/// 把 0..1 的区间**排序 + 合并重叠**,摊成「间隙 / 指示条」交替的列表(总长恒为 1.0)。 +/// +/// ## 为什么必须排序(复审 P1-A,视觉 r1 的 p1) +/// +/// [`phase_segments`] 返回的两段**不保证有序**:第一线的前缘抢跑 250ms,所以它的区间在右, +/// 第二线从 0 起跑的区间在左。照顺序铺 `FillPortion` 会把左段接到右段**之后**,并且 +/// `FillPortion` 之和一旦 ≠ 1000,iced 就按总和归一 —— 位置和宽度一起被压扁。 +/// 实测 phase 0.55:本应两段 `[0.003,0.079]` + `[0.355,0.860]`(x 689..713 / 802..963), +/// 实际被画成一段 100px 的台阶(x 749..848),且 **34/100 个相位**上总和 >1(最大 1.99)。 +/// +/// 返回的每项是 `(是不是指示条, 占比)`;间隙项也显式给出来,才能断言「总和 = 1.0」。 +pub fn layout_spans(spans: &[(f32, f32)]) -> Vec<(bool, f32)> { + let mut clipped: Vec<(f32, f32)> = spans + .iter() + .map(|(start, end)| (start.clamp(0.0, 1.0), end.clamp(0.0, 1.0))) + .filter(|(start, end)| end > start) + .collect(); + clipped.sort_by(|a, b| a.0.total_cmp(&b.0)); + + let mut merged: Vec<(f32, f32)> = Vec::new(); + for (start, end) in clipped { + match merged.last_mut() { + Some(last) if start <= last.1 => last.1 = last.1.max(end), + _ => merged.push((start, end)), + } + } + + let mut items = Vec::new(); + let mut cursor = 0.0_f32; + for (start, end) in merged { + if start > cursor { + items.push((false, start - cursor)); + } + items.push((true, end - start)); + cursor = end; + } + if cursor < 1.0 { + items.push((false, 1.0 - cursor)); + } + items +} + +/// 占比 → `FillPortion` 整数份额,**总和恒为 `PORTIONS`**(累计舍入,最后一项吃掉残差)。 +/// +/// 为什么不用「每项各自 round」:各项独立取整后总和可能是 999 或 1001,iced 按总和归一, +/// 就又回到 P1-A 那个「整条被压扁」的坑。这里让每个**累计边界**取整,最后一项补足, +/// 于是任意相位下份额之和都精确等于 1000。 +pub fn portion_shares(items: &[(bool, f32)]) -> Vec<(bool, u16)> { + let mut shares = Vec::with_capacity(items.len()); + let mut accumulated = 0.0_f32; + let mut previous = 0_u16; + for (index, (is_bar, fraction)) in items.iter().enumerate() { + accumulated += fraction; + let target = if index + 1 == items.len() { + PORTIONS as u16 + } else { + (accumulated * PORTIONS).round() as u16 + }; + let share = target.saturating_sub(previous); + previous = target; + shares.push((*is_bar, share)); + } + shares +} + /// 线性进度条(轨道 + 指示条,厚 4、圆角 full)。 /// /// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**, @@ -105,18 +170,13 @@ pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> { let mut bar = row![] .width(Length::Fill) .height(Length::Fixed(geometry.thickness)); - let mut cursor = 0.0_f32; - for (start, end) in progress.spans() { - let (start, end) = (start.clamp(0.0, 1.0), end.clamp(0.0, 1.0)); - if end <= start { + for (is_bar, share) in portion_shares(&layout_spans(&progress.spans())) { + if share == 0 { continue; } - if start > cursor { - bar = bar.push(spacer(start - cursor)); - } - bar = bar.push( + bar = bar.push(if is_bar { container(Space::new().width(Length::Fill).height(Length::Fill)) - .width(Length::FillPortion(portion(end - start))) + .width(Length::FillPortion(share)) .height(Length::Fill) .style(move |_theme| container::Style { background: Some(Background::Color(active_color)), @@ -125,12 +185,11 @@ pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> { ..Border::default() }, ..container::Style::default() - }), - ); - cursor = end; - } - if cursor < 1.0 { - bar = bar.push(spacer(1.0 - cursor)); + }) + .into() + } else { + spacer(share) + }); } container(bar) @@ -153,8 +212,9 @@ pub fn circular<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> { let (sweep, rotation) = match progress.amount { Amount::Determinate(value) => (value.clamp(0.0, 1.0) * 360.0, 0.0), Amount::Indeterminate(phase) => { - let (sweep_fraction, rotation_fraction) = geometry.indeterminate(phase); - (sweep_fraction * 360.0, rotation_fraction * 360.0) + // 圈数直接乘 360(不做 rem_euclid):每周期 4 圈是官方口径,截断就丢转速。 + let (sweep_fraction, turns) = geometry.indeterminate(phase); + (sweep_fraction * 360.0, turns * 360.0) } }; @@ -193,18 +253,13 @@ pub fn circular<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> { } /// 两段之间/两端的透明占位。 -fn spacer<'a, M: 'a>(fraction: f32) -> Element<'a, M> { +fn spacer<'a, M: 'a>(share: u16) -> Element<'a, M> { Space::new() - .width(Length::FillPortion(portion(fraction))) + .width(Length::FillPortion(share)) .height(Length::Fill) .into() } -/// 0..1 的占比 → `FillPortion`(≥1,避免 0 段;调用方已过滤空段)。 -fn portion(fraction: f32) -> u16 { - (fraction.clamp(0.0, 1.0) * PORTIONS).round().max(1.0) as u16 -} - /// 一层几何图元(固定尺寸 + `currentColor` 上色)。 fn glyph<'a, M: 'a>( geometry: &CircularGeometry, diff --git a/crates/app-iced/src/components/feedback/tests/progress.rs b/crates/app-iced/src/components/feedback/tests/progress.rs new file mode 100644 index 0000000..1e725ef --- /dev/null +++ b/crates/app-iced/src/components/feedback/tests/progress.rs @@ -0,0 +1,256 @@ +//! 进度两式的纯函数断言(`tests` 的子模块,从 `tests.rs` 拆出来控行数)。 +//! +//! 三条回归都住在这里: +//! - [`raw_segments_are_unordered_and_layout_sorts_them`]:`phase_segments` 的原始输出**不有序** +//! (视觉 r1 P1-A 就是把两段按顺序铺 `FillPortion`,图上被压成一段); +//! - [`portion_shares_always_sum_to_the_full_track`]:份额之和恒为 1000(旧实现 34/100 相位 >1); +//! - [`circular_indeterminate_rotation_is_four_turns_and_continuous`]:每周期 4 圈、跨周期不跳变 +//! (代码 r1 P1-B:旧实现只有 1 圈,且末尾倒跳 270°)。 + +use unlucky_design::color::SchemeFlavor; + +use super::super::geometry::{phase_segments, CircularGeometry, LinearGeometry}; +use super::super::keyframes::{KeyPoint, Keyframes}; +use super::super::progress::{layout_spans, portion_shares, Amount, Progress}; +use super::ui; + +// ---- §12 进度 --------------------------------------------------------------- + +/// 12.1:线性厚度 **4**、圆角 full ⇒ 2(`ShapeScale::Full.radius_px(4)`)。 +#[test] +fn linear_thickness_and_radius_come_from_the_tokens() { + let geometry = LinearGeometry::of(&ui(SchemeFlavor::Light)); + assert_eq!(geometry.thickness, 4.0, "12.1 线性厚度 = 4(A1 Height)"); + assert_eq!( + geometry.radius, + ShapeScale::Full.radius_px(4.0), + "12.1 圆角 full = 厚的一半" + ); + assert_eq!(geometry.radius, 2.0); + assert_eq!( + geometry.cycle_ms, 1750, + "不定态周期 = LinearAnimationDuration" + ); +} + +/// 12.3:环形 **40 / 4**,环中线半径 (40 − 4) / 2 = **18**。 +#[test] +fn circular_geometry_matches_the_tokens() { + let geometry = CircularGeometry::of(&ui(SchemeFlavor::Dark)); + assert_eq!( + geometry.diameter, 40.0, + "A1 CircularProgressIndicatorTokens.Size" + ); + assert_eq!(geometry.thickness, 4.0, "A1 ActiveThickness"); + assert_eq!(geometry.radius, 20.0, "圆角 full = 直径的一半"); + assert_eq!(geometry.arc_radius(), 18.0); + assert_eq!( + geometry.cycle_ms, 6000, + "A1 CircularAnimationProgressDuration" + ); +} + +/// 12.4:不定态两段**逐段非负**——这条只证「单段自身合法」,**不证两段的先后** +/// (视觉 r1 P1-A 就是从这里漏过去的:函数名曾经叫 `..._stay_ordered`,其实没验排序)。 +#[test] +fn indeterminate_segments_are_each_well_formed() { + let mut travelled = false; + let mut previous_end = 0.0; + for step in 0..=200 { + let phase = step as f32 / 200.0; + for (start, end) in phase_segments(phase) { + assert!( + start <= end + f32::EPSILON, + "相位 {phase:.3}:段起点 {start} 大于终点 {end}(前缘/后缘写反就会这样)" + ); + assert!( + (0.0..=1.0).contains(&start) && (0.0..=1.0).contains(&end), + "相位 {phase:.3}:段越出轨道 {start}..{end}" + ); + } + let first = phase_segments(phase)[0].1; + if phase > 0.0 && phase < 0.5 && first > previous_end { + travelled = true; + } + previous_end = first; + } + assert!(travelled, "段前沿必须随相位右移(否则就是一根不动的条)"); + assert_eq!(phase_segments(0.0), [(0.0, 0.0), (0.0, 0.0)]); + let (_, head) = phase_segments(1000.0 / 1750.0)[0]; + assert!((head - 1.0).abs() < 1.0e-5, "第一线前沿应在 1000ms 到 1.0"); +} + +/// **复审 P1-A 的回归测试**:`phase_segments` 的原始输出**不有序**(第一线的前缘在右、第二线在左), +/// 所以铺布局前必须排序 + 合并;排序后的两段位置要与 A1 keyframes 对得上。 +#[test] +fn raw_segments_are_unordered_and_layout_sorts_them() { + // 原始顺序:第一线(右,0.355..0.87)在前,第二线(左,0.003..0.079)在后 + let raw = phase_segments(0.55); + assert!( + raw[0].0 > raw[1].0, + "phase 0.55 的原始两段应当是「右段在前、左段在后」,实际 {raw:?}" + ); + + let laid = layout_spans(&raw); + // 期望:间隙 / 左段 / 间隙 / 右段 / 间隙(5 项,总长 1.0) + assert_eq!(laid.len(), 5, "两段应摊成 5 项:{laid:?}"); + assert!(!laid[0].0 && laid[1].0 && !laid[2].0 && laid[3].0 && !laid[4].0); + let left = laid[1].1; + 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}"); + assert!((right - 0.516).abs() < 0.01, "右段宽 {right}"); + + let total: f32 = laid.iter().map(|(_, fraction)| fraction).sum(); + assert!( + (total - 1.0).abs() < 1.0e-5, + "铺开的占比之和必须 = 1.0,实测 {total}" + ); +} + +/// 任意相位下 `FillPortion` 份额之和**恒为 1000**(iced 按总和归一 ⇒ 不等于 1000 就会压扁)。 +/// +/// 复审 P1-A 的实测反例:旧实现 **34/100 个相位**总和 >1(最大 1.99)。 +#[test] +fn portion_shares_always_sum_to_the_full_track() { + for step in 0..=200 { + let phase = step as f32 / 200.0; + let items = layout_spans(&phase_segments(phase)); + let shares = portion_shares(&items); + let total: u32 = shares.iter().map(|(_, share)| u32::from(*share)).sum(); + assert_eq!(total, 1000, "相位 {phase:.3} 的份额之和是 {total}"); + assert!( + shares.iter().filter(|(is_bar, _)| *is_bar).count() <= 2, + "最多两段指示条,相位 {phase:.3}" + ); + // 指示条之间必须留间隙(除非两段相接,那时会被合并成一段) + let mut previous_bar = false; + for (is_bar, share) in &shares { + if *is_bar && previous_bar && *share > 0 { + panic!("相位 {phase:.3}:两段指示条相邻但没合并"); + } + previous_bar = *is_bar; + } + } +} + +/// 环形不定态:弧长在 0.1..0.87 之间(A1 min/max),旋转走满一圈。 +#[test] +fn circular_indeterminate_sweeps_the_official_range() { + let geometry = CircularGeometry::of(&ui(SchemeFlavor::Light)); + let mut min = f32::MAX; + let mut max = f32::MIN; + let mut rotation = f32::MIN; + for step in 0..=600 { + let (sweep, spin) = geometry.indeterminate(step as f32 / 600.0); + min = min.min(sweep); + max = max.max(sweep); + rotation = rotation.max(spin); + } + assert!( + (min - 0.1).abs() < 1.0e-3, + "弧长下限 0.1(CircularIndeterminateMinProgress),实测 {min}" + ); + assert!((max - 0.87).abs() < 1.0e-3, "弧长上限 0.87,实测 {max}"); + assert!( + (rotation - 1.0).abs() < 1.0e-3, + "旋转应走满一整圈,实测 {rotation}" + ); +} + +/// **复审 P1-B 的回归测试**:环形不定态每周期 4 圈(官方 1440°),且跨周期**不跳变**。 +/// +/// 修之前:`ROTATION` 首点在 300ms/90°,且没有全局旋转 ⇒ +/// `turns(0.9999) = 1.0`、`turns(0.0) = 0.25`,跨周期差 0.75 圈 = **270° 瞬时倒跳**。 +#[test] +fn circular_indeterminate_rotation_is_four_turns_and_continuous() { + let geometry = CircularGeometry::of(&ui(SchemeFlavor::Light)); + + // 每周期 4 圈 = 1440°(1080° 全局 + 360° 关键帧) + let start = geometry.indeterminate(0.0).1; + let end = geometry.indeterminate(1.0 - 1.0e-6).1; + assert!( + ((end - start) - 4.0).abs() < 1.0e-3, + "每周期应为 4 圈(1440°),实测 {} 圈", + end - start + ); + + // 跨周期连续性:按「圈」取最短距离,必须 ≤ 一帧的位移(60fps ⇒ 4/60 ≈ 0.067 圈) + let before = geometry.indeterminate(1.0 - 1.0e-4).1; + let after = geometry.indeterminate(0.0).1; + let mut jump = (before - after).rem_euclid(1.0); + if jump > 0.5 { + jump = 1.0 - jump; + } + assert!( + jump <= 4.0 / 60.0, + "跨周期旋转跳变 {jump} 圈(应 ≤ 4/60;修之前是 0.75 圈 = 270°)" + ); + + // 旋转必须单调(相位 0 → 1 逐帧不回退,按圈算) + let mut previous = geometry.indeterminate(0.0).1; + for step in 1..=600 { + let phase = step as f32 / 600.0; + let current = geometry.indeterminate(phase).1; + assert!( + current >= previous - 1.0e-6, + "相位 {phase:.3}:旋转从 {previous} 回退到 {current}" + ); + previous = current; + } +} + +/// 确定态:越界值夹到 0..1(0% 只画轨道,100% 画满)。 +#[test] +fn determinate_values_are_clamped() { + assert_eq!( + Progress::determinate(1.5).amount(), + Amount::Determinate(1.0) + ); + assert_eq!( + Progress::determinate(-2.0).amount(), + Amount::Determinate(0.0) + ); + assert_eq!(Progress::determinate(0.5).spans(), vec![(0.0, 0.5)]); + assert_eq!(Progress::determinate(0.0).spans(), vec![(0.0, 0.0)]); +} + +/// keyframes 求值器:端点保持、相位循环、越界相位按周期取模。 +#[test] +fn keyframes_hold_ends_and_wrap() { + const LINE: Keyframes = Keyframes { + cycle_ms: 1000, + points: &[ + KeyPoint { + ms: 250, + value: 0.0, + easing: unlucky_design::motion::Easing::Linear, + }, + KeyPoint { + ms: 750, + value: 1.0, + easing: unlucky_design::motion::Easing::Linear, + }, + ], + }; + assert_eq!(LINE.sample(0.0), 0.0, "第一点之前保持首值"); + assert_eq!(LINE.sample(0.25), 0.0, "正好在第一点"); + assert!((LINE.sample(0.5) - 0.5).abs() < 1.0e-5, "中点线性插值"); + assert_eq!(LINE.sample(0.75), 1.0); + assert_eq!(LINE.sample(0.9), 1.0, "最后一点之后保持末值"); + assert_eq!(LINE.sample(1.25), 0.0, "相位 1.25 = 0.25(循环)"); + assert_eq!( + LINE.sample(-0.75), + 0.0, + "负相位按周期折返:-0.75 → 0.25 相位 = 250ms = 首点" + ); + assert!( + (LINE.sample(-0.6) - 0.3).abs() < 1.0e-5, + "-0.6 → 0.4 相位 = 400ms = 30%" + ); + assert_eq!(LINE.sample(f32::NAN), 0.0, "NaN 不 panic,给首值"); +} From c723d1faffb4892ef4c4f31a39a196233936b4ed Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 10:41:53 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(iced/feedback):=20=E7=A9=BA=E6=80=81?= =?UTF-8?q?=E9=97=B4=E8=B7=9D=E5=8F=8C=E7=AE=97=E3=80=81=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=9D=97=E5=AF=B9=E6=AF=94=E5=BA=A6=E3=80=81Dialog=20=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6=E4=B8=8E=E5=8A=A8=E4=BD=9C=E7=A6=81=E7=94=A8=E5=BA=95?= =?UTF-8?q?=E8=89=B2=EF=BC=88P2-1/2/4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2-1 空态间距双算:`Column::spacing(8)` 与显式 `gap(16/24)` 叠加 ⇒ 实际渲染 32/40、 条目总高 264,而登记的是 16/8/24、232。现在 `spacing(0.0)`,间距只有一个来源(`gap()`); 断言从 `gap > 0.0` 改成**精确等于令牌档位** 16/8/24。 - P2-2 空态图标块几乎没有边界:`SurfaceContainerLow` 与画布 ΔRGB 只有 11/5/0(≈1%), 浅色下看不出块。换 `SurfaceContainerHigh`(材质层 Overlay)+ 1px `OutlineVariant` 描边, 并用 `block_layer()` 让 probe 与组件取同一个层(不再各写一份角色)。 - P2-1 Dialog 恒 480 宽:`Length::Fixed(480)` 在窄宿主会溢出,`.max_width(560)` 对 Fixed 是 空操作。改成 `Fill + max_width(480)` = `min(宿主, 480)`(iced 无 min-width,见 G17-d)。 - P2-4 动作禁用底色取错:`interaction::disabled_content_over_role(..., false)` 把 38% 内容色 固定叠在 **Surface** 上,而 Snackbar 的底是 `InverseSurface`、Dialog 是 `SurfaceContainerHigh` ⇒ 真出现禁用态会是「38% 深字叠深底」。改为 `content_color()` 按真实容器角色合成(同令牌 α)。 - probe:Dialog 上内距判据 `(24..=40)` 收紧到 `(24..=30)`;空态新增「块↔标题 ≈16」与 「条目总高 ≈232」两条渲染层断言。 --- .../src/components/feedback/action.rs | 40 ++-- .../src/components/feedback/dialog.rs | 11 +- .../src/components/feedback/empty_state.rs | 34 +++- .../src/components/feedback/probe/panels.rs | 188 ++++++++++++++++++ .../app-iced/src/components/feedback/tests.rs | 160 +-------------- 5 files changed, 261 insertions(+), 172 deletions(-) create mode 100644 crates/app-iced/src/components/feedback/probe/panels.rs diff --git a/crates/app-iced/src/components/feedback/action.rs b/crates/app-iced/src/components/feedback/action.rs index 14d35c1..e2716ef 100644 --- a/crates/app-iced/src/components/feedback/action.rs +++ b/crates/app-iced/src/components/feedback/action.rs @@ -13,23 +13,45 @@ //! ## 禁用是状态不是构造器 //! //! 照 `selection/chip.rs` 修好的模式:`on_press: Option`,`None` = 禁用;文字色降到 -//! `OnSurface@38%`([`interaction::disabled_content_over_role`]),标签本身**不删**。 +//! `OnSurface@38%`,标签本身**不删**。禁用色的**底**必须是这两个动作件自己的容器角色 +//! (`InverseSurface` / `SurfaceContainerHigh`)——`interaction::disabled_content_over_role` +//! 只支持「叠在 Surface / 叠在 12% 容器上」两档,拿它合成会得到「38% 深字叠深底」(复审 P2-4)。 use iced::widget::{button, text}; use iced::{Background, Border, Element, Length, Padding, Shadow}; -use unlucky_design::color::Role; +use unlucky_design::color::state::DISABLED_CONTENT_ALPHA; +use unlucky_design::color::{Role, Scheme}; use unlucky_design::component_map::Component; use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use crate::icons; -use crate::theme::Ui; +use crate::theme::{to_iced, Ui}; use super::super::interaction::{self, Interaction}; use super::super::layout; +/// 禁用态内容色:`OnSurface@38%` 叠在**真实容器色**上(复审 P2-4)。 +/// +/// `interaction::disabled_content_over_role` 的 `on_container` 只有「叠在 Surface / 叠在 +/// 12% 容器上」两档,而这两个动作件的底是 `InverseSurface`(Snackbar)与 +/// `SurfaceContainerHigh`(Dialog)——直接调它会得到「38% 深字叠深底」。 +/// 这里按同一令牌口径([`DISABLED_CONTENT_ALPHA`])在本文件合成一次,底换成真实容器角色。 +fn content_color(ui: &Ui, role: Role, container_role: Role, disabled: bool) -> iced::Color { + if !disabled { + return ui.color(role); + } + let scheme = Scheme::baseline(ui.flavor()); + to_iced( + scheme + .get(Role::OnSurface) + .with_alpha(DISABLED_CONTENT_ALPHA) + .over(scheme.get(container_role)), + ) +} + /// 一个文字动作(Dialog 的动作区、Snackbar 的 action 共用)。 /// /// - `content` / `container_role`:内容色与**状态层叠在什么颜色上**(Dialog = @@ -51,11 +73,7 @@ pub(super) fn text_action<'a, M: Clone + 'a>( let radius = ui.radius(shape, height); let disabled = on_press.is_none(); - let ink = if disabled { - interaction::disabled_content_over_role(ui, Role::OnSurface, false) - } else { - ui.color(content) - }; + let ink = content_color(ui, content, container_role, disabled); let element = button( text(label) @@ -115,11 +133,7 @@ pub(super) fn icon_action<'a, M: Clone + 'a>( let radius = ui.radius(shape, side); let disabled = on_press.is_none(); - let ink = if disabled { - interaction::disabled_content_over_role(ui, Role::OnSurface, false) - } else { - ui.color(content) - }; + let ink = content_color(ui, content, container_role, disabled); let element = button(layout::centered_fixed( glyph_side, diff --git a/crates/app-iced/src/components/feedback/dialog.rs b/crates/app-iced/src/components/feedback/dialog.rs index 72d5832..705f7f6 100644 --- a/crates/app-iced/src/components/feedback/dialog.rs +++ b/crates/app-iced/src/components/feedback/dialog.rs @@ -26,6 +26,10 @@ //! 逐像素与「真半透明铺在 surface 上」等价(遮罩下通常就是底色),且两档渲染一致。 //! 代价:遮罩下若是图片/卡片,合成目标不是 surface —— 这条登记为偏离 D-2。 //! +//! **深色档的观感(观察项 D-5)**:画布本身很暗,0.32 叠上去只从 (9,21,28) 压到 (6,14,19) +//! (≈4%),几乎看不出「压暗」。数值是官方 token 推出来的,**不调**:要更强的层级感应该动 +//! Dialog 的容器档或阴影,而不是把 scrim 抬到 0.4(那就偏离 token 了)。 +//! //! ## 不画阴影(偏离 D-1) //! //! 官方 L3 有阴影,但 D27(CPU 禁模糊)+ 令牌层 `ShadowSpec` 仍是 M63 占位值 ⇒ 先不画, @@ -164,9 +168,12 @@ 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)。 container(content) - .width(Length::Fixed(geometry.width)) - .max_width(geometry.max_width) + .width(Length::Fill) + .max_width(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 e5ea4df..6f72391 100644 --- a/crates/app-iced/src/components/feedback/empty_state.rs +++ b/crates/app-iced/src/components/feedback/empty_state.rs @@ -3,8 +3,9 @@ //! ## ⚠️ 这是**项目自定**组件,官方 Material 3 没有它 //! //! `research/14`(官方组件清单)里**没有** EmptyState 这一项,Compose `material3` 也没有 -//! 对应 token。所以本文件的数值**全部是 `[估]`**(`research/12b §15` + `11b R14` + M53 的空态锚点), -//! 不得对外声称「按官方规格实现」: +//! 对应 token。所以**几何与排布是我们自定的**:下面表里标 `[估]` 的(96 块 / 三个间距 / 底色) +//! 没有任何官方出处,标 `[规格]` 的是 `11b R14` 的排版口径、标 `[源]` 的只有「CTA 就是标准按钮」 +//! 这一条(按钮家族自己的 A1)。**不得整体对外声称「按官方规格实现」**: //! //! | # | 项 | 值 | 等级 | //! |---|---|---|---| @@ -75,9 +76,11 @@ pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> El icons::view::(spec.icon, ui.color(Role::OnSurfaceVariant)), ); let block_radius = iced::border::Radius::new(geometry.block_radius); - // 块底色 `[估]`:取材质层 `Card`(`SurfaceContainerLow`)——空态块是「内容卡片」层级, - // 不是浮层也不是画布;官方没有这个组件,所以这条是我们自己的口径。 - let block_color = ui.layer(MaterialLayer::Card); + // 块底色 `[估]`:`SurfaceContainerHigh`(材质层 `Overlay`)。早先取 `Card` + // (`SurfaceContainerLow`)与画布的 **ΔRGB 只有 11/5/0(≈1% 亮度)**,浅色下几乎看不出块 + // 边界(复审 P2-2);这里换成高一档,并补一条 1px `OutlineVariant` 描边兜底。 + let block_color = ui.layer(BLOCK_LAYER); + let block_outline = ui.color(Role::OutlineVariant); let block = container(glyph) .width(Length::Fixed(geometry.block)) .height(Length::Fixed(geometry.block)) @@ -86,15 +89,19 @@ pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> El .style(move |_theme| container::Style { background: Some(Background::Color(block_color)), border: Border { + color: block_outline, + width: crate::theme::STROKE_PX, radius: block_radius, - ..Border::default() }, ..container::Style::default() }); + // **列间距走显式 gap,不走 `Column::spacing`**(复审 P2-1):两者叠加时 + // block↔标题 会渲染成 8 + 16 + 8 = **32**、正文↔CTA 会渲染成 **40**, + // 而登记的是 16 / 8 / 24(实测内容总高 264,按登记值应 232)。 let mut content = column![] .align_x(Alignment::Center) - .spacing(geometry.gap_body) + .spacing(0.0) .push(block) .push(gap(geometry.gap_title)) .push( @@ -103,6 +110,7 @@ pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> El .font(ui.font_of(title_style)) .color(ui.color(Role::OnSurface)), ) + .push(gap(geometry.gap_body)) .push( text(spec.body) .size(body_style.size_px) @@ -125,7 +133,17 @@ pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> El .into() } -/// 固定高度的透明留白(列间距在 `spacing` 之外还要分档时用)。 +/// 图标块的材质层(`[估]`:空态块是「抬起来的卡片」层级,不是画布也不是浮层)。 +/// +/// probe 与测试都从这里取,避免「组件一个色、断言另一个色」。 +const BLOCK_LAYER: MaterialLayer = MaterialLayer::Overlay; + +/// 图标块的实际材质层(probe / 测试用)。 +pub fn block_layer() -> MaterialLayer { + BLOCK_LAYER +} + +/// 固定高度的透明留白(列间距的唯一来源:`Column::spacing` 必须保持 0,否则双算)。 fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> { iced::widget::Space::new() .width(Length::Fill) diff --git a/crates/app-iced/src/components/feedback/probe/panels.rs b/crates/app-iced/src/components/feedback/probe/panels.rs new file mode 100644 index 0000000..8758ca2 --- /dev/null +++ b/crates/app-iced/src/components/feedback/probe/panels.rs @@ -0,0 +1,188 @@ +//! 两个「面」的渲染层判据:Dialog(§7)与空态(§15)。`probe` 的子模块。 +//! +//! 量什么: +//! +//! - 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**。 +//! +//! 空态那两条间距断言是复审 P2-1 的回归:早先 `Column::spacing(8)` 与显式 `gap(16/24)` 双算, +//! 渲染成 32/40、总高 264,而登记的是 16/24、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, matches, near, rgb, row_ink, runs, BBox, Shot, +}; +use crate::theme::Ui; + +/// Dialog 宽(本项目取值,官方区间 280..560 内)。 +const DIALOG_W: usize = 480; +/// 空态图标块边长。 +const BLOCK: usize = 96; +/// 空态条目总高:96 块 + 16 + 32 标题 + 8 + 16 副文案 + 24 + 40 CTA。 +const EMPTY_TOTAL: usize = 232; + +/// §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); + + // ---- 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); + } + } + } + let (width, card_left, card_row) = widest; + assert!( + near(width, DIALOG_W), + "7.4:对话框宽必须是 {DIALOG_W}(G17-e 项目取值),实测 {width}" + ); + assert!( + (280..=560).contains(&width), + "7.4:宽必须落在官方区间 280..560 内(A1 DialogMinWidth/DialogMaxWidth),实测 {width}" + ); + + // 该行上下扩成卡片带(同一张卡片的连续行;两张卡并排时各自成带) + 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; + } + + // ---- 7.3 内距:标题墨迹距卡片左缘 ≈24、顶 ≈24 + 行内 bearing ----------------- + let bbox = BBox { + left: card_left, + right: card_left + width - 1, + top, + bottom, + }; + let title = + ink_inside(shot, &bbox, ui.color(Role::OnSurface), card, 2).expect("卡片里必须有标题墨迹"); + let inset = title.left.saturating_sub(card_left); + assert!( + (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 宽的遮罩带,颜色 = 预合成实色 ---------------------- + let backdrop = scrim(ui); + let scrim_bands = bands(shot, backdrop, surface, 700); + assert!( + !scrim_bands.is_empty(), + "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", + scrim_bands.len() + ); +} + +/// §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()); + + // 下限 40 而不是 88:块只有 96 宽,中间的图标(32)会把那几行的块色墨量压到 ≈64。 + let found = bands(shot, block, surface, 40); + let (top, bottom) = found + .iter() + .copied() + .max_by_key(|(top, bottom)| bottom - top) + .filter(|(top, bottom)| near(bottom - top + 1, BLOCK)) + .unwrap_or_else(|| { + panic!("15.1:没找到高 {BLOCK} 的图标块(`[估]` 项目自定值),实测量到的带:{found:?}") + }); + + let width = band_width(shot, (top, bottom), block, surface); + assert!( + near(width, BLOCK), + "15.1:图标块必须 {BLOCK}×{BLOCK},实测宽 {width}" + ); + + // ---- P2-1 回归:块↔标题的**实际**间距(16 而不是 32)------------------------- + let bbox = BBox { + left: 0, + right: shot.width - 1, + top: bottom + 1, + bottom: shot.height - 1, + }; + let title = ink_inside(shot, &bbox, ui.color(Role::OnSurface), surface, 0) + .expect("块下面必须有标题墨迹"); + let gap = title.top.saturating_sub(bottom + 1); + assert!( + (16..=24).contains(&gap), + "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() + .copied() + .find(|(top, bottom)| near(bottom - top + 1, 40)) + .unwrap_or_else(|| panic!("15.6:CTA 应是标准按钮(可见高 40),实测带 {cta:?}")); + let total = cta_bottom + 1 - top; + assert!( + total.abs_diff(EMPTY_TOTAL) <= 6, + "15.x:条目总高应 ≈{EMPTY_TOTAL}(96+16+32+8+16+24+40),实测 {total}" + ); + + eprintln!( + "[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · 块↔标题 {gap} · \ + 总高 {total} · CTA {}..{}", + bottom - top + 1, + cta_top, + cta_bottom + ); +} diff --git a/crates/app-iced/src/components/feedback/tests.rs b/crates/app-iced/src/components/feedback/tests.rs index da32ee0..d0bd523 100644 --- a/crates/app-iced/src/components/feedback/tests.rs +++ b/crates/app-iced/src/components/feedback/tests.rs @@ -7,173 +7,28 @@ use iced::Element; use unlucky_design::color::{Role, Scheme, SchemeFlavor, SCRIM_ALPHA}; use unlucky_design::elevation::MaterialLayer; -use unlucky_design::shape::ShapeScale; use unlucky_design::typography::Platform; use crate::theme::{to_iced, Families, Ui}; use super::super::interaction::{self, Interaction}; use super::dialog::scrim; -use super::geometry::{phase_segments, CircularGeometry, LinearGeometry}; -use super::keyframes::{KeyPoint, Keyframes}; use super::panel::{DialogGeometry, EmptyStateGeometry, SnackbarGeometry}; -use super::progress::{Amount, Progress}; use super::snackbar::{Placement, Snackbar}; use super::{circular, dialog, empty_state, linear, snackbar, Dialog, EmptyState}; +mod progress; + const FAMILIES: Families = Families { text: "Segoe UI", display: "Segoe UI", mono: "Consolas", }; -fn ui(flavor: SchemeFlavor) -> Ui { +pub(super) fn ui(flavor: SchemeFlavor) -> Ui { Ui::with_platform(flavor, Platform::Windows, FAMILIES) } -// ---- §12 进度 --------------------------------------------------------------- - -/// 12.1:线性厚度 **4**、圆角 full ⇒ 2(`ShapeScale::Full.radius_px(4)`)。 -#[test] -fn linear_thickness_and_radius_come_from_the_tokens() { - let geometry = LinearGeometry::of(&ui(SchemeFlavor::Light)); - assert_eq!(geometry.thickness, 4.0, "12.1 线性厚度 = 4(A1 Height)"); - assert_eq!( - geometry.radius, - ShapeScale::Full.radius_px(4.0), - "12.1 圆角 full = 厚的一半" - ); - assert_eq!(geometry.radius, 2.0); - assert_eq!( - geometry.cycle_ms, 1750, - "不定态周期 = LinearAnimationDuration" - ); -} - -/// 12.3:环形 **40 / 4**,环中线半径 (40 − 4) / 2 = **18**。 -#[test] -fn circular_geometry_matches_the_tokens() { - let geometry = CircularGeometry::of(&ui(SchemeFlavor::Dark)); - assert_eq!( - geometry.diameter, 40.0, - "A1 CircularProgressIndicatorTokens.Size" - ); - assert_eq!(geometry.thickness, 4.0, "A1 ActiveThickness"); - assert_eq!(geometry.radius, 20.0, "圆角 full = 直径的一半"); - assert_eq!(geometry.arc_radius(), 18.0); - assert_eq!( - geometry.cycle_ms, 6000, - "A1 CircularAnimationProgressDuration" - ); -} - -/// 12.4:不定态两段**始终非负**且真的在跑(相位扫一圈,段中心单调右移)。 -#[test] -fn indeterminate_segments_stay_ordered_and_travel_right() { - let mut travelled = false; - let mut previous_end = 0.0; - for step in 0..=200 { - let phase = step as f32 / 200.0; - for (start, end) in phase_segments(phase) { - assert!( - start <= end + f32::EPSILON, - "相位 {phase:.3}:段起点 {start} 大于终点 {end}(前缘/后缘写反就会这样)" - ); - assert!( - (0.0..=1.0).contains(&start) && (0.0..=1.0).contains(&end), - "相位 {phase:.3}:段越出轨道 {start}..{end}" - ); - } - let first = phase_segments(phase)[0].1; - if phase > 0.0 && phase < 0.5 && first > previous_end { - travelled = true; - } - previous_end = first; - } - assert!(travelled, "段前沿必须随相位右移(否则就是一根不动的条)"); - - // 相位 0:两段都是空的(前沿/后缘都在 0) - assert_eq!(phase_segments(0.0), [(0.0, 0.0), (0.0, 0.0)]); - // 1000/1750 ≈ 0.5714:第一线前沿到头(= 1.0) - let (_, head) = phase_segments(1000.0 / 1750.0)[0]; - assert!((head - 1.0).abs() < 1.0e-5, "第一线前沿应在 1000ms 到 1.0"); -} - -/// 环形不定态:弧长在 0.1..0.87 之间(A1 min/max),旋转走满一圈。 -#[test] -fn circular_indeterminate_sweeps_the_official_range() { - let geometry = CircularGeometry::of(&ui(SchemeFlavor::Light)); - let mut min = f32::MAX; - let mut max = f32::MIN; - let mut rotation = f32::MIN; - for step in 0..=600 { - let (sweep, spin) = geometry.indeterminate(step as f32 / 600.0); - min = min.min(sweep); - max = max.max(sweep); - rotation = rotation.max(spin); - } - assert!( - (min - 0.1).abs() < 1.0e-3, - "弧长下限 0.1(CircularIndeterminateMinProgress),实测 {min}" - ); - assert!((max - 0.87).abs() < 1.0e-3, "弧长上限 0.87,实测 {max}"); - assert!( - (rotation - 1.0).abs() < 1.0e-3, - "旋转应走满一整圈,实测 {rotation}" - ); -} - -/// 确定态:越界值夹到 0..1(0% 只画轨道,100% 画满)。 -#[test] -fn determinate_values_are_clamped() { - assert_eq!( - Progress::determinate(1.5).amount(), - Amount::Determinate(1.0) - ); - assert_eq!( - Progress::determinate(-2.0).amount(), - Amount::Determinate(0.0) - ); - assert_eq!(Progress::determinate(0.5).spans(), vec![(0.0, 0.5)]); - assert_eq!(Progress::determinate(0.0).spans(), vec![(0.0, 0.0)]); -} - -/// keyframes 求值器:端点保持、相位循环、越界相位按周期取模。 -#[test] -fn keyframes_hold_ends_and_wrap() { - const LINE: Keyframes = Keyframes { - cycle_ms: 1000, - points: &[ - KeyPoint { - ms: 250, - value: 0.0, - easing: unlucky_design::motion::Easing::Linear, - }, - KeyPoint { - ms: 750, - value: 1.0, - easing: unlucky_design::motion::Easing::Linear, - }, - ], - }; - assert_eq!(LINE.sample(0.0), 0.0, "第一点之前保持首值"); - assert_eq!(LINE.sample(0.25), 0.0, "正好在第一点"); - assert!((LINE.sample(0.5) - 0.5).abs() < 1.0e-5, "中点线性插值"); - assert_eq!(LINE.sample(0.75), 1.0); - assert_eq!(LINE.sample(0.9), 1.0, "最后一点之后保持末值"); - assert_eq!(LINE.sample(1.25), 0.0, "相位 1.25 = 0.25(循环)"); - assert_eq!( - LINE.sample(-0.75), - 0.0, - "负相位按周期折返:-0.75 → 0.25 相位 = 250ms = 首点" - ); - assert!( - (LINE.sample(-0.6) - 0.3).abs() < 1.0e-5, - "-0.6 → 0.4 相位 = 400ms = 30%" - ); - assert_eq!(LINE.sample(f32::NAN), 0.0, "NaN 不 panic,给首值"); -} - // ---- §14 Snackbar ----------------------------------------------------------- /// 14.3 / 14.1 / 宽度上限:48 / 68 / 4 / 600。 @@ -254,7 +109,14 @@ fn empty_state_geometry_is_the_project_defined_set() { assert_eq!(geometry.block, 96.0); assert_eq!(geometry.block_radius, 16.0, "ShapeScale::Large"); assert_eq!(geometry.icon, 32.0, "IconSize::Empty"); - assert!(geometry.gap_title > 0.0 && geometry.gap_body > 0.0); + // **复审 P2-1 的回归断言**:三个间距必须精确等于令牌档位。 + // 早先这里只写 `> 0.0`,所以「Column::spacing(8) 与显式 gap(16/24) 双算成 32/40」 + // 完全藏得住(渲染实测 264 vs 登记 232)。 + assert_eq!(geometry.gap_title, 16.0, "块↔标题 = Space::X4"); + 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); } // ---- 结构 / 角色 ------------------------------------------------------------ From 38b1e7a4ddfd42fb2df8ab3b8d358cc7f3e8a39d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 10:42:05 +0800 Subject: [PATCH 04/10] =?UTF-8?q?docs(iced/feedback):=20=E5=81=8F=E7=A6=BB?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E3=80=81=E5=9B=BE=E6=B3=A8=20L3=E3=80=81[?= =?UTF-8?q?=E4=BC=B0]=20=E5=8F=A3=E5=BE=84=E4=B8=8E=E6=B7=B1=E8=89=B2=20sc?= =?UTF-8?q?rim=20=E8=A7=82=E5=AF=9F=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1 的文档/证据订正(都是「图注与登记不一致」这类,但视觉 reviewer 正是照图注看图): - gallery 图注「SurfaceContainerHigh + L3」→ 补「(本项目不画阴影,见 D-1)」; - `snackbar.rs` 的「登记为偏离 D-3」→ D-4(D-3 是进度条波浪降级,之前混号了); - `mod.rs`「三条偏离」→ 四条,并把深色档 scrim 的观察项登记成 D-5; - `empty_state.rs` 开头「本文件数值全部是 [估]」→ 按表逐项区分 `[估]/[规格]/[源]`; - `action.rs` 模块文档补一句「禁用色的底必须是这两个动作件自己的容器角色」。 --- crates/app-iced/src/components/feedback/mod.rs | 4 +++- crates/app-iced/src/components/feedback/snackbar.rs | 3 ++- crates/app-iced/src/components/gallery/feedback.rs | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/app-iced/src/components/feedback/mod.rs b/crates/app-iced/src/components/feedback/mod.rs index 0089586..0edd03c 100644 --- a/crates/app-iced/src/components/feedback/mod.rs +++ b/crates/app-iced/src/components/feedback/mod.rs @@ -26,7 +26,8 @@ //! //! 本家族零模糊、零运行时半透明:进度条是实色、Dialog 的 scrim 是**预合成实色** //! ([`scrim`]),两个浮层都不画阴影(`ShadowSpec` 还是 M63 占位值 + D27 禁模糊)。 -//! 三条偏离逐条登记在报告里(D-1 无阴影 / D-2 scrim 预合成 / D-3 波浪未实现)。 +//! 四条偏离逐条登记在报告里(D-1 无阴影 / D-2 scrim 预合成 / D-3 波浪未实现 / D-4 Snackbar 无阴影), +//! 另有一条**观察项** D-5(深色档 scrim 只压暗 4%,见 `dialog.rs` 的 scrim 一节)。 //! //! ## 逐项偏离登记(提交报告里同一份表) //! @@ -37,6 +38,7 @@ //! | D-3 | 线性波浪 3/40、环形 1.6/15 | 直线段 / 等弧长 | `12b §12.4` 明确允许,登记后用匀速替代 | 是 | //! | D-4 | Snackbar L3 阴影 | 无阴影 | 同 D-1 | 是 | //! | G17-d | Snackbar 最小宽 344 | 组件不强制(宿主保证) | iced `Length` 无 min-width(同按钮 G15) | 是(自定义 Widget) | +//! | D-5 | 深色档 scrim 压暗 32% | 保持 0.32(官方值) | 画布本身很暗,(9,21,28)→(6,14,19) 只差 4%;改 α 就偏离官方 token,层级感应靠 Dialog 容器档解决 | 是(GPU 档可加真 α / 改容器档) | //! //! ## 文件地图 //! diff --git a/crates/app-iced/src/components/feedback/snackbar.rs b/crates/app-iced/src/components/feedback/snackbar.rs index 489a3b5..713dd49 100644 --- a/crates/app-iced/src/components/feedback/snackbar.rs +++ b/crates/app-iced/src/components/feedback/snackbar.rs @@ -28,7 +28,8 @@ //! ## 不画阴影 //! //! 官方是 L3,但 D27(CPU 档禁模糊)与令牌层 `ShadowSpec` 仍是 M63 占位值这两条都指向 -//! 「先不画」——层级靠 `InverseSurface` 与页面的 scrim/留白表达。登记为偏离 D-3。 +//! 「先不画」——层级靠 `InverseSurface` 与页面的 scrim/留白表达。登记为偏离 **D-4** +//! (D-3 是进度条的波浪降级,别混号)。 use iced::widget::{container, row, text}; use iced::{Alignment, Background, Border, Element, Length, Padding}; diff --git a/crates/app-iced/src/components/gallery/feedback.rs b/crates/app-iced/src/components/gallery/feedback.rs index ec65680..2ce10fd 100644 --- a/crates/app-iced/src/components/gallery/feedback.rs +++ b/crates/app-iced/src/components/gallery/feedback.rs @@ -297,7 +297,7 @@ fn dialog_block<'a>(ui: &'a Ui, full: bool) -> Element<'a, DemoMessage> { let mut body = column![caption( ui, - "Dialog · R28 · SurfaceContainerHigh + L3 · pad 24(标题下 16 / 正文下 24)· 动作 Primary 间距 8", + "Dialog · R28 · SurfaceContainerHigh + L3(**本项目不画阴影,见 D-1**)· pad 24(标题下 16 / 正文下 24)· 动作 Primary 间距 8", )] .spacing(ui.space(Space::X2)); From 10d4f86fdf037b19f66b481a80e7f6b9a9586218 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 12:19:41 +0800 Subject: [PATCH 05/10] =?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 个。") From 5d9e75efd82fb1ad77d22e8dabcad68ec47122fb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 12:34:08 +0800 Subject: [PATCH 06/10] =?UTF-8?q?test(iced/feedback):=20=E7=8E=AF=E5=BD=A2?= =?UTF-8?q?=E6=97=8B=E8=BD=AC=E5=91=A8=E6=9C=9F=E6=96=AD=E8=A8=80=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E5=AE=9E=E6=B5=8B=E6=95=B0=E5=80=BC=EF=BC=88=E8=B7=A8?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E8=B7=B3=E5=8F=98=20/=20=E6=AF=8F=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E5=9C=88=E6=95=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 给复审用的一行 `--nocapture` 输出:`[rotation] turns(0.9999)=… 跳变=… 圈(…°)· 每周期 4 圈 = 1440.0°`, 代码本身不变。 --- crates/app-iced/src/components/feedback/tests/progress.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/app-iced/src/components/feedback/tests/progress.rs b/crates/app-iced/src/components/feedback/tests/progress.rs index 84b9622..58a66fa 100644 --- a/crates/app-iced/src/components/feedback/tests/progress.rs +++ b/crates/app-iced/src/components/feedback/tests/progress.rs @@ -195,6 +195,13 @@ fn circular_indeterminate_rotation_is_four_turns_and_continuous() { jump <= 4.0 / 60.0, "跨周期旋转跳变 {jump} 圈(应 ≤ 4/60;修之前是 0.75 圈 = 270°)" ); + // 给报告用的一行实测数值(`--nocapture` 可见):跨周期跳变 + 每周期圈数 + eprintln!( + "[rotation] turns(0.9999)={before:.6} turns(0.0)={after:.6} 跳变={jump:.6} 圈({:.2}°)· 每周期 {:.4} 圈 = {:.1}°", + jump * 360.0, + end - start, + (end - start) * 360.0 + ); // 旋转必须单调(相位 0 → 1 逐帧不回退,按圈算) // 只扫 [0, 1) 的相位:phase == 1.0 就是下一周期的 0,回绕是**正常的**(mod 1 等价)。 From a4cdd61fe532caf6808add3c62e809221aa63f93 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 13:16:34 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(iced/feedback):=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=20r3=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 模块文档)", From 5d7426b6c9fefef98878fa12f692c83db172acb5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 13:17:34 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(iced/feedback):=20=E7=A9=BA=E6=80=81?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E6=94=B9=E7=94=A8=20M86=20=E7=9A=84=20view?= =?UTF-8?q?=5Fat=20=E6=B8=B2=E6=9F=93=2032=EF=BC=88=E5=A4=8D=E5=AE=A1=20C?= =?UTF-8?q?=20=E4=BF=AE=E6=AD=A3=E7=89=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tower 修正指令:M86(e377989)把 `icons::view_at(kind, ink, px)` 并进 main 了,所以 C 项 从「登记值改成 24」改为「登记值保持 32 + 用 view_at 真渲 32」: - `empty_state.rs`:`icons::view(..)` → `icons::view_at(.., geometry.icon)`,盒仍 96、 图标 32;`view_at` 按 32 **重新烘焙 stroke**,视觉线宽仍是 1.5px(不会把描边拉粗)。 早先 `view` 把 svg 钉成 `Fixed(24)`,32 的盒子只居中不拉伸 —— 实渲 24、墨迹 20×20。 - `panel.rs`:`EMPTY_ICON = 32.0`(`IconSize::Empty`),删掉 G18-h 欠账说明(已消解)。 - `probe/panels.rs`:图标墨迹判据 16..26 → **22..34**(32 盒)。 - `tests.rs`:`geometry.icon == 32` 保留,并写明它守的是「登记值 = 交给 view_at 的 px」, 抓不住「盒子 32 而 svg 仍钉 24」那类(渲染层由 probe 的墨迹断言兜)。 - 顺带清掉 `panels.rs` 里两个未用 import(`-D warnings` 会红)。 已 `git rebase main`(1f4bc1b,含 M86),无冲突。 --- .../src/components/feedback/empty_state.rs | 6 ++++-- crates/app-iced/src/components/feedback/panel.rs | 15 ++++++--------- .../src/components/feedback/probe/panels.rs | 10 ++++------ crates/app-iced/src/components/feedback/tests.rs | 8 ++++++-- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/crates/app-iced/src/components/feedback/empty_state.rs b/crates/app-iced/src/components/feedback/empty_state.rs index d173cc0..62f538f 100644 --- a/crates/app-iced/src/components/feedback/empty_state.rs +++ b/crates/app-iced/src/components/feedback/empty_state.rs @@ -69,11 +69,13 @@ pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> El let title_style = ui.style(TypeRole::HeadlineSmall); let body_style = ui.style(TypeRole::BodySmall); - // Q9:图标必须落在固定尺寸的盒子里,否则 fill 约束会让 usvg 按 Contain 撑满 96 块。 + // Q9(两层):①`view_at` 自己按 32 烘焙(stroke 也按 32 重算,视觉仍是 1.5px); + // ②外面再套 32 的固定盒,确保内容拿到的约束就是 32,不会被 96 块的 Fill 撑成 Contain。 + // 早先写的是 `view(..)`:它把 svg 钉成 24,32 的盒子只起居中作用,实渲 24(复审 C)。 let glyph = layout::centered_fixed( geometry.icon, geometry.icon, - icons::view::(spec.icon, ui.color(Role::OnSurfaceVariant)), + icons::view_at::(spec.icon, ui.color(Role::OnSurfaceVariant), geometry.icon), ); let block_radius = iced::border::Radius::new(geometry.block_radius); // 块底色 `[估]`:`SurfaceContainerHigh`(材质层 `Overlay`)。早先取 `Card` diff --git a/crates/app-iced/src/components/feedback/panel.rs b/crates/app-iced/src/components/feedback/panel.rs index 72d5bc3..08b537d 100644 --- a/crates/app-iced/src/components/feedback/panel.rs +++ b/crates/app-iced/src/components/feedback/panel.rs @@ -107,8 +107,11 @@ impl DialogGeometry { } } -/// 空态图标边长 = `icons::view` 的实际渲染尺寸(24),见 [`EmptyStateGeometry::icon`] 的 G18-h。 -pub const EMPTY_ICON: f32 = 24.0; +/// 空态图标边长(`IconSize::Empty` = 32)。 +/// +/// M86 起 `icons::view_at` 会按请求尺寸**重新烘焙 stroke**,所以 32 渲染仍是视觉 1.5px 线宽 +/// (早先 `view` 把 svg 钉成 24,32 的盒子只是居中不拉伸,实渲 24 —— 那个欠账已随 M86 消解)。 +pub const EMPTY_ICON: f32 = 32.0; /// 空态的几何(**项目自定**:官方没有这个组件;`[估]` 几何 + `[规格]` 排版 + `[源]` CTA)。 #[derive(Debug, Clone, Copy, PartialEq)] @@ -117,13 +120,7 @@ pub struct EmptyStateGeometry { pub block: f32, /// 图标块圆角(16,`ShapeScale::Large`)。 pub block_radius: f32, - /// 块内图标(**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,本线不自造。 + /// 块内图标(**32**,`IconSize::Empty`;由 `icons::view_at` 按 32 烘焙,stroke 同步重算)。 pub icon: f32, /// 块 ↔ 标题(16)。 pub gap_title: f32, diff --git a/crates/app-iced/src/components/feedback/probe/panels.rs b/crates/app-iced/src/components/feedback/probe/panels.rs index 89aa0ab..e6e996a 100644 --- a/crates/app-iced/src/components/feedback/probe/panels.rs +++ b/crates/app-iced/src/components/feedback/probe/panels.rs @@ -22,9 +22,7 @@ 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::pixel::{band_width, bands, ink_inside, matches, near, rgb, BBox, Shot}; use crate::theme::Ui; /// Dialog 宽(本项目取值,官方区间 280..560 内)。 @@ -218,7 +216,7 @@ pub(super) fn check_empty(shot: &Shot, ui: &Ui) { "15.1:图标块必须 {BLOCK}(±2,1px 描边)宽,实测 {width}" ); - // 图标本身:`icons::view` 钉成 24(G18-h 的登记值),墨迹 ≈20×20。 + // 图标本身:`view_at` 按 32 烘焙(stroke 同步重算),墨迹 ≈26~28。 let glyph = ink_inside( shot, &BBox { @@ -233,8 +231,8 @@ pub(super) fn check_empty(shot: &Shot, ui: &Ui) { ) .expect("块里必须有图标墨迹"); assert!( - (16..=26).contains(&glyph.width()) && (16..=26).contains(&glyph.height()), - "15.1:图标墨迹应在 16..26(24 盒;G18-h 登记 24,不是 32),实测 {}×{}", + (22..=34).contains(&glyph.width()) && (22..=34).contains(&glyph.height()), + "15.1:图标墨迹应在 22..34(32 盒;`view` 时代只有 20×20),实测 {}×{}", glyph.width(), glyph.height() ); diff --git a/crates/app-iced/src/components/feedback/tests.rs b/crates/app-iced/src/components/feedback/tests.rs index a39c264..fafa87d 100644 --- a/crates/app-iced/src/components/feedback/tests.rs +++ b/crates/app-iced/src/components/feedback/tests.rs @@ -109,8 +109,12 @@ 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"); - // 复审 C:登记值是 **24**(`icons::view` 的实渲染尺寸),不是 32;见欠账 G18-h。 - assert_eq!(geometry.icon, 24.0, "图标实渲染 24(G18-h)"); + // 复审 C:登记值 32(`IconSize::Empty`)。这条守「登记值 = 交给 `view_at` 的 px」—— + // 它抓不住「盒子 32 而 svg 仍钉 24」那种情况(那是渲染层的事,probe 量墨迹 22..34 兜)。 + assert_eq!( + geometry.icon, 32.0, + "盒内图标 32(`view_at` 按 32 烘焙 stroke)" + ); // **复审 P2-1 的回归断言**:三个间距必须精确等于令牌档位。 // 早先这里只写 `> 0.0`,所以「Column::spacing(8) 与显式 gap(16/24) 双算成 32/40」 // 完全藏得住(渲染实测 264 vs 登记 232)。 From 11d7b64a5ece44535a7c9504ce47a3bec6111f0e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 14:49:45 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(iced/feedback):=20=E9=80=90=E5=8D=A1?= =?UTF-8?q?=E5=AE=BD=E5=BA=A6/=E5=8A=A8=E4=BD=9C=E8=89=B2=E7=B2=BE?= =?UTF-8?q?=E7=A1=AE=E5=88=A4=E6=8D=AE=E3=80=81=E8=B4=B4=E5=BA=95=E8=BE=B9?= =?UTF-8?q?=E8=B7=9D=E3=80=81Dialog=20=E4=B8=8B=E9=99=90=E4=B8=8E=20icon?= =?UTF-8?q?=20=E8=B5=B0=20view=5Fat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r3 的 A/G/D/E 与随之冒出来的 clippy 项: - **A(p1,假绿)**:`7.6 Dialog 动作色` 原来量的是 `DialogActions::None` 那张卡的 bbox,且判据 是「离 Primary 更近」——标题/正文字形都被算进去(旧值 1790px)。现在:①判据换**精确色**; ②加两条反向证据(无动作那张卡里精确 Primary 必须为 0;有动作那张卡的下半区必须有字形行); ③量的是全图的动作字形(实测 362px,与复审独立复算一致)。变异:把 350 个动作字形涂成 `OnSurfaceVariant` → `7.6 实测只命中 12 px` → 红。 - **G**:Dialog 宽度改**逐卡**量(列剖面,排掉 scrim 色,深色档不会再把遮罩算成 1180 宽的假卡)。 环的逐环断言**登记为 G18-i**(实测 6 个环只有 3~4 个能被精确色簇干净命中,按簇数断言会把 「相位换了」误报成「环丢了」,理由写进 `probe/circular.rs` 模块文档)。 - **D**:两个 bottom 宿主 72 → 96,并给窄宿主画 1px 取景框;probe 新增「条底距宿主底 = 12」 (实测 `底边距 [11, 12]`)。取景框的描边不参与布局,所以宿主宽不变、条宽仍是 344/300。 - **E**:`DialogGeometry::effective_width` = `min(480, 可用宽).max(280)`(尊重官方下限); gallery 加一格 `Dialog::width(360)`;probe 逐卡断言实测 `[360, 480, 480, 480, 480]`。 - **C(按 tower 修正版)**:空态图标改用 M86 的 `icons::view_at(.., 32)`(先 rebase 到 main)。 - 顺带:`action.rs` 的 24 图标也走 `view_at(24)`——`icons.rs` 的守门测试按「逐文件调用点计数」 要求不在白名单里的文件 `icons::view` 为 0,本文件按盒尺寸烘一份(输出逐像素相同)。 - clippy:去掉 `needless_range_loop` 与未用变量,`-D warnings` 已绿。 --- .../src/components/feedback/action.rs | 4 +- .../src/components/feedback/geometry.rs | 4 ++ .../app-iced/src/components/feedback/probe.rs | 14 +++- .../src/components/feedback/probe/panels.rs | 69 +++++++++++++++---- .../src/components/gallery/feedback.rs | 1 + 5 files changed, 76 insertions(+), 16 deletions(-) diff --git a/crates/app-iced/src/components/feedback/action.rs b/crates/app-iced/src/components/feedback/action.rs index e2716ef..843ce2d 100644 --- a/crates/app-iced/src/components/feedback/action.rs +++ b/crates/app-iced/src/components/feedback/action.rs @@ -135,10 +135,12 @@ pub(super) fn icon_action<'a, M: Clone + 'a>( let ink = content_color(ui, content, container_role, disabled); + // 走 `view_at(24)` 而不是 `view(..)`:`icons.rs` 的守门测试按**逐文件调用点计数** + // 要求「不在白名单里的文件 `icons::view` 必须为 0」;本文件按盒尺寸显式烘一份(同值 24)。 let element = button(layout::centered_fixed( glyph_side, glyph_side, - icons::view::(kind, ink), + icons::view_at::(kind, ink, glyph_side), )) .width(Length::Fixed(side)) .height(Length::Fixed(side)) diff --git a/crates/app-iced/src/components/feedback/geometry.rs b/crates/app-iced/src/components/feedback/geometry.rs index 433119d..5ac3206 100644 --- a/crates/app-iced/src/components/feedback/geometry.rs +++ b/crates/app-iced/src/components/feedback/geometry.rs @@ -118,6 +118,10 @@ line!(SEG_TAIL_2, 900, 1750); /// /// 值来自:第二线 `[0.003, 0.076]`(tail 0.003 / head 0.079 → 合并到 0.076 区间的实测), /// 第一线 `[0.355, 0.860]`。 +/// +/// `#[cfg(test)]`:只有单测与渲染层探针(都在 `cfg(test)` 里)读它,产品路径不需要—— +/// 不留成产品常量是为了让 `-D warnings` 下的 `dead_code` 保持沉默。 +#[cfg(test)] pub const PHASE_055: [(f32, f32); 2] = [(0.003, 0.076), (0.355, 0.860)]; /// 线性进度的几何(厚度与圆角;轨道宽由宿主给,所以没有 width)。 diff --git a/crates/app-iced/src/components/feedback/probe.rs b/crates/app-iced/src/components/feedback/probe.rs index d8a1ada..bf7e4bd 100644 --- a/crates/app-iced/src/components/feedback/probe.rs +++ b/crates/app-iced/src/components/feedback/probe.rs @@ -275,10 +275,22 @@ fn check_snackbar(shot: &Shot, ui: &Ui) { if !(near(width, 300) || near(width, 344)) { continue; } + // 1px 描边常落在半像素上(两行各 ~50% 覆盖),精确色匹配抓不住; + // 用「更靠近框色而不是画布」判(这一带除了框没有别的灰线)。 + let distance = |pixel: [u8; 3], target: [u8; 3]| -> u32 { + pixel + .iter() + .zip(target) + .map(|(a, b)| u32::from(a.abs_diff(b))) + .sum() + }; let frame_row = ((bottom + 1)..shot.height) .find(|y| { (0..shot.width) - .filter(|x| matches(shot.at(*x, *y), frame)) + .filter(|x| { + let pixel = shot.at(*x, *y); + distance(pixel, frame) < distance(pixel, rgb(surface)) + }) .count() >= 200 }) diff --git a/crates/app-iced/src/components/feedback/probe/panels.rs b/crates/app-iced/src/components/feedback/probe/panels.rs index e6e996a..76a3df9 100644 --- a/crates/app-iced/src/components/feedback/probe/panels.rs +++ b/crates/app-iced/src/components/feedback/probe/panels.rs @@ -44,37 +44,85 @@ pub(super) fn check_dialog(shot: &Shot, ui: &Ui) { // ---- 7.4 宽度:先分「卡带」(同排两张卡的 y 区间会重叠,所以只能按行分组), // 再在每条带里按**列剖面**分出每一张卡(复审 A/G:早先只量最宽的那张)。 - let card_bands = bands(shot, card, surface, 200); + // 卡带用**精确色**数行(不用斜坡):深色档里正文/描边的抗锯齿像素都落在 + // 「surface → card」那条很短的斜坡上,用斜坡会把整屏都算成卡带(实测出现 1180 宽的假卡)。 + let card_rgb = rgb(card); + let scrim_rgb = rgb(scrim(ui)); + let card_rows: Vec = (0..shot.height) + .filter(|y| { + (0..shot.width) + .filter(|x| { + let pixel = shot.at(*x, *y); + matches(pixel, card_rgb) && !matches(pixel, scrim_rgb) + }) + .count() + >= 200 + }) + .collect(); + let mut card_bands: Vec<(usize, usize)> = Vec::new(); + let mut start: Option = None; + let mut previous: usize = 0; + for y in card_rows { + match start { + None => { + start = Some(y); + previous = y; + } + Some(_) if y == previous + 1 => previous = y, + Some(top) => { + card_bands.push((top, previous)); + start = Some(y); + previous = y; + } + } + } + if let Some(top) = start { + card_bands.push((top, previous)); + } assert!( card_bands.len() >= 3, "至少要找到 3 条卡带(第一排两张 + 双动作 + scrim 里那张),实测 {card_bands:?}" ); - let mut widths: Vec = Vec::new(); + // 每张卡记 (左缘, 宽):宽度给「逐卡」断言,左缘给内距断言用(不能按单行找左缘—— + // 圆角 28 会让 band_top+4 那一行的卡片像素内缩 ~10px,实测内距被量成 12 而不是 24)。 + // + // **深色档要把遮罩色排掉**:暗色下 `SurfaceContainerHigh` 与预合成 scrim 只差 ~35/通道, + // 不排掉时 scrim 那片也被算成「卡」(实测出现 1180 宽的假卡)。 + let mut cards: Vec<(usize, usize)> = 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))) + .filter(|y| { + let pixel = shot.at(x, *y); + matches(pixel, card_rgb) && !matches(pixel, scrim_rgb) + }) .count() }) .collect(); let mut start: Option = None; - for x in 0..=shot.width { - let column = x < shot.width && profile[x] >= height / 3; + for (x, ink) in profile.iter().enumerate() { + let column = *ink >= height / 3; match (column, start) { (true, None) => start = Some(x), (false, Some(from)) => { if x - from >= 280 { - widths.push(x - from); + cards.push((from, x - from)); } start = None; } _ => {} } } + if let Some(from) = start { + if shot.width - from >= 280 { + cards.push((from, shot.width - from)); + } + } } + let mut widths: Vec = cards.iter().map(|(_, width)| *width).collect(); widths.sort_unstable(); assert!( widths.iter().any(|w| near(*w, DIALOG_W)), @@ -93,14 +141,7 @@ pub(super) fn check_dialog(shot: &Shot, ui: &Ui) { // 内距与 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 (card_left, width) = cards[0]; let top = first.0; let bottom = first.1; let bbox = BBox { diff --git a/crates/app-iced/src/components/gallery/feedback.rs b/crates/app-iced/src/components/gallery/feedback.rs index 7a6163f..7da2d75 100644 --- a/crates/app-iced/src/components/gallery/feedback.rs +++ b/crates/app-iced/src/components/gallery/feedback.rs @@ -107,6 +107,7 @@ fn caption<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, DemoMessage> { } /// 带 1px 取景框的宿主:`Snackbar` 的贴底/贴顶需要一条**可见的宿主边界**才量得出来 +/// (描边画在盒内但**不参与布局**:内容区仍是整盒宽,实测条宽不受影响)。 /// (透明宿主的底边在图上不存在,`Placement::Bottom` 就不可证)。 fn framed_host<'a>( ui: &'a Ui, From d4e73aacdd1fef22b3721f2093e62459b4cd5576 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 15:40:29 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix(iced/feedback):=20=E9=80=90=E7=8E=AF?= =?UTF-8?q?=E6=94=B9=E5=9B=BA=E5=AE=9A=E7=AA=97=E5=88=A4=E6=8D=AE=20+=20?= =?UTF-8?q?=E8=B4=B4=E9=A1=B6=E8=BE=B9=E8=B7=9D=20+=20Dialog=20=E4=B8=8B?= =?UTF-8?q?=E9=99=90=E5=8D=95=E6=B5=8B=EF=BC=88=E5=A4=8D=E5=AE=A1=20r3=20P?= =?UTF-8?q?2-3/P2-1/P2-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D45 封顶轮的最后一改,只动这三条: - **P2-3 逐环断言(假绿)**:环本来就在固定 x 栅格(24 + 56·i、宽 40),判据从「主色连通簇数 非空」改成**六个窗逐个测**「窗内墨迹(轨道 ∪ 激活并集)外接成 40(±2)×40(±2)、且不越窗」, 外加「1..5 号窗必须有激活色弧」。簇数只打印不再当判据。G18-i 从「欠账」改成**已收口**, 并删掉模块文档里「要么把 6 个环摆成固定 x 栅格」那句错话(它们本来就在栅格上)。 环带定位也换过两版(「每行墨点 4..20」会把 40 切成 32;「首行..末行」在深色档扩到 274 行), 最终用「第一个窗里连续有墨、高度≈40 的行组」。 变异:涂掉 4 个环 → `第 1 个窗里没有任何环墨迹:环丢了` 红;25% 环缩成 20×20 → `第 1 个窗里的环应 40×40(±2),实测 20×20` 红。 - **P2-1 贴顶边距**:新增「框顶行 → 条顶行 = 36±1」与「取景框高 = 96±1」两条。 变异(`HOST_SHORT` 96 → 72,重编重拍):`D:贴底 ⇒ 框顶到条顶应为 36(96 − 48 − 12),实测 12` 红; 还原后绿。—— 之前只量「条底距框底」,宿高回退成 72 时确实抓不住。 顺带修了取景框判据本身:条自身(深色)比画布更接近框色,会把条自己认成框(实测差值 1), 现在要求「框色连续段比条本身更宽」。 - **P2-2 Dialog 下限**:补单测 `effective_width(200.0) == 280.0`(360/800 两点一并钉住), 并把「不低于官方 280」写进 `Dialog::width` 的文档。 - 欠账登记(只写表、不改代码):G18-j(动作色用全图合计,单卡漂移可能被别卡盖住)、 G18-k(再 4 处文档残留,不为凑数扩大 diff)、G18-l(取景框判据会把条的抗锯齿底行算成框, 余量 0.0008,方向是误报;加了「框高 96」后触发概率下降)。 --- .../src/components/feedback/dialog.rs | 3 +- .../app-iced/src/components/feedback/mod.rs | 4 + .../app-iced/src/components/feedback/probe.rs | 48 +++-- .../src/components/feedback/probe/circular.rs | 171 ++++++++++++++---- .../app-iced/src/components/feedback/tests.rs | 20 ++ 5 files changed, 198 insertions(+), 48 deletions(-) diff --git a/crates/app-iced/src/components/feedback/dialog.rs b/crates/app-iced/src/components/feedback/dialog.rs index 09eed0d..78cae57 100644 --- a/crates/app-iced/src/components/feedback/dialog.rs +++ b/crates/app-iced/src/components/feedback/dialog.rs @@ -99,7 +99,8 @@ impl<'a, M> Dialog<'a, M> { } } - /// 可用宽(默认 480,组件取 `min(available, 480)`)。 + /// 可用宽(默认 480)。实际宽 = `min(available, 480)`,且**不低于官方下限 280** + /// (`DialogMinWidth`;可用宽传 200 时仍画 280 并溢出宿主 —— iced 无 min-width,见 G17-d)。 /// /// 为什么不让组件自己从宿主推:iced 的 `Length` 没有 min-width,而 `Fill + max_width` /// 会被父级的精确分配顶掉(实测渲染成 548 而不是 480,见 `dialog()` 里的说明)。 diff --git a/crates/app-iced/src/components/feedback/mod.rs b/crates/app-iced/src/components/feedback/mod.rs index 2e8ef13..c3860b8 100644 --- a/crates/app-iced/src/components/feedback/mod.rs +++ b/crates/app-iced/src/components/feedback/mod.rs @@ -42,6 +42,10 @@ //! | D-4 | Snackbar L3 阴影 | 无阴影 | 同 D-1 | 是 | //! | G17-d | Snackbar 最小宽 344 | 组件不强制(宿主保证) | iced `Length` 无 min-width(同按钮 G15) | 是(自定义 Widget) | //! | D-5 | 深色档 scrim 压暗 32% | 保持 0.32(官方值) | 画布本身很暗,(9,21,28)→(6,14,19) 只差 4%;改 α 就偏离官方 token,层级感应靠 Dialog 容器档解决 | 是(GPU 档可加真 α / 改容器档) | +//! | G18-i | 环的逐环断言 | **已用固定窗收口** | gallery 的环本来就在固定 x 栅格(24+56·i),按窗测存在性 + 40(±2) 尺寸完全稳定;簇数只打印不再当判据 | 已修(r4) | +//! | G18-j | Dialog 动作色阈值 | 120 px(全图精确色) | 多卡时「单卡动作色漂移」可能被别的卡的总数盖住;精确色本身是硬的,弱的是「合计」这个口径 | 是(改成逐卡计数) | +//! | G18-k | 文档残留 | 本轮只改了能确证的行 | 复审又找出 4 处同类残留;不为凑数扩大 diff,留待下次触碰该文件时顺手改 | 是 | +//! | G18-l | snackbar 取景框判据 | 「更靠近框色而不是画布」 | 条自身的抗锯齿底行覆盖率实测 0.5008(余量 0.0008),可能被算成框 ⇒ **误报方向**(不是假绿);加了「框高 96」之后触发概率下降 | 是(改用精确色 + 容差,或自绘取景框) | //! //! ## 文件地图 //! diff --git a/crates/app-iced/src/components/feedback/probe.rs b/crates/app-iced/src/components/feedback/probe.rs index bf7e4bd..bd88fe2 100644 --- a/crates/app-iced/src/components/feedback/probe.rs +++ b/crates/app-iced/src/components/feedback/probe.rs @@ -275,8 +275,9 @@ fn check_snackbar(shot: &Shot, ui: &Ui) { if !(near(width, 300) || near(width, 344)) { continue; } - // 1px 描边常落在半像素上(两行各 ~50% 覆盖),精确色匹配抓不住; - // 用「更靠近框色而不是画布」判(这一带除了框没有别的灰线)。 + // 1px 描边常落在半像素上(两行各 ~50% 覆盖),精确色匹配抓不住 ⇒ 用「更靠近框色 + // 而不是画布」判。但条自身(深色)比画布**更接近**框色,所以还要一条:这一行的 + // 框色连续段必须**比条本身更宽**(框横贯整个宿主),否则会认成条自己(实测过:差值 1)。 let distance = |pixel: [u8; 3], target: [u8; 3]| -> u32 { pixel .iter() @@ -284,19 +285,44 @@ fn check_snackbar(shot: &Shot, ui: &Ui) { .map(|(a, b)| u32::from(a.abs_diff(b))) .sum() }; + let surface_rgb = rgb(surface); + let frame_run = |y: usize| -> usize { + let mut longest = 0; + let mut current = 0; + for x in 0..shot.width { + let pixel = shot.at(x, y); + if distance(pixel, frame) < distance(pixel, surface_rgb) { + current += 1; + longest = longest.max(current); + } else { + current = 0; + } + } + longest + }; + // 框的横贯长度 > 条宽 + 10 ⇒ 只有框那一两行满足 + let is_frame_row = |y: usize| frame_run(y) >= width + 10; + let frame_row = ((bottom + 1)..shot.height) - .find(|y| { - (0..shot.width) - .filter(|x| { - let pixel = shot.at(*x, *y); - distance(pixel, frame) < distance(pixel, rgb(surface)) - }) - .count() - >= 200 - }) + .find(|y| is_frame_row(*y)) .unwrap_or_else(|| { panic!("D:窄条下方没找到宿主取景框(条 y {top}..{bottom},宽 {width})") }); + let frame_top_row = (0..*top) + .rev() + .find(|y| is_frame_row(*y)) + .unwrap_or_else(|| panic!("D:窄条上方没找到宿主取景框(条顶 y {top})")); + // 复审 P2-1:宿高回退到 72(上下留白相等)时这条会红。 + assert!( + (*top as i64 - frame_top_row as i64 - 36).abs() <= 1, + "D:贴底 ⇒ 框顶到条顶应为 36(96 − 48 − 12),实测 {}", + *top as i64 - frame_top_row as i64 + ); + let frame_height = frame_row - frame_top_row + 1; + assert!( + frame_height.abs_diff(96) <= 1, + "D:取景框高应 96(宿主高),实测 {frame_height}" + ); margins.push(frame_row - bottom); } assert!( diff --git a/crates/app-iced/src/components/feedback/probe/circular.rs b/crates/app-iced/src/components/feedback/probe/circular.rs index b675754..42e7a7a 100644 --- a/crates/app-iced/src/components/feedback/probe/circular.rs +++ b/crates/app-iced/src/components/feedback/probe/circular.rs @@ -1,59 +1,158 @@ //! 环形进度的渲染层判据(`probe` 的子模块,`inputs/probe/` 的同一先例)。 //! -//! 量两件事(`research/12b` §12.3): +//! ## 判据:六个**固定窗**逐个测存在性与尺寸 //! -//! - 环的**外接框 40(±1)** —— 直径是 A1 `CircularProgressIndicatorTokens.Size`; -//! - 环的外接框**四角无墨** —— 真圆,不是圆角方。 +//! gallery 里这一排环在**固定 x 栅格**上:左缘 `24 + 56·i`(环宽 40、间距 16),共 6 个 +//! (0% / 25% / 50% / 75% / 100% / ind 0.30)。判据是「每个窗里都必须有一圈 +//! **40(±2)×40(±2)** 的墨迹(轨道 ∪ 激活色的并集)」: +//! +//! - 少一个环 → 该窗没有墨迹 → 红; +//! - 某个环被画小了(例如 20×20)→ 该窗墨迹的外接框变小 → 红; +//! - 0% 那格只有轨道、没有激活色 → 用「并集」判,不把「没有激活色」当失败。 +//! +//! 早先用「主色连通簇数」判(只要求非空):把 5 个环涂掉仍然绿(复审 r3 P2-3 的反证), +//! 所以 G18-i 的处置从「不修」改成**已用固定窗收口**。簇数仍在日志里打印供复核。 //! //! 不定态的**节奏**(4 圈/周期、跨周期不跳变)不在这里量:那是时间的函数,截图只有一帧, //! 判据在 `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 后按几何断言)。 +//! 欠账 **G18-l**(登记不修):`snackbar` 的「取景框」判据会把条自身的抗锯齿底行算成框 +//! (覆盖率 0.5008,余量 0.0008),方向是**误报**不是假绿;本轮给 snackbar 加了「框高 = 96」 +//! 之后触发概率下降,但那 0.0008 的余量仍在。 +use iced::Color; + +use super::super::pixel::{blobs, coverage, dims, rgb, BBox, Shot}; use unlucky_design::color::Role; -use super::super::pixel::{blobs, dims, matches, near, rgb, Shot}; use crate::theme::Ui; /// 环直径(A1 `CircularProgressIndicatorTokens.Size = 40`)。 const DIAMETER: usize = 40; +/// 环排栅格的左缘(页内边距 24)。 +const RING_X0: usize = 24; +/// 栅格间距(40 环 + 16 = `Space::X4`)。 +const RING_PITCH: usize = 56; +/// 窗数(0 / 25 / 50 / 75 / 100 / ind)。 +const RINGS: usize = 6; -/// 环形那一排的判据。 +/// 某个像素算不算「这块墨」(覆盖率 ≥50%,与 `pixel::runs` 同一口径)。 +fn is_ink(pixel: [u8; 3], fill: Color, under: Color) -> bool { + coverage(pixel, rgb(fill), rgb(under)) >= 0.5 +} + +/// 环形那一排的判据:逐窗测「有没有一圈 40×40 的墨迹」。 pub(super) fn check(shot: &Shot, ui: &Ui) { let active = ui.color(Role::Primary); - let rings: Vec<_> = blobs(shot, active, 30) - .into_iter() - .filter(|bbox| near(bbox.width(), DIAMETER) && near(bbox.height(), DIAMETER)) - .collect(); - assert!( - !rings.is_empty(), - "12.3:没找到 40(±1) 的环形激活层;候选:{}", - dims(&blobs(shot, active, 10)) - ); - for ring in &rings { - for (x, y) in [ - (ring.left, ring.top), - (ring.right, ring.top), - (ring.left, ring.bottom), - (ring.right, ring.bottom), - ] { - assert!( - !matches(shot.at(x, y), rgb(active)), - "12.3:环必须是真圆(外接框四角不该有墨)@ ({x},{y})" - ); + let track = ui.color(Role::SecondaryContainer); + let surface = ui.color(Role::Surface); + + // 环带的 y:以第一个窗(0% 环,只有轨道色)的**圆环签名**定位 —— 中间那几十行在窗的 + // 左右两侧都有墨迹;再向上/下各扩到「窗内还有墨」为止(把圆顶/圆底那两行收回来)。 + // + // 为什么不按「每行墨点数 4..20」判:圆顶那一行笔画横跨 ~20~28 px,会落在区间外, + // 按连续段会把 40 高的带子切成 32 行;为什么不按「首行..末行」:深色档里正文的抗锯齿 + // 像素也会落在轨道色的混色线上,跨度会扩到 274 行(两种都实测过)。 + let ink_count = |y: usize, from: usize, to: usize| -> usize { + (from..to) + .filter(|x| is_ink(shot.at(*x, y), track, surface)) + .count() + }; + let left = RING_X0; + let right = RING_X0 + DIAMETER; + + // 环带 = 第一个窗里「有墨」的**连续行组**中、高度 ≈40 的那一组。 + // + // 走过的两条弯路都实测过: + // ①「每行墨点 4..20」→ 圆顶那一行笔画横跨 ~20~28 px,被排除,带子切成 32 行; + // ②「左右两侧都有墨」→ 进度条那一行也满足(通栏),会认成 4 行高的假带。 + // 连续行组 + 高度窗口同时把「进度条」(4~5 行)与「正文行」排除在外。 + let mut runs: Vec<(usize, usize)> = Vec::new(); + let mut current: Option<(usize, usize)> = None; + for y in 0..shot.height { + match (ink_count(y, left, right) > 0, current) { + (true, None) => current = Some((y, y)), + (true, Some((top, _))) => current = Some((top, y)), + (false, Some(run)) => { + runs.push(run); + current = None; + } + (false, None) => {} } } + if let Some(run) = current { + runs.push(run); + } + let (top, bottom) = runs + .iter() + .copied() + .find(|(top, bottom)| (DIAMETER - 6..=DIAMETER + 6).contains(&(bottom - top + 1))) + .unwrap_or_else(|| panic!("没找到高约 {DIAMETER} 的环形带;窗口 0 的连续行组:{runs:?}")); - let clusters = blobs(shot, active, 10).len(); + let mut report = Vec::new(); + for index in 0..RINGS { + let left = RING_X0 + RING_PITCH * index; + let right = left + DIAMETER - 1; + let mut ink: Option = None; + for y in top..=bottom { + for x in left..=right { + let pixel = shot.at(x, y); + if is_ink(pixel, active, surface) || is_ink(pixel, track, surface) { + match &mut ink { + Some(found) => found.include(x, y), + None => { + ink = Some(BBox { + left: x, + right: x, + top: y, + bottom: y, + }) + } + } + } + } + } + let ink = ink.unwrap_or_else(|| { + panic!("第 {index} 个窗(x {left}..{right})里没有任何环墨迹:环丢了") + }); + assert!( + (DIAMETER - 2..=DIAMETER + 2).contains(&ink.width()) + && (DIAMETER - 2..=DIAMETER + 2).contains(&ink.height()), + "第 {index} 个窗(x {left}..{right})里的环应 {DIAMETER}×{DIAMETER}(±2),实测 {}×{}", + ink.width(), + ink.height() + ); + assert!( + ink.left >= left && ink.right <= right, + "第 {index} 个窗的墨迹越界:{}..{} 不在 {left}..{right}", + ink.left, + ink.right + ); + report.push(format!("{index}:{}×{}", ink.width(), ink.height())); + } + + // 激活色(弧)必须出现在 1.. 号窗里(0% 那格本来就只有轨道)。 + for index in 1..RINGS { + let left = RING_X0 + RING_PITCH * index; + let count: usize = (top..=bottom) + .map(|y| { + (left..left + DIAMETER) + .filter(|x| is_ink(shot.at(*x, y), active, surface)) + .count() + }) + .sum(); + assert!( + count > 40, + "第 {index} 个窗(x {left}..)的激活色弧只有 {count} px:环在但弧没画" + ); + } + + let clusters = blobs(shot, active, 10); eprintln!( - "[feedback-probe] circular:40(±1) 环 {} 个 {}(主色连通簇共 {clusters} 个,见 G18-i)", - rings.len(), - dims(&rings) + "[feedback-probe] circular:固定窗 {RINGS} 个逐窗 [{ }](y {top}..{bottom})· \ + 主色连通簇 {} 个(仅打印,判据是窗)", + report.join(" / "), + clusters.len() ); + let _ = dims(&clusters); } diff --git a/crates/app-iced/src/components/feedback/tests.rs b/crates/app-iced/src/components/feedback/tests.rs index fafa87d..7ecc203 100644 --- a/crates/app-iced/src/components/feedback/tests.rs +++ b/crates/app-iced/src/components/feedback/tests.rs @@ -62,6 +62,26 @@ fn dialog_geometry_sits_inside_the_official_bounds() { ); } +/// 复审 P2-2:`max(280)` 那条支以前一次都没被执行过(只有定义 + 一个调用点)。 +/// +/// 抓得住:有人把下限去掉(200 → 200)、或把 `min/max` 顺序写反(200 → 480 之外的值)。 +/// 抓不住:渲染时是否真的画了 280(那需要真窗口;gallery 的 360 那格由 probe 的逐卡宽度兜)。 +#[test] +fn dialog_width_respects_the_official_minimum() { + let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light)); + assert_eq!( + geometry.effective_width(200.0), + 280.0, + "可用宽 200 → 仍画官方下限 280" + ); + assert_eq!(geometry.effective_width(360.0), 360.0, "窄宿主取可用宽"); + assert_eq!( + geometry.effective_width(800.0), + 480.0, + "宽宿主夹到项目值 480" + ); +} + /// 7.1 / 7.3 / 7.6:圆角 28、内距 24 / 16 / 24、动作 8 / 12。 #[test] fn dialog_padding_and_action_gaps_are_the_tokens() {