//! 形状 / 高程 / 动效之外的「尺子」: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 栅格, //! 所以这层**不用改口径**,只是把值搬进唯一真源。 /// 栅格基数(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, /// 展开态导航宽度。 pub nav_expanded_width: f32, /// 收起态导航宽度。 pub nav_collapsed_width: 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, nav_expanded_width: 88.0, nav_collapsed_width: 48.0, 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 惯例值,单列一条断言钉住,不让它静默漂移。 for (name, value) in [ ("titlebar_height", m.titlebar_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" ); } #[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); } }