Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/empty_state.rs
T
AskaEth c723d1faff fix(iced/feedback): 空态间距双算、图标块对比度、Dialog 宽度与动作禁用底色(P2-1/2/4)
- P2-1 空态间距双算:`Column::spacing(8)` 与显式 `gap(16/24)` 叠加 ⇒ 实际渲染 32/40、
  条目总高 264,而登记的是 16/8/24、232。现在 `spacing(0.0)`,间距只有一个来源(`gap()`);
  断言从 `gap > 0.0` 改成**精确等于令牌档位** 16/8/24。
- P2-2 空态图标块几乎没有边界:`SurfaceContainerLow` 与画布 ΔRGB 只有 11/5/0(≈1%),
  浅色下看不出块。换 `SurfaceContainerHigh`(材质层 Overlay)+ 1px `OutlineVariant` 描边,
  并用 `block_layer()` 让 probe 与组件取同一个层(不再各写一份角色)。
- P2-1 Dialog 恒 480 宽:`Length::Fixed(480)` 在窄宿主会溢出,`.max_width(560)` 对 Fixed 是
  空操作。改成 `Fill + max_width(480)` = `min(宿主, 480)`(iced 无 min-width,见 G17-d)。
- P2-4 动作禁用底色取错:`interaction::disabled_content_over_role(..., false)` 把 38% 内容色
  固定叠在 **Surface** 上,而 Snackbar 的底是 `InverseSurface`、Dialog 是 `SurfaceContainerHigh`
  ⇒ 真出现禁用态会是「38% 深字叠深底」。改为 `content_color()` 按真实容器角色合成(同令牌 α)。
- probe:Dialog 上内距判据 `(24..=40)` 收紧到 `(24..=30)`;空态新增「块↔标题 ≈16」与
  「条目总高 ≈232」两条渲染层断言。
2026-11-02 15:56:44 +08:00

153 lines
6.2 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.
//! 空态 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:图标必须落在固定尺寸的盒子里,否则 fill 约束会让 usvg 按 Contain 撑满 96 块。
let glyph = layout::centered_fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(spec.icon, ui.color(Role::OnSurfaceVariant)),
);
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 与测试都从这里取,避免「组件一个色、断言另一个色」。
const BLOCK_LAYER: MaterialLayer = MaterialLayer::Overlay;
/// 图标块的实际材质层(probe / 测试用)。
pub fn block_layer() -> MaterialLayer {
BLOCK_LAYER
}
/// 固定高度的透明留白(列间距的唯一来源:`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()
}