Files
Unlucky-Launcher/crates/design/src/spacing.rs
T
AskaEth 141e9b12c7 feat(design): M69 令牌补齐 —— 圆角分层 / 组件微度量 / 骨架值与一像素线
G13(D32 分层):新增 shape::ComponentRadius(白名单只有 Micro=2)、
MICRO_CONTROL_MAX_SIZE=24 与纯函数 radius_layer_for();微半径走
radius_px(size) -> Option<f32>,尺寸 >24dp 一律 None,靠签名而不是自觉拦。
ShapeScale 与 rank() 一个字未动,2 不进刻度。

G11:LayoutMetrics 加 search_bar_height=56 / nav_rail_width=88 /
nav_drawer_width=240 / divider_thickness=1;删除语义相反的
nav_expanded_width=88 / nav_collapsed_width=48(D33:展开 drawer 240 /
折叠 rail 88;值 88 的后继是新名字 nav_rail_width,48 无后继)。

G10:新增 component_map::ComponentMetrics(12 字段 + 4 个命名常量 +
indicator_width),入口 Component::metrics();框内几何与 LayoutMetrics 的
骨架值分工写进模块文档,骨架级尺寸在 CM 里留 0 不许重复。

G9:Component 末尾追加 Checkbox / Radio / Search / NavigationDrawer /
Divider(20 -> 25),同步 table.rs 槽位、ALL 与 name();ComponentSpec 新增
micro_radius(Some 时 shape 必须是占位 None,由构造器 + 测试钉死)。

G12:新增 spacing::stroke::HAIRLINE = 1.0(crate 根再导出为
unlucky_design::stroke),divider_thickness 直接引用它。

其余:lib.rs 的「OKLCh 近似」文案改回真实口径(真 HCT / ColorSpec2021);
component_map 单测拆到 tests.rs 以免 mod.rs 超 400 行;测试 145 -> 165。
2026-11-01 16:53:30 +08:00

291 lines
13 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.
//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格、骨架尺寸、一像素线。
//!
//! 源流有两条:`DevDocs/ops/slint-reference/crates/app/ui/theme/metrics.slint`(M52 定稿的骨架节奏),
//! 以及 `ui-integration-contract.md` §3「间距只允许 4/8/12/16/24/32」。M3 同样用 4dp 栅格,
//! 所以这层**不用改口径**,只是把值搬进唯一真源。
//!
//! ## 职责边界:`LayoutMetrics` vs `ComponentMetrics`(M69 / G10)
//!
//! | 结构 | 管什么 | 例子 |
//! |---|---|---|
//! | [`LayoutMetrics`] | **窗口骨架与布局节奏**:标题栏/页头/行的节奏、dock 宽度(导航、AI 面板)、命中区 | [`LayoutMetrics::nav_drawer_width`] / [`LayoutMetrics::search_bar_height`] / `touch_target` |
//! | `component_map::ComponentMetrics` | **单个控件内部几何**:外框尺寸、微圆角、描边/环线宽、状态层直径、内点、图标偏移 | checkbox 20×20 + 描边 2 + 状态层 Ø40 |
//! | [`stroke::HAIRLINE`] | **一像素线**的唯一真源:分隔线厚度、容器描边、聚焦指示 | [`LayoutMetrics::divider_thickness`] |
//!
//! 判据:一个值若随窗口/列宽变化、或决定一块面板占多宽,归 LM;若它只在某个控件自己的方框
//! 内起作用(画进去的那几个像素),归 CM。**同一个值不许两边各写一份**。
/// 一像素线(G12):全站 1px 线的**唯一真源**。
///
/// 为什么要单独开一个命名空间:`1.0` 在项目里同时承担三种语义——分隔线厚度、容器描边宽度、
/// 聚焦指示描边宽度。各自抄字面量就会漂成 1.0 / 1.5 / 2.0 混排(M66 的 G12 正是这么来的)。
/// 从这里取,语义和数值只有一个落点。
///
/// iced 侧怎么落地(适配层照此抄,别再自己定 `STROKE_PX`):
/// - **容器描边**:`Border { width: unlucky_design::stroke::HAIRLINE, .. }`(卡片/输入框/chip/菜单);
/// - **分隔线**:[`LayoutMetrics::divider_thickness`](数值就是 [`stroke::HAIRLINE`])——
/// 横向分隔线是容器高度、纵向是宽度,都取它;
/// - **聚焦环**:内描边与外描边**都用**它(M3 的聚焦指示是 1dp,做成 2dp 就是加粗);
/// - **不用于**:elevation 的 blur / offset(那是 [`crate::elevation`] 的量)、
/// 图标描边(图标走 SVG 的 viewBox 单位,见 [`crate::icons::stroke_width_for`])。
pub mod stroke {
/// 一像素线宽度(逻辑 px @100% DPI = 1dp;DividerTokens.Thickness)。
pub const HAIRLINE: f32 = 1.0;
}
/// 栅格基数(dp)。
pub const GRID: f32 = 4.0;
/// 间距档位(M3/M2 通用 4px 栅格)。禁止 6/10/14 这类非整倍数值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Space {
/// 4dp:图标与文字之间。
X1,
/// 8dp:控件内边距、行内元素间距。
X2,
/// 12dp:控件间距。
X3,
/// 16dp:区块内行距、卡片内边距下限。
X4,
/// 24dp:区块间距、页面内边距。
X6,
/// 32dp:页级大留白。
X8,
}
impl Space {
/// 间距值(dp)。
pub const fn dp(self) -> f32 {
match self {
Space::X1 => 4.0,
Space::X2 => 8.0,
Space::X3 => 12.0,
Space::X4 => 16.0,
Space::X6 => 24.0,
Space::X8 => 32.0,
}
}
/// 全部档位,由小到大。
pub const ALL: [Space; 6] = [
Space::X1,
Space::X2,
Space::X3,
Space::X4,
Space::X6,
Space::X8,
];
}
/// 把任意数值对齐到 4px 栅格(最近档)。
pub fn snap_to_grid(value: f32) -> f32 {
(value / GRID).round() * GRID
}
/// 骨架尺寸:窗口结构(标题栏、导航、页头、行高)的单一来源。
///
/// 这些值不是 M3 规范值(M3 只规定组件内部节奏),而是本启动器的**布局契约**,
/// 由页面/组件两条线共同消费——任何页面自己写 `56px` 都是重复定义。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutMetrics {
/// 标题栏高度。
pub titlebar_height: f32,
/// 标题栏按钮宽度(关闭/最大化/最小化)。
pub titlebar_button_width: f32,
/// 展开态导航(drawer 形态)宽度。
///
/// **G11 语义修正(M69 / D33)**:D16「左侧固定、允许折叠」落地为**同一个常驻容器**的两种
/// 宽度——展开 240(drawer)、折叠 88(rail)。旧字段 `nav_expanded_width = 88` 的名字与值
/// 相反(88 其实是 rail),已删除,**不留别名**:两个名字指同一个量等于把选择权还给组件层。
///
/// 官方 `NavigationDrawerTokens.ContainerWidth = 360`、`MinimumDrawerWidth = 240`;
/// 本项目取官方下限 240——360 + AI 面板 320 = 680 = 1180 窗宽的 58%,内容区只剩 500 放不下两列卡片。
pub nav_drawer_width: f32,
/// 折叠态导航(rail 形态)宽度。
///
/// 88 = 12b §5.1 对官方 rail 96 的登记偏离(−8):指示器宽 88 − 2×16 = 56 仍成立,
/// 再窄就装不下 56 宽的选中指示器。
pub nav_rail_width: f32,
/// 搜索条高度(G11)。
///
/// 官方 `SearchBarTokens.ContainerHeight = 56`。**不走 [`LayoutMetrics::control_height`](40)**:
/// 搜索条是页级入口而不是密集行控件;56 也顺带 ≥ 命中下限 48。圆角由 `ShapeScale::Full`
/// 按边长折算(56 → 28,正好等于官方 `SearchBarCornerRadius`)。
pub search_bar_height: f32,
/// 分隔线厚度(G11 + G12)。
///
/// 官方 `DividerTokens.Thickness = 1.0.dp`。值就是 [`stroke::HAIRLINE`]——**一像素线不受 4px
/// 栅格约束**,所以它不在栅格测试的表里(测试里显式豁免并写明原因)。
pub divider_thickness: f32,
/// 导航项高度。
pub nav_item_height: f32,
/// 导航项间距。
pub nav_item_gap: f32,
/// 单行页头高度。
pub page_header_height: f32,
/// 两行页头高度(日志页)。
pub page_header_tall_height: f32,
/// 运行中状态条高度。
pub running_strip_height: f32,
/// 列表行高(桌面密集列表;见模块文档的「触控目标」说明)。
pub row_height: f32,
/// 标准控件可见高度(按钮/输入框)。
pub control_height: f32,
/// 最小**命中**区域(M3 Expressive 的无障碍下限)。
pub touch_target: f32,
/// 卡片内边距。
pub card_padding: f32,
/// AI 面板宽度。
pub ai_panel_width: f32,
}
impl LayoutMetrics {
/// 桌面基线(1180×760 窗口)。
///
/// **触控目标的换算依据**(reviewer P3-1:不许和 `metrics.slint` 沉默地不一致):
/// - 规范:M3 Expressive 要求可点目标 ≥**48dp**(无障碍下限,手机与桌面同标准)。
/// - 我们取 `touch_target = 48`(照规范),`control_height = 40`(**可见**高度):
/// 两者差 8,正好由控件上下各 4px 的内边距补足,于是「看起来 40 高的按钮」有 48 的命中区。
/// - `row_height = 44` / `nav_item_height = 44` 是 M63 §R13 + §1.4 表定的桌面密集档
/// (`metrics.slint` 的导航 56 是 Fluent 2 时代的旧节奏,D25 已终止):可见高度 44,
/// 点击命中也按 `touch_target`(48)判定,不靠行高本身。
/// - `titlebar_height = 48` 取自 M63 §1.4 的 S2 面板表(旧值 40 是 Fluent 口径)。
pub const DESKTOP: Self = Self {
titlebar_height: 48.0,
titlebar_button_width: 46.0,
// G11 / D33:展开 = drawer 240,折叠 = rail 88(旧 `nav_expanded_width=88` /
// `nav_collapsed_width=48` 的名字与值相反,已删)。
nav_drawer_width: 240.0,
nav_rail_width: 88.0,
// G11:官方 SearchBarTokens.ContainerHeight = 56。
search_bar_height: 56.0,
// G11/G12:官方 DividerTokens.Thickness = 1.0dp → 一像素线的唯一真源。
divider_thickness: stroke::HAIRLINE,
nav_item_height: 44.0,
nav_item_gap: 4.0,
page_header_height: 56.0,
page_header_tall_height: 84.0,
running_strip_height: 32.0,
row_height: 44.0,
control_height: 40.0,
touch_target: 48.0,
card_padding: 20.0,
ai_panel_width: 320.0,
};
}
impl LayoutMetrics {
/// 页头是否两行(日志页),决定主区与 AI 面板分隔线对齐的高度。
pub fn header_height(&self, two_line: bool) -> f32 {
if two_line {
self.page_header_tall_height
} else {
self.page_header_height
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_space_is_on_the_grid() {
for space in Space::ALL {
assert_eq!(space.dp() % GRID, 0.0, "{space:?} 不在 4px 栅格上");
}
}
#[test]
fn layout_values_are_on_the_grid() {
let m = LayoutMetrics::DESKTOP;
// 受 4px 栅格约束的字段(间距/高度/宽度类)。
// `titlebar_button_width` 刻意不在表内:46 对齐 Windows 11 原生 caption 按钮宽度,
// 属 OS 惯例值,单列一条断言钉住,不让它静默漂移。
// `divider_thickness` 同理不在表内:它是**一像素线**(1 不是 4 的倍数),
// 归 `stroke::HAIRLINE` 那条规则管,下面单独断言。
for (name, value) in [
("titlebar_height", m.titlebar_height),
("nav_drawer_width", m.nav_drawer_width),
("nav_rail_width", m.nav_rail_width),
("search_bar_height", m.search_bar_height),
("nav_item_height", m.nav_item_height),
("nav_item_gap", m.nav_item_gap),
("page_header_height", m.page_header_height),
("page_header_tall_height", m.page_header_tall_height),
("running_strip_height", m.running_strip_height),
("row_height", m.row_height),
("control_height", m.control_height),
("touch_target", m.touch_target),
("card_padding", m.card_padding),
("ai_panel_width", m.ai_panel_width),
] {
assert_eq!(value % GRID, 0.0, "{name} = {value} 不在 4px 栅格上");
}
assert_eq!(
m.titlebar_button_width, 46.0,
"标题栏按钮宽度应对齐 Win11 原生 caption"
);
// 一像素线的显式豁免(不是「忘了加进表」,是它本来就不受栅格约束)。
assert_eq!(m.divider_thickness, stroke::HAIRLINE);
assert_ne!(m.divider_thickness % GRID, 0.0);
}
#[test]
fn nav_widths_follow_the_two_form_decision() {
// D33:左侧导航是同一容器的两种宽度,展开(drawer)必须比折叠(rail)宽。
let m = LayoutMetrics::DESKTOP;
assert_eq!(m.nav_drawer_width, 240.0, "展开 drawer 取官方下限 240");
assert_eq!(m.nav_rail_width, 88.0, "折叠 rail 为 88(登记偏离官方 96)");
assert!(m.nav_rail_width < m.nav_drawer_width);
// rail 里的选中指示器 = 容器宽 − 2×16;88 − 32 = 56,再窄就装不下 56 宽指示器。
assert_eq!(m.nav_rail_width - Space::X4.dp() * 2.0, 56.0);
// 内容区下限:1180 窗宽 − drawer − AI 面板 仍 ≥600(够放两列卡片)。
assert!(1180.0 - m.nav_drawer_width - m.ai_panel_width >= 600.0);
}
#[test]
fn search_bar_height_is_a_page_level_entry() {
let m = LayoutMetrics::DESKTOP;
assert_eq!(m.search_bar_height, 56.0, "官方 ContainerHeight = 56");
assert!(
m.search_bar_height >= m.touch_target,
"页级入口必须 ≥ 命中下限"
);
// 圆角走 ShapeScale::Full,56 → 28,正好是官方 SearchBarCornerRadius。
assert_eq!(
crate::shape::ShapeScale::Full.radius_px(m.search_bar_height),
28.0
);
}
#[test]
fn touch_target_meets_the_expressive_48dp_minimum() {
let m = LayoutMetrics::DESKTOP;
// M3 Expressive 无障碍下限:可点目标 ≥48dp。
assert_eq!(m.touch_target, 48.0);
// 可见控件高 + 上下各 4px 内边距 = 命中区。
assert_eq!(m.control_height + 2.0 * Space::X1.dp(), m.touch_target);
// 密集行(M63 §1.4/R13 的桌面档)可见高度 44,命中仍按 touch_target。
assert_eq!(m.row_height, 44.0);
assert_eq!(m.nav_item_height, 44.0);
assert!(m.row_height <= m.touch_target);
// M63 §1.4 的 S2 面板表:标题栏 48。
assert_eq!(m.titlebar_height, 48.0);
}
#[test]
fn snap_rounds_to_nearest_grid_step() {
assert_eq!(snap_to_grid(5.0), 4.0);
assert_eq!(snap_to_grid(6.5), 8.0);
assert_eq!(snap_to_grid(0.0), 0.0);
}
#[test]
fn header_height_follows_line_count() {
let m = LayoutMetrics::DESKTOP;
assert_eq!(m.header_height(false), 56.0);
assert_eq!(m.header_height(true), 84.0);
}
}