Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/dialog.rs
T
AskaEth d4e73aacdd fix(iced/feedback): 逐环改固定窗判据 + 贴顶边距 + Dialog 下限单测(复审 r3 P2-3/P2-1/P2-2)
D45 封顶轮的最后一改,只动这三条:

- **P2-3 逐环断言(假绿)**:环本来就在固定 x 栅格(24 + 56·i、宽 40),判据从「主色连通簇数
  非空」改成**六个窗逐个测**「窗内墨迹(轨道 ∪ 激活并集)外接成 40(±2)×40(±2)、且不越窗」,
  外加「1..5 号窗必须有激活色弧」。簇数只打印不再当判据。G18-i 从「欠账」改成**已收口**,
  并删掉模块文档里「要么把 6 个环摆成固定 x 栅格」那句错话(它们本来就在栅格上)。
  环带定位也换过两版(「每行墨点 4..20」会把 40 切成 32;「首行..末行」在深色档扩到 274 行),
  最终用「第一个窗里连续有墨、高度≈40 的行组」。
  变异:涂掉 4 个环 → `第 1 个窗里没有任何环墨迹:环丢了` 红;25% 环缩成 20×20 →
  `第 1 个窗里的环应 40×40(±2),实测 20×20` 红。
- **P2-1 贴顶边距**:新增「框顶行 → 条顶行 = 36±1」与「取景框高 = 96±1」两条。
  变异(`HOST_SHORT` 96 → 72,重编重拍):`D:贴底 ⇒ 框顶到条顶应为 36(96 − 48 − 12),实测 12` 红;
  还原后绿。—— 之前只量「条底距框底」,宿高回退成 72 时确实抓不住。
  顺带修了取景框判据本身:条自身(深色)比画布更接近框色,会把条自己认成框(实测差值 1),
  现在要求「框色连续段比条本身更宽」。
- **P2-2 Dialog 下限**:补单测 `effective_width(200.0) == 280.0`(360/800 两点一并钉住),
  并把「不低于官方 280」写进 `Dialog::width` 的文档。
- 欠账登记(只写表、不改代码):G18-j(动作色用全图合计,单卡漂移可能被别卡盖住)、
  G18-k(再 4 处文档残留,不为凑数扩大 diff)、G18-l(取景框判据会把条的抗锯齿底行算成框,
  余量 0.0008,方向是误报;加了「框高 96」后触发概率下降)。
2026-11-02 15:56:48 +08:00

263 lines
10 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)`,且**不低于官方下限 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()
}