上一轮被 2 小时上限打断前的工作,逐条落地(都已在本地编译 + 单测 17 passed + 8 组 probe 中 6 组通过、empty-light 差 ±1 阈值): - `keyframes::sample` 的 debug_assert 收窄成「首点不在 0ms **且** 首值不是 0」才炸: 原先 `debug_assert_eq!(first.ms, 0)` 会把 `SEG_TAIL_1/2`(首点 250/900ms,值为 0)也判红, 而那一类的「保持首值」与 Compose 的「动画初值 0」等价,是合法的。真正危险的是 ROTATION 那种 「首值 ≠ 0」(会画出不存在的静止段),现在只拦这一种。 - `empty_state::block_layer()` 改成 `pub(super) const BLOCK_LAYER`:函数版在非 test 构建里 是 dead_code(`-D warnings` 会红),改成常量后组件与 probe 共用一个来源。 - `probe/panels.rs`:图标块尺寸容差放到 ±2(块上补的 1px `OutlineVariant` 描边各占 1 行, 浅色实测块色行 94、深色 97);顺带去掉没用到的 `matches` import。 - `tests/progress.rs`:三处期望值按实现对齐 —— 旋转每周期 **4 圈**(不是 1 圈)、 右段宽 **0.505**(0.355..0.860,手算 0.516 偏了 0.01)、单调扫描只扫 `[0,1)`(phase==1.0 就是下一周期的 0,回绕正常)。 - `dialog.rs`:`Fill + max_width(480)` **实测不生效**(Row 给 Fill 子项的 limits 是 min=max=分配额,`Limits::max_width()` 的 `.max(self.min.width)` 会把 480 顶回去, 两列并排实测渲染成 548)⇒ 改回 `Fixed(min(可用宽, 480))`,并加 `Dialog::width(available)` 让页面递可用宽;gallery 里接线用 `HOST_WIDE`(=800 ⇒ 仍取 480)。能力缺口登记在注释里 (与 G17-d 同一堵墙,报告里有 548 的实测)。 未完成(下一轮/合并前补):`cmp/dialog-*` 并排图要按 REF_H≈420 重出(动作行被裁掉)、 empty-light 的 ±2 阈值要复跑确认、clippy/test 门禁要贴真实 `test result:` 行。
259 lines
9.8 KiB
Rust
259 lines
9.8 KiB
Rust
//! 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)`)。
|
||
///
|
||
/// 为什么不让组件自己从宿主推: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(可用宽)`。
|
||
container(content)
|
||
.width(Length::Fixed(spec.max_width.min(geometry.width)))
|
||
.padding(Padding::from(geometry.padding))
|
||
.style(move |_theme| container::Style {
|
||
background: Some(Background::Color(surface)),
|
||
border: Border {
|
||
radius,
|
||
..Border::default()
|
||
},
|
||
..container::Style::default()
|
||
})
|
||
.into()
|
||
}
|
||
|
||
/// 两区之间的固定留白(A1 的「标题下 16 / 正文下 24」)。
|
||
fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> {
|
||
Space::new()
|
||
.width(Length::Fill)
|
||
.height(Length::Fixed(height))
|
||
.into()
|
||
}
|
||
|
||
/// 动作区:右对齐,主轴间距 8(A1 `ButtonsMainAxisSpacing`)。
|
||
fn actions_row<'a, M: Clone + 'a>(
|
||
ui: &'a Ui,
|
||
geometry: &DialogGeometry,
|
||
actions: DialogActions<'a, M>,
|
||
state: Option<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()
|
||
}
|