Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/dialog.rs
T
AskaEth 10d4f86fdf fix(iced/feedback): 圆角/描边带来的 probe 阈值订正 + Dialog 宽度逃生口接线
上一轮被 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:` 行。
2026-11-02 15:56:45 +08:00

259 lines
9.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()
}