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

+66 -18
View File
@@ -132,38 +132,81 @@ fn pane_thresholds_are_the_sum_of_their_columns() {
assert!(WidthClass::of(supporting_pane_min_window()) == WidthClass::Medium);
}
/// 行宽上限:M68 的事故(1180 宽正文 712px ≈ 118 字符)不许再出现。
/// 实测样本:**实例页详情那段正文**(`pages/instances/data.rs` 的 `NOTE`)。
///
/// 口径(`layout` 模块文档 §3):Segoe UI Regular @14px、无 letter-spacing、
/// FreeType `getlength` 求整段排版宽。**这是外部实测数据,不是从常量推出来的**——
/// 下面的断言拿它反算「一行能放几个字符」,所以把 `CHAR_ADVANCE_EM` 改回估算值会立刻红。
const MEASURE_SAMPLE_CHARS: f32 = 361.0;
const MEASURE_SAMPLE_WIDTH_PX: f32 = 2185.0;
/// 样本的实测平均步进(px/字符)。
fn measured_advance_px() -> f32 {
MEASURE_SAMPLE_WIDTH_PX / MEASURE_SAMPLE_CHARS
}
/// 给定列宽,按**实测步进**能放多少字符。
fn chars_per_line(width: f32) -> f32 {
width / measured_advance_px()
}
/// 行宽上限必须真的停在 72 字符:名字、常量、实测三者一致。
#[test]
fn line_length_is_capped_at_72_characters() {
let char_px = TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM;
assert_eq!(measure_cap_px(), 504.0, "14 × 0.5 × 72");
assert_eq!(measure_floor_px(), 315.0, "14 × 0.5 × 45");
// ① 常量对得上实测步进(14px 下的平均步进)
let body_size = TypeRole::BodyMedium.style().size_px;
assert_eq!(body_size, 14.0);
assert!(
(measured_advance_px() - body_size * CHAR_ADVANCE_EM).abs() < 0.02,
"CHAR_ADVANCE_EM = {CHAR_ADVANCE_EM} 与实测步进 {:.4}px/字符对不上",
measured_advance_px()
);
// ② 上限是对着实测步进算出来的 435(floor),下限 272
assert_eq!(measure_cap_px(), 435.0, "floor(14 × 0.43233 × 72)");
assert_eq!(measure_floor_px(), 272.0, "floor(14 × 0.43233 × 45)");
// ③ **本条是 tower 要的断言**:按实测步进,上限列宽下一行放得下 ≤72 字符
let chars_at_cap = chars_per_line(measure_cap_px());
assert!(
chars_at_cap <= MAX_MEASURE_CHARS,
"上限 {:?}px 按实测步进能放 {chars_at_cap:.1} 字符,超过 {MAX_MEASURE_CHARS}",
measure_cap_px()
);
assert!(
chars_at_cap > MAX_MEASURE_CHARS - 1.0,
"上限太紧,白丢一行字符"
);
// ④ 回归护栏:旧的 0.5em 口径(504px)在同一实测步进下是 83 字符 —— 这条专拦「改回去」
assert!(
chars_per_line(504.0) > MAX_MEASURE_CHARS + 10.0,
"504px 在实测步进下应能放 >82 字符(旧口径的破绽),现在算出来 {:.1}",
chars_per_line(504.0)
);
for width in [600.0, 840.0, 900.0, 1180.0, 1440.0, 2560.0] {
let column = content_column_width(width);
let chars = column / char_px;
assert!(
chars <= MAX_MEASURE_CHARS + 0.001,
"{width} 宽窗口的正文行宽 {chars:.1} 字符,超过 {MAX_MEASURE_CHARS} 上限"
chars_per_line(column) <= MAX_MEASURE_CHARS,
"{width} 宽窗口的正文行宽 {:.1} 字符,超过 {MAX_MEASURE_CHARS} 上限",
chars_per_line(column)
);
assert!(column <= measure_cap_px() + f32::EPSILON);
}
// 1180 —— M68 就是在这条上翻的车:当年 712px(118 字符),现在必须是 504(72 字符)
// 1180 —— M68 就是在这条上翻的车(712px),现在必须是 435
let wide = content_column_width(1180.0);
assert!(
wide <= 72.0 * char_px,
"1180 宽下正文列 {wide}px 超过 72 字符换算值"
);
assert_eq!(wide, measure_cap_px());
assert!(wide < 712.0, "不许回到 M68 的全宽单卡(712px)");
}
#[test]
fn line_length_tracks_width_until_it_hits_the_cap() {
// 窄窗:正文列跟着可用宽度走,但永远不低于「下限以下就不该再压缩」
let narrow = content_column_width(600.0);
assert!(narrow < measure_cap_px(), "600 宽时正文列应窄于上限");
assert_eq!(narrow, available_width(600.0));
// 窄窗:正文列跟着可用宽度走(480 宽还在上限以内),撞上上限就锁死
let narrow = content_column_width(480.0);
assert!(narrow < measure_cap_px(), "480 宽时正文列应窄于上限");
assert_eq!(narrow, available_width(480.0));
// 单调不减:窗口变宽,正文列不变窄
let mut previous = 0.0;
@@ -236,6 +279,7 @@ fn settings_pane_keeps_the_drawer_width_and_a_readable_form_column() {
assert_eq!(metrics.secondary, Some(SIDE_NAV_WIDTH));
assert_eq!(SIDE_NAV_WIDTH, 240.0, "D33:展开态是 240 的 drawer");
assert_eq!(metrics.column, measure_cap_px(), "表单列吃满行宽上限");
assert_eq!(measure_cap_px(), 435.0);
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
@@ -244,9 +288,13 @@ fn settings_pane_keeps_the_drawer_width_and_a_readable_form_column() {
#[test]
fn meta_grid_drops_columns_as_the_detail_narrows() {
assert_eq!(meta_columns(measure_cap_px()), 3, "504 = 3×160 + 2×12");
assert_eq!(
meta_columns(measure_cap_px()),
3,
"432 = 3×136 + 2×12 ≤ 435"
);
assert_eq!(meta_columns(340.0), 2);
assert_eq!(meta_columns(300.0), 1);
assert_eq!(meta_columns(280.0), 1);
// 与三张对照图的详情列宽对应:1180 → 3 列,900 → 2 列
let wide = frame_metrics(PaneKit::ListDetail, 1180.0);