fix(app-iced): P2-1 「72 字符」改成实测口径(上限 504 → 435)

两位 reviewer 都量到:`CHAR_ADVANCE_EM = 0.5` 是估的,14 × 0.5 × 72 = 504px 实际能放 83 字符,
「72 字符」只活在名字里。改成实测:

- 口径:`BodyMedium` 14px / Segoe UI Regular / 无 letter-spacing(iced 0.14 不支持字距),
  样本 = 实例页详情那段正文(361 字符,大小写/标点/空格/`→` 混排),FreeType getlength
  实测 **2185.00px / 361 = 6.0526 px/字符 = 0.43233em**(旧 0.5 高估 15.7%);
  交叉验证:同段首行 78 字符渲染墨迹 484px,FreeType 对同一串 479px,差 1%(抗锯齿外扩)。
- `CHAR_ADVANCE_EM` 0.5 → **0.43233**;`measure_cap_px()` 加 **floor** → **435**
  (435.79 四舍五入成 436 就会让最后一两个字符挤不进 72 字符的承诺);下限 315 → 272。
- 版面连带重算:`META_CARD_MIN_WIDTH` 160 → **136**(3×136 + 2×12 = 432 ≤ 435,否则 1180 宽下
  元信息网格会从 3 列掉到 2 列)。**816 / 720 两个两列阈值不受影响**(它们由 LIST/DETAIL/FORM 宽度算,
  与行宽上限无关),单测里的绝对值断言原样成立。
- 单测:`line_length_is_capped_at_72_characters` 里新增 tower 要求的断言 —— 用**实测步进**
  (样本字符数 + 实测宽度,独立于常量)反算「上限列宽能放多少字符」≤ 72(实际 71.87),
  并加一条回归护栏:同一实测步进下旧口径 504px = 83.3 字符,改回 0.5em 立刻红。
- 文档:`layout.rs` §3 补「怎么重新量」(字体、样本、工具、交叉验证、CJK 不适用)与全部数字;
  `mod.rs` / 四页文档里的 ≤504 同步改 435。
This commit is contained in:
AskaEth committed 2026-11-02 00:13:20 +08:00
1 parent 2c442585b1
commit fae1b61d9e
7 files changed
+117 -43

No files matched your search

+43 -17
View File
@@ -38,13 +38,28 @@
//! ## 3. 行宽约束(历史事故:M68 的全宽单卡)
//!
//! M68 骨架的内容区是**一张全宽卡**:1180 窗宽 − 88 rail − 2×24 留白 = **712px**。
//! 按当前字体栈(`BodyMedium` 14px,西文平均推进 0.5em ≈ 7px/字符)算是 **≈118 字符/行**,
//! 远超舒适行宽。视觉复审的判词因此是「**不能把全宽单卡当页面模板**,官方桌面口径是
//! 那张卡里的正文是 `BodySmall` 12px,**实测步进 5.19px/字符 → 712px 装下了 137 字符**
//! (早期口径拿 14px 正文的步进换算,比实际渲染的字号少算了 16%,已按实测订正)。
//! 视觉复审的判词因此是「**不能把全宽单卡当页面模板**,官方桌面口径是
//! supporting-pane / list-detail」。
//!
//! 所以正文列宽有**硬上限** [`measure_cap_px`] = 14 × 0.5 × 72 = **504px ≈ 72 字符**,
//! 由 [`content_column_width`] 与 [`frame_metrics`] 无条件施加:任何窗口宽度、任何版面,
//! 正文列都不会超过它。1180 宽窗口下本 mission 的正文列是 504(不是 712)。
//! 所以正文列宽有**硬上限** [`measure_cap_px`]:按**实测**的 `BodyMedium` 14px 步进
//! 6.05px/字符(= 0.4323em)算出 **72 字符 = 435px**,由 [`content_column_width`] 与
//! [`frame_metrics`] 无条件施加:任何窗口宽度、任何版面,正文列都不会超过它。
//! 1180 宽窗口下正文列是 **435**(不是 712)。
//!
//! ### 步进是怎么量的(口径 + 可复现)
//!
//! - **测的是哪组字形**:`BodyMedium` 的字体栈(本机解析到 Segoe UI **Regular**,14px),
//! **无 letter-spacing**——令牌的 0.5px 字距 iced 0.14 不支持(已登记的令牌丢失),渲染里没有;
//! - **样本**:实例页详情那段正文(`pages/instances/data.rs` 的 `NOTE`,**361 字符**,
//! 大小写 / 标点 / 空格 / `→` 混排,是我们自己的真实正文,不是造出来的 `mmmm`);
//! - **量法**:FreeType `getlength`(PIL `ImageFont.truetype(segoeui.ttf, 14)`)求整段排版宽,
//! 再除以字符数 → **2185.00px / 361 = 6.0526 px/字符 = 0.43233em**;× 72 = 435.79 → **floor 435**;
//! - **交叉验证**:同一段的首行(78 字符)渲染实测墨迹 **484px**,FreeType 对同一串给 479px
//! (差 ~1%,抗锯齿外扩),说明「步进」这个口径在 iced 渲染器与 FreeType 之间是一致的;
//! - **CJK 不适用**:全角字形是 1em(14px),中文的 72 字符是 1008px。页面文案走 i18n 之后
//! 要按语言分别定上限,这条留给 i18n mission;本模块只保证英文基线。
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
@@ -58,7 +73,10 @@ pub const BREAKPOINT_EXPANDED: f32 = 840.0;
/// 落在区间中段,既放得下「名字 + 版本 + 尾随图标」,又给详情列多留 16px。
pub const LIST_COLUMN_WIDTH: f32 = 336.0;
/// 详情列下限:再窄就不再分栏(约 45 字符 × 7px ≈ 315,取 4px 栅格的 320)。
/// 详情列下限:再窄就不再分栏。
///
/// 按实测步进算:45 字符 × 6.05px ≈ 272,取 4px 栅格并留余量 → **320**
/// (320 / 6.05 = 53 字符,仍在行宽下限之上)。
pub const DETAIL_MIN_WIDTH: f32 = 320.0;
/// 侧列宽(设置页分组导航)= D33 的 drawer 宽 240。
@@ -73,15 +91,22 @@ pub const FORM_MIN_WIDTH: f32 = DETAIL_MIN_WIDTH;
/// 两列之间的间隙(`Space::X6` = 24)。
pub const PANES_GAP: f32 = Space::X6.dp();
/// 详情里元信息网格的**单卡最小宽度**(三列时 3×160 + 2×12 = 504,正好等于行宽上限)。
pub const META_CARD_MIN_WIDTH: f32 = 160.0;
/// 详情里元信息网格的**单卡最小宽度**:三列要塞进 435 的行宽上限 → 3×136 + 2×12 = **432** ≤ 435。
///
/// 这一个是跟着行宽上限走的:上限从 504 收到 435 之后,旧的 160 会让三列塞不下
/// (3×160 + 24 = 504 > 435),所以同步收窄到 136。四列(4×136 + 36 = 580)从来就没进过上限。
pub const META_CARD_MIN_WIDTH: f32 = 136.0;
/// 元信息网格卡片间距(`Space::X3` = 12)。
pub const META_GAP: f32 = Space::X3.dp();
/// 西文每字符的平均推进(em)。`[估]`:Segoe UI / Inter 混排在 0.45~0.55em 之间,取中值 0.5。
/// 正文平均字符步进(em)——**实测值**,口径见模块文档 §3。
///
/// 这是本文件唯一一个「按字体栈换算」的经验系数;换字体栈要重新量(不是令牌)。
pub const CHAR_ADVANCE_EM: f32 = 0.5;
/// `BodyMedium` 14px / Segoe UI Regular / 361 字符英文混排正文,FreeType 实测
/// **6.0526px/字符 = 0.43233em**。
///
/// 旧的 `0.5`(`[估]`)把 72 字符的列宽算成 504px,而实测 504px 能塞 **83 字符**——
/// 「72 字符」这个名字和数字对不上的根因就在这里。换字体栈要重新量(这不是令牌)。
pub const CHAR_ADVANCE_EM: f32 = 0.43233;
/// 舒适行宽的字符数上限(任务建议 60~72,取上界 72)。
pub const MAX_MEASURE_CHARS: f32 = 72.0;
/// 行宽下限的字符数(再窄正文就碎成短行,45 是最低可读长度)。
@@ -92,16 +117,17 @@ pub const fn rail_width() -> f32 {
LayoutMetrics::DESKTOP.nav_rail_width
}
/// 正文列宽上限(px):`BodyMedium` 14px × 0.5em × 72 字符 = **504**。
/// 正文列宽上限(px):`floor(14px × 0.43233em × 72 字符)` = **435**。
///
/// **向下取整**:算出来是 435.79,四舍五入成 436 就会让最后一两个字符挤不进「72 字符」的承诺。
/// 硬上限,任何版面 / 任何窗口宽度都不许超;`1180` 窗宽下正文列就是它(而非 M68 的 712)。
pub fn measure_cap_px() -> f32 {
TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MAX_MEASURE_CHARS
(TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MAX_MEASURE_CHARS).floor()
}
/// 正文列宽下限(px):14 × 0.5 × 45 = 315。
/// 正文列宽下限(px):`floor(14 × 0.43233 × 45)` = **272**。
pub fn measure_floor_px() -> f32 {
TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MIN_MEASURE_CHARS
(TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MIN_MEASURE_CHARS).floor()
}
/// 窗口宽度档位(官方 600 / 840 两个断点)。
@@ -230,12 +256,12 @@ pub fn available_width(window_width: f32) -> f32 {
/// - 单列页:就是正文列宽(水平居中摆放);
/// - 两列页:这是**主列**的正文列宽;实际值还要看 [`frame_metrics`](详情列会被列表列挤窄)。
///
/// 返回值永远 ≤ [`measure_cap_px`]:1180 窗宽下是 504(≈72 字符),不是 712(≈118 字符)。
/// 返回值永远 ≤ [`measure_cap_px`]:1180 窗宽下是 435(实测 72 字符),不是 M68 的 712。
pub fn content_column_width(window_width: f32) -> f32 {
available_width(window_width).min(measure_cap_px())
}
/// 元信息网格的列数:3 列需要 504(= 行宽上限),2 列需要 332,否则 1 列。
/// 元信息网格的列数:3 列需要 432(≤ 行宽上限 435),2 列需要 284,否则 1 列。
pub fn meta_columns(width: f32) -> usize {
for columns in [3usize, 2] {
let needed = columns as f32 * META_CARD_MIN_WIDTH + (columns - 1) as f32 * META_GAP;