Merge branch 'feat/p4-iced-components-feedback-progress-sna'
This commit is contained in:
commit
270026338f
17 files changed
+3753
-39
No files matched your search
@@ -0,0 +1,173 @@
|
||||
//! 反馈家族里的两个**小动作件**:文字动作与图标动作。
|
||||
//!
|
||||
//! ## 为什么不复用 `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<M>`,`None` = 禁用;文字色降到
|
||||
//! `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::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::{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 =
|
||||
/// `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<M>,
|
||||
forced: Option<Interaction>,
|
||||
) -> 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 = content_color(ui, content, container_role, disabled);
|
||||
|
||||
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<M>,
|
||||
forced: Option<Interaction>,
|
||||
) -> 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 = 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_at::<M>(kind, ink, glyph_side),
|
||||
))
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! 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-5)**:画布本身很暗,0.32 叠上去只从 (9,21,28) 压到 (6,14,19)
|
||||
//! (≈4%),几乎看不出「压暗」。数值是官方 token 推出来的,**不调**:要更强的层级感应该动
|
||||
//! Dialog 的容器档或阴影,而不是把 scrim 抬到 0.4(那就偏离 token 了)。
|
||||
//!
|
||||
//! ## 不画阴影(偏离 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>,
|
||||
max_width: f32,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Dialog<'a, M> {
|
||||
/// 标题 + 正文,默认无动作。
|
||||
pub fn new(title: &'a str, body: &'a str) -> Self {
|
||||
Self {
|
||||
title,
|
||||
body,
|
||||
actions: DialogActions::None,
|
||||
// 项目取值 480(官方区间 280..560 内);窄宿主由页面传更小的可用宽。
|
||||
max_width: 480.0,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 可用宽(默认 480)。实际宽 = `min(available, 480)`,且**不低于官方下限 280**
|
||||
/// (`DialogMinWidth`;可用宽传 200 时仍画 280 并溢出宿主 —— iced 无 min-width,见 G17-d)。
|
||||
///
|
||||
/// 为什么不让组件自己从宿主推: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 };
|
||||
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>>) -> 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,
|
||||
));
|
||||
}
|
||||
|
||||
// 宽度 = `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(可用宽)`。
|
||||
// 可用宽 → 实际宽:`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(effective))
|
||||
.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<Interaction>,
|
||||
) -> 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()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 空态 EmptyState(`research/12b` §15):图标块 + 标题 + 说明 + 可选动作。
|
||||
//!
|
||||
//! ## ⚠️ 这是**项目自定**组件,官方 Material 3 没有它
|
||||
//!
|
||||
//! `research/14`(官方组件清单)里**没有** EmptyState 这一项,Compose `material3` 也没有
|
||||
//! 对应 token。所以**几何与排布是我们自定的**:下面表里标 `[估]` 的(96 块 / 三个间距 / 底色)
|
||||
//! 没有任何官方出处,标 `[规格]` 的是 `11b R14` 的排版口径、标 `[源]` 的只有「CTA 就是标准按钮」
|
||||
//! 这一条(按钮家族自己的 A1)。**不得整体对外声称「按官方规格实现」**:
|
||||
//!
|
||||
//! | # | 项 | 值 | 等级 |
|
||||
//! |---|---|---|---|
|
||||
//! | 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(两层):①`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_at::<M>(spec.icon, ui.color(Role::OnSurfaceVariant), geometry.icon),
|
||||
);
|
||||
let block_radius = iced::border::Radius::new(geometry.block_radius);
|
||||
// 块底色 `[估]`:`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))
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(block_color)),
|
||||
border: Border {
|
||||
color: block_outline,
|
||||
width: crate::theme::STROKE_PX,
|
||||
radius: block_radius,
|
||||
},
|
||||
..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(0.0)
|
||||
.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(gap(geometry.gap_body))
|
||||
.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()
|
||||
}
|
||||
|
||||
/// 图标块的材质层(`[估]`:空态块是「抬起来的卡片」层级,不是画布也不是浮层)。
|
||||
///
|
||||
/// probe 与测试都从这里取,避免「组件一个色、断言另一个色」。
|
||||
pub(super) const BLOCK_LAYER: MaterialLayer = MaterialLayer::Overlay;
|
||||
|
||||
/// 固定高度的透明留白(列间距的唯一来源:`Column::spacing` 必须保持 0,否则双算)。
|
||||
fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> {
|
||||
iced::widget::Space::new()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(height))
|
||||
.into()
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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);
|
||||
@@ -0,0 +1,289 @@
|
||||
//! 反馈家族的**几何与节奏**:值要么来自令牌,要么在此登记 **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→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` |
|
||||
//! | 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);
|
||||
|
||||
/// 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]`。
|
||||
///
|
||||
/// `#[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)。
|
||||
#[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;
|
||||
/// 环形的**全局旋转**:`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 {
|
||||
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°)。
|
||||
///
|
||||
/// **首点 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,
|
||||
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
|
||||
}
|
||||
|
||||
/// 不定态:相位 → `(弧长占比, 旋转圈数)`。
|
||||
///
|
||||
/// **圈数允许 > 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) {
|
||||
let global = CIRCULAR_GLOBAL_TURNS * phase.rem_euclid(1.0);
|
||||
(ARC_SWEEP.sample(phase), global + ROTATION.sample(phase))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 不定态的**节奏**:把官方的 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 对未覆盖的区间就是保持,这也是「段跑到头以后停在满格」的来源)。
|
||||
//!
|
||||
//! ## 首点的两种合法情况(复审 P1-B 的教训)
|
||||
//!
|
||||
//! Compose 在 keyframes **缺 0ms 关键帧**时会自己补一个 `t = 0`、值 = 动画**初值**的点
|
||||
//! (`VectorizedKeyframesSpec` 构造里 `Map.containsKey(0) == false` 就 `timestamps.add(0, 0)`,
|
||||
//! 且 `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;
|
||||
|
||||
/// 一个关键帧:`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;
|
||||
// 首点不在 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,92 @@
|
||||
//! **反馈类家族(桩)**:由后续 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`],但**动作件是个例外**——
|
||||
//! `action.rs` 的 `content_color()` 把 38% 内容色叠在它**自己的容器角色**上
|
||||
//! (`InverseSurface` / `SurfaceContainerHigh`),因为 `interaction` 那两档只认
|
||||
//! 「叠在 Surface / 12% 容器上」;
|
||||
//! 禁用是**状态**(`on_press: Option<M>` 为 `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-4 Snackbar 无阴影),
|
||||
//! 另有一条**观察项** D-5(深色档 scrim 只压暗 4%,见 `dialog.rs` 的 scrim 一节)。
|
||||
//!
|
||||
//! ## 逐项偏离登记(提交报告里同一份表)
|
||||
//!
|
||||
//! | 编号 | 参考 | 我们 | 理由 | 可修 |
|
||||
//! |---|---|---|---|---|
|
||||
//! | 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) |
|
||||
//! | 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」之后触发概率下降 | 是(改用精确色 + 容差,或自绘取景框) |
|
||||
//!
|
||||
//! ## 文件地图
|
||||
//!
|
||||
//! ```text
|
||||
//! geometry.rs G17 常量 + 五组几何(含不定态 keyframes)
|
||||
//! keyframes.rs 相位 → 进度 的纯函数求值器(组件不计时)
|
||||
//! progress.rs linear / circular(含环形 SVG 图元)
|
||||
//! snackbar.rs 提示条 + 贴顶/贴底
|
||||
//! dialog.rs 对话框 + scrim
|
||||
//! empty_state.rs 空态(项目自定:`[估]` 几何 + `[规格]` 排版 + 一条 `[源]` CTA)
|
||||
//! 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};
|
||||
@@ -0,0 +1,146 @@
|
||||
//! 三件「面」的几何:Snackbar / Dialog / EmptyState(数值与出处见 [`super::geometry`] 的 G17 表)。
|
||||
//!
|
||||
//! 为什么与进度分家:进度那两个是**带节奏的图形**(keyframes 与相位),这三件是**静态盒子**
|
||||
//! (宽高/内距/圆角)。合在一个文件里会顶到 400 行红线(AGENTS.md §3-1)。
|
||||
|
||||
use unlucky_design::component_map::Component;
|
||||
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 {
|
||||
/// 可用宽 → 实际渲染宽:`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;
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 空态图标边长(`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)]
|
||||
pub struct EmptyStateGeometry {
|
||||
/// 图标块边长(96)。
|
||||
pub block: f32,
|
||||
/// 图标块圆角(16,`ShapeScale::Large`)。
|
||||
pub block_radius: f32,
|
||||
/// 块内图标(**32**,`IconSize::Empty`;由 `icons::view_at` 按 32 烘焙,stroke 同步重算)。
|
||||
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: EMPTY_ICON,
|
||||
gap_title: ui.space(Space::X4),
|
||||
gap_body: ui.space(Space::X2),
|
||||
gap_action: ui.space(Space::X6),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
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<Self, String> {
|
||||
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::<Vec<_>>()
|
||||
.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::<f32>() / square;
|
||||
let distance = (0..3)
|
||||
.map(|i| (offset[i] - t * axis[i]).powi(2))
|
||||
.sum::<f32>()
|
||||
.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<usize> = 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<usize> = 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<BBox> {
|
||||
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<BBox> {
|
||||
let (target, base) = (rgb(ink), rgb(under));
|
||||
let mut found: Option<BBox> = 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::<u32>();
|
||||
let to_base = pixel
|
||||
.iter()
|
||||
.zip(base)
|
||||
.map(|(a, b)| u32::from(a.abs_diff(b)))
|
||||
.sum::<u32>();
|
||||
if to_ink < to_base {
|
||||
match &mut found {
|
||||
Some(bbox) => bbox.include(x, y),
|
||||
None => found = Some(BBox::new(x, y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! 反馈家族的**渲染层**几何断言:对着真截图量像素(M68 的教训:纯函数对、图可能是错的)。
|
||||
//!
|
||||
//! 为什么住在 `feedback/` 而不是 `gallery/`:`gallery/feedback.rs` 只负责把矩阵摆进 harness,
|
||||
//! 断言加上去会把它顶到 400 行红线。像素工具在 [`super::pixel`],环形与两个「面」的判据
|
||||
//! 拆在同级子模块 [`circular`] / [`panels`](`inputs/probe/` 的同一先例)。
|
||||
//!
|
||||
//! ## 量什么(每条对一个具体错误,打红它的是「变异」那一列)
|
||||
//!
|
||||
//! | 类 | 判据 | 打红它的变异 |
|
||||
//! |---|---|---|
|
||||
//! | 线性厚度 / 圆角 | 轨道带**恰好 4 行**;条的四角**透出底色** | 厚度 4→6 / `Full`→`None` |
|
||||
//! | 线性不定态 | 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**、顶缩 **≈26** | 480→488 / pad 24→16 |
|
||||
//! | 空态块 / 间距 / CTA | 块 **96×96** · 块↔标题 **≈16**+bearing · 条目总高 **≈232** · CTA **40** | 96→88 / spacing 双算回来 |
|
||||
//! | scrim | 一块 ≥700 宽的遮罩带,颜色 = 预合成实色 | 遮罩改成 surface |
|
||||
//!
|
||||
//! ## 复现(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
|
||||
//! ```
|
||||
|
||||
mod circular;
|
||||
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};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
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() {
|
||||
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_linear(&shot, &ui);
|
||||
circular::check(&shot, &ui);
|
||||
}
|
||||
"snackbar" => check_snackbar(&shot, &ui),
|
||||
"dialog" => panels::check_dialog(&shot, &ui),
|
||||
"empty" => panels::check_empty(&shot, &ui),
|
||||
other => {
|
||||
// 整屏时只查最不容易被折叠线切掉的那一类;分图才是逐项判据的场合。
|
||||
check_linear(&shot, &ui);
|
||||
eprintln!("[feedback-probe] part={other:?} 只查了 progress 那一类;其余请在分图里查");
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[feedback-probe] OK flavor={flavor:?} part={part}");
|
||||
}
|
||||
|
||||
/// §12.1 + §12.4:线性厚度 / 圆角 / **不定态两段**。
|
||||
fn check_linear(shot: &Shot, ui: &Ui) {
|
||||
// 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后
|
||||
// 「实测 6 == 期望 6」会照样绿,等于没有断言。
|
||||
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.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(|(start, len)| {
|
||||
*start >= TRACK3_X0 && start + len <= TRACK3_X0 + TRACK3_W + 2 && *len >= 8
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
found.len(),
|
||||
2,
|
||||
"12.4:phase 0.55 必须是**两段**(P1-A 修完才成立),实测 {found:?} @ 行 {row}"
|
||||
);
|
||||
|
||||
// 期望位置与单测**共用同一个常量**(`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;
|
||||
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:轨道带 {} 条(高 {:?})· phase 0.55 两段 {found:?}",
|
||||
bar_bands.len(),
|
||||
bar_bands
|
||||
.iter()
|
||||
.map(|(top, bottom)| bottom - top + 1)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 线性不定态那一行的 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。
|
||||
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()
|
||||
);
|
||||
|
||||
// 宽度集 = 每条带里最宽的那一行(留白行没有文字,量得到全宽)。
|
||||
let mut widths: Vec<usize> = found
|
||||
.iter()
|
||||
.map(|band| band_width(shot, *band, bar, surface))
|
||||
.collect();
|
||||
let mut heights: Vec<usize> = 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:?}"
|
||||
);
|
||||
assert!(
|
||||
widths.iter().any(|w| near(*w, 344)),
|
||||
"宿区 368 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}"
|
||||
);
|
||||
assert!(
|
||||
widths.iter().any(|w| near(*w, 300)),
|
||||
"宿区 324 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}"
|
||||
);
|
||||
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)"
|
||||
);
|
||||
|
||||
// ---- 位置语义(复审 D):两条 bottom 条的**条底距取景框底 = 12** ---------------
|
||||
//
|
||||
// 取景框是 gallery 给窄宿主画的 1px `OutlineVariant` 边(`framed_host`):透明宿主没有
|
||||
// 可见底边时「贴底」在像素上不可证(旧宿主 72 = 48 + 2×12,上下留白相等)。
|
||||
let frame = rgb(ui.color(Role::OutlineVariant));
|
||||
let mut margins: Vec<usize> = Vec::new();
|
||||
for (top, bottom) in &found {
|
||||
let width = band_width(shot, (*top, *bottom), bar, surface);
|
||||
if !(near(width, 300) || near(width, 344)) {
|
||||
continue;
|
||||
}
|
||||
// 1px 描边常落在半像素上(两行各 ~50% 覆盖),精确色匹配抓不住 ⇒ 用「更靠近框色
|
||||
// 而不是画布」判。但条自身(深色)比画布**更接近**框色,所以还要一条:这一行的
|
||||
// 框色连续段必须**比条本身更宽**(框横贯整个宿主),否则会认成条自己(实测过:差值 1)。
|
||||
let distance = |pixel: [u8; 3], target: [u8; 3]| -> u32 {
|
||||
pixel
|
||||
.iter()
|
||||
.zip(target)
|
||||
.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| 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!(
|
||||
!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 · 底边距 {margins:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 环形进度的渲染层判据(`probe` 的子模块,`inputs/probe/` 的同一先例)。
|
||||
//!
|
||||
//! ## 判据:六个**固定窗**逐个测存在性与尺寸
|
||||
//!
|
||||
//! 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-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 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 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 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<BBox> = 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:固定窗 {RINGS} 个逐窗 [{ }](y {top}..{bottom})· \
|
||||
主色连通簇 {} 个(仅打印,判据是窗)",
|
||||
report.join(" / "),
|
||||
clusters.len()
|
||||
);
|
||||
let _ = dims(&clusters);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! 两个「面」的渲染层判据:Dialog(§7)与空态(§15)。`probe` 的子模块。
|
||||
//!
|
||||
//! ## Dialog 量什么
|
||||
//!
|
||||
//! - **逐卡宽度**:按列剖面把每张卡单独量出来(早先只量「最宽那张」,一宽一窄并排时会漏);
|
||||
//! 本项目 480,窄宿主那格 360(`Dialog::width(360)`),且都必须落在官方 280..560 内;
|
||||
//! - 内距:标题墨迹左缩 ≈24、顶缩 24+bearing(24..=30);
|
||||
//! - **动作色**:`Role::Primary` 的**精确色**命中数(复审 A:早先用「离谁更近」把标题/正文字形
|
||||
//! 也算成了 Primary —— 一张没有动作的卡也能"通过");并配两条反向证据:
|
||||
//! ①无动作的那张卡里精确 Primary 必须是 **0**;②有动作的那张卡下半区必须有字形行。
|
||||
//! - scrim:一块 ≥700 宽的遮罩带,色值 = 预合成实色。
|
||||
//!
|
||||
//! ## 空态量什么
|
||||
//!
|
||||
//! 图标块 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, matches, near, rgb, 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。
|
||||
pub(super) fn check_dialog(shot: &Shot, ui: &Ui) {
|
||||
let surface = ui.color(Role::Surface);
|
||||
let card = ui.layer(MaterialLayer::Overlay);
|
||||
let primary = rgb(ui.color(Role::Primary));
|
||||
|
||||
// ---- 7.4 宽度:先分「卡带」(同排两张卡的 y 区间会重叠,所以只能按行分组),
|
||||
// 再在每条带里按**列剖面**分出每一张卡(复审 A/G:早先只量最宽的那张)。
|
||||
// 卡带用**精确色**数行(不用斜坡):深色档里正文/描边的抗锯齿像素都落在
|
||||
// 「surface → card」那条很短的斜坡上,用斜坡会把整屏都算成卡带(实测出现 1180 宽的假卡)。
|
||||
let card_rgb = rgb(card);
|
||||
let scrim_rgb = rgb(scrim(ui));
|
||||
let card_rows: Vec<usize> = (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<usize> = 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:?}"
|
||||
);
|
||||
|
||||
// 每张卡记 (左缘, 宽):宽度给「逐卡」断言,左缘给内距断言用(不能按单行找左缘——
|
||||
// 圆角 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<usize> = (0..shot.width)
|
||||
.map(|x| {
|
||||
(band.0..=band.1)
|
||||
.filter(|y| {
|
||||
let pixel = shot.at(x, *y);
|
||||
matches(pixel, card_rgb) && !matches(pixel, scrim_rgb)
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.collect();
|
||||
let mut start: Option<usize> = None;
|
||||
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 {
|
||||
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<usize> = cards.iter().map(|(_, width)| *width).collect();
|
||||
widths.sort_unstable();
|
||||
assert!(
|
||||
widths.iter().any(|w| near(*w, DIALOG_W)),
|
||||
"7.4:应有 {DIALOG_W} 宽的卡(G17-e 项目取值),实测逐卡宽度 {widths:?}"
|
||||
);
|
||||
assert!(
|
||||
widths.iter().any(|w| near(*w, DIALOG_NARROW)),
|
||||
"7.4:窄宿主那格应量到 {DIALOG_NARROW}(`Dialog::width(360)`,复审 E),实测 {widths:?}"
|
||||
);
|
||||
for width in &widths {
|
||||
assert!(
|
||||
(280..=560).contains(width),
|
||||
"7.4:每张卡都必须落在官方 280..560 内,实测 {width}:{widths:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// 内距与 scrim 只在一张卡上量(第一张,最靠左上)。
|
||||
let first = card_bands[0];
|
||||
let (card_left, width) = cards[0];
|
||||
let top = first.0;
|
||||
let bottom = first.1;
|
||||
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..=30).contains(&top_inset),
|
||||
"7.3:上内距 24 ⇒ 标题墨迹顶缩 24+字形 bearing(24..30),实测 {top_inset}"
|
||||
);
|
||||
|
||||
// ---- 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!(
|
||||
!scrim_bands.is_empty(),
|
||||
"7.7:没找到 ≥700 宽的遮罩带(宿主 800 宽那格)"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"[feedback-probe] dialog:逐卡宽度 {widths:?} · 标题左缩 {inset} · 顶缩 {top_inset} · \
|
||||
精确 Primary {action_pixels}px(无动作卡 {} 张)· 动作行 {action_rows} 行 · scrim 带 {} 条",
|
||||
quiet.len(),
|
||||
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);
|
||||
|
||||
let found = bands(shot, block, surface, 40);
|
||||
let (top, bottom) = found
|
||||
.iter()
|
||||
.copied()
|
||||
.max_by_key(|(top, bottom)| bottom - top)
|
||||
.filter(|(top, bottom)| (BLOCK - 2..=BLOCK + 2).contains(&(bottom - top + 1)))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"15.1:没找到高 {BLOCK}(±2) 的图标块(`[估]` 项目自定值),实测量到的带:{found:?}"
|
||||
)
|
||||
});
|
||||
|
||||
let width = band_width(shot, (top, bottom), block, surface);
|
||||
assert!(
|
||||
(BLOCK - 2..=BLOCK + 2).contains(&width),
|
||||
"15.1:图标块必须 {BLOCK}(±2,1px 描边)宽,实测 {width}"
|
||||
);
|
||||
|
||||
// 图标本身:`view_at` 按 32 烘焙(stroke 同步重算),墨迹 ≈26~28。
|
||||
let glyph = ink_inside(
|
||||
shot,
|
||||
&BBox {
|
||||
left: 0,
|
||||
right: shot.width - 1,
|
||||
top,
|
||||
bottom,
|
||||
},
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
block,
|
||||
2,
|
||||
)
|
||||
.expect("块里必须有图标墨迹");
|
||||
assert!(
|
||||
(22..=34).contains(&glyph.width()) && (22..=34).contains(&glyph.height()),
|
||||
"15.1:图标墨迹应在 22..34(32 盒;`view` 时代只有 20×20),实测 {}×{}",
|
||||
glyph.width(),
|
||||
glyph.height()
|
||||
);
|
||||
|
||||
// ---- 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 双算回来了)"
|
||||
);
|
||||
|
||||
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 {cta_top}..{cta_bottom}",
|
||||
bottom - top + 1,
|
||||
glyph.width(),
|
||||
glyph.height()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! 进度指示器(`research/12b` §12):`linear` 与 `circular`,各有确定态与不定态。
|
||||
//!
|
||||
//! ## 两种形态、四种态
|
||||
//!
|
||||
//! | 形态 | 确定态 | 不定态 |
|
||||
//! |---|---|---|
|
||||
//! | [`linear`] | 轨道 `SecondaryContainer` + 指示条 `Primary`,厚 **4**、圆角 full | 官方 1750ms **双段**追逐([`phase_segments`]) |
|
||||
//! | [`circular`] | 40 环 + 4 线宽,弧长 = 数值 × 360° | 官方 6000ms(弧长 0.1↔0.87 + 旋转 **0→1440°**:全局 1080° 线性 + 关键帧 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 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` 上——**不需要知道轨道像素宽**,
|
||||
/// 所以 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));
|
||||
for (is_bar, share) in portion_shares(&layout_spans(&progress.spans())) {
|
||||
if share == 0 {
|
||||
continue;
|
||||
}
|
||||
bar = bar.push(if is_bar {
|
||||
container(Space::new().width(Length::Fill).height(Length::Fill))
|
||||
.width(Length::FillPortion(share))
|
||||
.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()
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
spacer(share)
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
// 圈数直接乘 360(不做 rem_euclid):每周期 4 圈是官方口径,截断就丢转速。
|
||||
let (sweep_fraction, turns) = geometry.indeterminate(phase);
|
||||
(sweep_fraction * 360.0, turns * 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>(share: u16) -> Element<'a, M> {
|
||||
Space::new()
|
||||
.width(Length::FillPortion(share))
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一层几何图元(固定尺寸 + `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!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{size}\" height=\"{size}\" \
|
||||
viewBox=\"0 0 {size} {size}\"><circle cx=\"{center}\" cy=\"{center}\" r=\"{radius}\" \
|
||||
fill=\"none\" stroke=\"currentColor\" stroke-width=\"{thickness}\"/></svg>"
|
||||
)
|
||||
}
|
||||
|
||||
/// 一段圆弧(`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!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{size}\" height=\"{size}\" \
|
||||
viewBox=\"0 0 {size} {size}\"><path d=\"M {x0:.3} {y0:.3} A {radius} {radius} 0 \
|
||||
{large} 1 {x1:.3} {y1:.3}\" fill=\"none\" stroke=\"currentColor\" \
|
||||
stroke-width=\"{thickness}\" stroke-linecap=\"round\"/></svg>"
|
||||
)
|
||||
}
|
||||
|
||||
/// 极角(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())
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! 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-4**
|
||||
//! (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<M>,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! 纯函数级断言:几何对令牌、节奏对 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::typography::Platform;
|
||||
|
||||
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};
|
||||
|
||||
mod progress;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
pub(super) fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
// ---- §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,
|
||||
"自选宽必须落在官方区间内"
|
||||
);
|
||||
}
|
||||
|
||||
/// 复审 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() {
|
||||
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");
|
||||
// 复审 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)。
|
||||
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,
|
||||
"块底色是 SurfaceContainerHigh(材质层 Overlay)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 结构 / 角色 ------------------------------------------------------------
|
||||
|
||||
/// 五个入口都能在**任意**消息类型下构建(契约 §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);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! 进度两式的纯函数断言(`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 unlucky_design::shape::ShapeScale;
|
||||
|
||||
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;
|
||||
|
||||
// ---- §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;
|
||||
// 期望值来自 `geometry::PHASE_055`——与渲染层探针**共用**(复审 B:早先两边差 0.011)
|
||||
let [(a0, a1), (b0, b1)] = PHASE_055;
|
||||
assert!(
|
||||
(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();
|
||||
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}");
|
||||
// P1-B:每周期 4 圈(1080° 全局 + 360° 关键帧),不是 1 圈
|
||||
assert!(
|
||||
(rotation - 4.0).abs() < 0.01,
|
||||
"旋转应走满 4 圈,实测 {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°)"
|
||||
);
|
||||
// 给报告用的一行实测数值(`--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 等价)。
|
||||
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,给首值");
|
||||
}
|
||||
@@ -1,22 +1,365 @@
|
||||
//! 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;
|
||||
/// 窄宿主:给 `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;
|
||||
|
||||
/// 反馈类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column"
|
||||
)]
|
||||
.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()
|
||||
}
|
||||
|
||||
/// 带 1px 取景框的宿主:`Snackbar` 的贴底/贴顶需要一条**可见的宿主边界**才量得出来
|
||||
/// (描边画在盒内但**不参与布局**:内容区仍是整盒宽,实测条宽不受影响)。
|
||||
/// (透明宿主的底边在图上不存在,`Placement::Bottom` 就不可证)。
|
||||
fn framed_host<'a>(
|
||||
ui: &'a Ui,
|
||||
width: f32,
|
||||
height: f32,
|
||||
content: impl Into<Element<'a, DemoMessage>>,
|
||||
) -> 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,
|
||||
height: Option<f32>,
|
||||
content: impl Into<Element<'a, DemoMessage>>,
|
||||
) -> 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>>,
|
||||
) -> 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 = framed_host(
|
||||
ui,
|
||||
HOST_MIN,
|
||||
HOST_SHORT,
|
||||
feedback::snackbar(
|
||||
ui,
|
||||
Snackbar::new("已删除实例")
|
||||
.placement(Placement::Bottom)
|
||||
.action("撤销", press("snackbar/undo")),
|
||||
),
|
||||
);
|
||||
let narrow = framed_host(
|
||||
ui,
|
||||
HOST_NARROW,
|
||||
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);贴底 = 条底距框底 12"
|
||||
),
|
||||
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);
|
||||
|
||||
// 页面把自己的**可用宽**递给对话框(组件取 `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 个。")
|
||||
.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,
|
||||
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(**本项目不画阴影,见 D-1**)· pad 24(标题下 16 / 正文下 24)· 动作 Primary 间距 8",
|
||||
)]
|
||||
.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(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()
|
||||
}
|
||||
Reference in new issue
Block a user