Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/empty_state.rs
T
AskaEth 5d7426b6c9 fix(iced/feedback): 空态图标改用 M86 的 view_at 渲染 32(复审 C 修正版)
tower 修正指令:M86(e377989)把 `icons::view_at(kind, ink, px)` 并进 main 了,所以 C 项
从「登记值改成 24」改为「登记值保持 32 + 用 view_at 真渲 32」:

- `empty_state.rs`:`icons::view(..)` → `icons::view_at(.., geometry.icon)`,盒仍 96、
  图标 32;`view_at` 按 32 **重新烘焙 stroke**,视觉线宽仍是 1.5px(不会把描边拉粗)。
  早先 `view` 把 svg 钉成 `Fixed(24)`,32 的盒子只居中不拉伸 —— 实渲 24、墨迹 20×20。
- `panel.rs`:`EMPTY_ICON = 32.0`(`IconSize::Empty`),删掉 G18-h 欠账说明(已消解)。
- `probe/panels.rs`:图标墨迹判据 16..26 → **22..34**(32 盒)。
- `tests.rs`:`geometry.icon == 32` 保留,并写明它守的是「登记值 = 交给 view_at 的 px」,
  抓不住「盒子 32 而 svg 仍钉 24」那类(渲染层由 probe 的墨迹断言兜)。
- 顺带清掉 `panels.rs` 里两个未用 import(`-D warnings` 会红)。

已 `git rebase main`(1f4bc1b,含 M86),无冲突。
2026-11-02 15:56:47 +08:00

150 lines
6.4 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(两层):①`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()
}