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() +}