reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口: P2-1 冻结测试补全(变异实测证明原来可静默漂移): - tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family) 与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写 - tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、 16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯 - 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED P2-2 emphasized 口径事实错误(用户点名): - 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组): label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、 title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0 - TypeRole::emphasized() 改为官方表(typography/emphasized.rs); 原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方 - 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、 title-medium 0.2)与 TitleLarge 的 Brand 族归属 - crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap 弹簧改为两套 token 结构(用户追加令): - motion::spring(scheme × category × speed) 二维表 12 格: standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、 effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值 - Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量 中文字重覆盖(用户追加令): - FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给 Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记 component_map 高程按官方回调(P2 并入 finding): - FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时 按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言 P3 四条: - 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma), primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变 - femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门 - 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档 - 图标折算口径订正为「居中描边,视觉外扩 <1px」
199 lines
7.1 KiB
Rust
199 lines
7.1 KiB
Rust
//! 形状 / 高程 / 动效之外的「尺子」: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);
|
||
}
|
||
}
|