fix(app-iced): 必修1 「72 字符」改成逐行口径,上限 435 → 424

代码 r2 的复核:`435 / 6.0526 = 71.9` 是**整段平均步进**换来的数,不是每行都 ≤72;
它实测交付图里就有 **73 字符**行(`guess. Nothing on this page is wired to real data yet: the layout is, the`,
ink 428px)。上一轮是系数错,这一轮是**把平均当成了逐行上界**。

选 **(a)**:不变式改成逐行可验证,cap 跟着压到能满足它的地方。依据是 FreeType 量宽 + 贪心词折行的模拟表
(同一段 361 字符正文扫列宽):

| 列宽 | 最长一行 | 该行 ink | 判定 |
|---|---|---|---|
| 435(= 平均口径上界) | 73 字符 | 428px | ✗ |
| 428 | 73 字符 | 428px | ✗ |
| 426 | 71 字符 | 426px | ✓ 只剩 1 字符余量 |
| **424**(4px 栅格) | **67 字符** | **402px** | ✓ 取这个 |

- `measure_cap_px()` = `min(逐行安全宽 424, 平均口径上界 435)` = **424**;平均口径另立
  `measure_average_bound_px()`(435)并在文档里标明「只是上界,不是取值」。
- 单测 `line_length_is_capped_at_72_characters` 改成**两侧夹逼**:`402 ≤ cap < 428` ——
  放大到 428 会重新出现 73 字符行(红),压到 402 以下白丢宽度(也红);另断言
  `cap < measure_average_bound_px()`,防止有人把平均口径当上限。
- §3 新增「逐行口径」小节:模拟表 + **作用域**(承诺只覆盖 14px 正文;12px 辅助文案是 1~2 行短句,
  不做逐行承诺;CJK 全角 1em 另行定)。
- 四页文档与 `pages/mod.rs` 的 ≤435 同步改 424。
This commit is contained in:
AskaEth committed 2026-11-02 02:04:23 +08:00
1 parent a650f8184b
commit ddf6937b2b
7 files changed
+93 -29

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
//! 下载页:**单列**版面的用例(列宽 ≤435,水平居中)。
//! 下载页:**单列**版面的用例(列宽 ≤424,水平居中)。
//!
//! 这一页永远单列,理由不是「简单」,而是官方对「一次只做一件事」的入口(下载队列)的口径:
//! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限),
+2 -2
View File
@@ -4,8 +4,8 @@
//!
//! | 版面 | 结构 | 退化行为 |
//! |---|---|---|
//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,正文列 ≤435 | — |
//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(≤435,居中) | 顶部给「Back to instances」,列表退成上一屏 |
//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,正文列 ≤424 | — |
//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(≤424,居中) | 顶部给「Back to instances」,列表退成上一屏 |
//!
//! 为什么窄窗不是「上下堆」:官方 compact 下 list-detail 的口径是一次只显示一个 pane(列表 → 详情,
//! 整页切换)。上下堆会把详情挤到折叠线以下,等于详情不存在。
+39 -9
View File
@@ -51,10 +51,28 @@
//! > 现在事故描述一律用**实测步进 × 实际字号**;这也是
//! > `line_length_is_capped_at_72_characters` 里那条「旧口径 504px 能放 83 字符」护栏的由来。
//!
//! 所以正文列宽有**硬上限** [`measure_cap_px`]:按**实测**的 `BodyMedium` 14px 步进
//! 6.05px/字符(= 0.4323em)算出 **72 字符 = 435px**,由 [`content_column_width`] 与
//! [`frame_metrics`] 无条件施加:任何窗口宽度、任何版面,正文列都不会超过它。
//! 1180 宽窗口下正文列是 **435**(不是 712)。
//! ### 逐行口径:72 字符是**最长一行**的上界,不是平均(r2 复审的教训)
//!
//! 「平均步进 × 72」只能保证**平均**一行 72 字符:r2 复审在交付图里抓到一行 **73 字符**
//! (`guess. Nothing on this page is wired to real data yet: the layout is, the`,ink 428px)。
//! 所以上限**不是算术结果,而是逐行折行模拟的结果**——用 FreeType 量宽 + 贪心词折行,
//! 对实例页那段正文(361 字符)扫不同列宽:
//!
//! | 列宽 | 最长一行 | 该行 ink | 结论 |
//! |---|---|---|---|
//! | 435(= 平均口径上界) | 73 字符 | 428px | ✗ 破了 72 |
//! | 428 | 73 字符 | 428px | ✗ |
//! | 426 | 71 字符 | 426px | ✓ 但只剩 1 字符余量 |
//! | **424**(4px 栅格) | **67 字符** | **402px** | ✓ 取这个:满足上界 + 留 5 字符余量 |
//!
//! 于是 [`measure_cap_px`] = **424**(= `min(逐行安全宽 424, 平均口径上界 435)`),由
//! [`content_column_width`] 与 [`frame_metrics`] 无条件施加:任何窗口宽度、任何版面,
//! 正文列都不会超过它。1180 宽窗口下正文列是 **424**(不是 712)。
//!
//! **作用域**:这条承诺只覆盖 `BodyMedium` 14px 的正文。`BodySmall` 12px 的辅助文案
//! (空态副文案、窄窗提示)在同一列宽下每行可到 ~82 字符——它们都是 1~2 行的短句,不做逐行承诺;
//! 中文(全角 1em)另算,等 i18n mission 定。
//!
//! ### 步进是怎么量的(口径 + 可复现)
//!
@@ -63,7 +81,7 @@
//! - **样本**:实例页详情那段正文(`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**;
//! 再除以字符数 → **2185.00px / 361 = 6.0526 px/字符 = 0.43233em**;× 72 = 435.79(这是**平均**口径上界,逐行口径见下);
//! - **交叉验证**:同一段的首行(78 字符)渲染实测墨迹 **484px**,FreeType 对同一串给 479px
//! (差 ~1%,抗锯齿外扩),说明「步进」这个口径在 iced 渲染器与 FreeType 之间是一致的;
//! - **CJK 不适用**:全角字形是 1em(14px),中文的 72 字符是 1008px。页面文案走 i18n 之后
@@ -133,12 +151,24 @@ pub const fn rail_width() -> f32 {
LayoutMetrics::DESKTOP.nav_rail_width
}
/// 正文列宽上限(px):`floor(14px × 0.43233em × 72 字符)` = **435**。
/// **逐行**安全宽度(px):折行模拟下「最长一行 ≤72 字符」的最大 4px 栅格列宽 = **424**。
///
/// 见模块文档 §3 的模拟表:428 会出现 73 字符行,426 起降到 71,424 给到 67(留 5 字符余量)。
/// 这个数是**量出来的**,不是 `advance × 72` 算出来的——后者只保证平均。
pub const LINE_SAFE_WIDTH_PX: f32 = 424.0;
/// 平均口径上界(px):`floor(14px × 0.43233em × 72)` = **435**。
///
/// 只能保证**平均**一行 72 字符(实测 435 下会冒出 73 字符行),所以它是上界而不是取值。
pub fn measure_average_bound_px() -> f32 {
(TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MAX_MEASURE_CHARS).floor()
}
/// 正文列宽上限(px)= `min(逐行安全宽 424, 平均口径上界 435)` = **424**。
///
/// **向下取整**:算出来是 435.79,四舍五入成 436 就会让最后一两个字符挤不进「72 字符」的承诺。
/// 硬上限,任何版面 / 任何窗口宽度都不许超;`1180` 窗宽下正文列就是它(而非 M68 的 712)。
pub fn measure_cap_px() -> f32 {
(TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MAX_MEASURE_CHARS).floor()
LINE_SAFE_WIDTH_PX.min(measure_average_bound_px())
}
/// 正文列宽下限(px):`floor(14 × 0.43233 × 45)` = **272**。
@@ -272,7 +302,7 @@ pub fn available_width(window_width: f32) -> f32 {
/// - 单列页:就是正文列宽(水平居中摆放);
/// - 两列页:这是**主列**的正文列宽;实际值还要看 [`frame_metrics`](详情列会被列表列挤窄)。
///
/// 返回值永远 ≤ [`measure_cap_px`]:1180 窗宽下是 435(实测 72 字符),不是 M68 的 712。
/// 返回值永远 ≤ [`measure_cap_px`]:1180 窗宽下是 424(逐行 67 字符,≤72),不是 M68 的 712。
pub fn content_column_width(window_width: f32) -> f32 {
available_width(window_width).min(measure_cap_px())
}
+46 -12
View File
@@ -150,7 +150,17 @@ fn chars_per_line(width: f32) -> f32 {
width / measured_advance_px()
}
/// 行宽上限必须真的停在 72 字符:名字、常量、实测三者一致。
/// **逐行**折行模拟结果(FreeType `getlength` 量宽 + 贪心词折行,脚本见模块文档 §3)。
///
/// 这两组数是外部实测:在同一段正文(361 字符)上扫列宽得到——
/// `SAFE` 是交付宽度下的最长一行,`BREAK` 是第一档会冒出 73 字符行的宽度。
/// 有了它,Rust 侧不用字体引擎也能把「上限必须夹在这两者之间」写成断言。
const SIM_SAFE_MAX_CHARS: f32 = 67.0;
const SIM_SAFE_MAX_INK: f32 = 402.0;
const SIM_BREAK_INK: f32 = 428.0;
const SIM_BREAK_CHARS: f32 = 73.0;
/// 行宽上限必须真的停在 72 字符:名字、常量、**逐行模拟**三者一致。
#[test]
fn line_length_is_capped_at_72_characters() {
// ① 常量对得上实测步进(14px 下的平均步进)
@@ -161,24 +171,47 @@ fn line_length_is_capped_at_72_characters() {
"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());
// ② 上限是**逐行**模拟出来的 424,不是「平均步进 × 72」的算术值 435
assert_eq!(measure_cap_px(), 424.0, "逐行安全宽(4px 栅格)");
assert_eq!(
measure_average_bound_px(),
435.0,
"floor(14 × 0.43233 × 72)"
);
assert!(
chars_at_cap <= MAX_MEASURE_CHARS,
"上限 {:?}px 按实测步进能放 {chars_at_cap:.1} 字符,超过 {MAX_MEASURE_CHARS}",
measure_cap_px() < measure_average_bound_px(),
"平均口径上界必须比逐行安全宽**更宽**——这说明 cap 不是拿平均算出来的"
);
// ③ 交付宽度下,模拟出来的最长一行装得下,且字符数 ≤72
assert!(
SIM_SAFE_MAX_INK <= measure_cap_px(),
"模拟最长行 {}px 超过上限 {}px",
SIM_SAFE_MAX_INK,
measure_cap_px()
);
assert!(
chars_at_cap > MAX_MEASURE_CHARS - 1.0,
"上限太紧,白丢一行字符"
SIM_SAFE_MAX_CHARS <= MAX_MEASURE_CHARS,
"模拟最长行 {SIM_SAFE_MAX_CHARS} 字符超过 {MAX_MEASURE_CHARS}"
);
// ④ 回归护栏:旧的 0.5em 口径(504px)在同一实测步进下是 83 字符 —— 这条专拦「改回去」
// ④ **两侧夹逼**:这是本条真正的护栏。
// 放大到 SIM_BREAK_INK(428)逐行模拟就会重新出现 73 字符行;
// 压到 SAFE 以下则纯属白丢宽度。两个方向都会红。
assert!(
measure_cap_px() < SIM_BREAK_INK,
"上限 ≥{SIM_BREAK_INK}px 时逐行模拟会再次出现 {SIM_BREAK_CHARS} 字符行——请重跑折行模拟"
);
assert!(
measure_cap_px() >= SIM_SAFE_MAX_INK,
"上限 {}px 比模拟出来的最长行 {}px 还窄,白丢宽度",
measure_cap_px(),
SIM_SAFE_MAX_INK
);
// ⑤ 平均口径的回归护栏:旧 0.5em 的 504px 在实测步进下能放 83 字符
assert!(
chars_per_line(504.0) > MAX_MEASURE_CHARS + 10.0,
"504px 在实测步进下应能放 >82 字符(旧口径的破绽),现在算出来 {:.1}",
@@ -187,9 +220,10 @@ fn line_length_is_capped_at_72_characters() {
for width in [600.0, 840.0, 900.0, 1180.0, 1440.0, 2560.0] {
let column = content_column_width(width);
// 平均口径只是次要护栏(逐行口径见上面的模拟常数)
assert!(
chars_per_line(column) <= MAX_MEASURE_CHARS,
"{width} 宽窗口的正文行宽 {:.1} 字符,超过 {MAX_MEASURE_CHARS} 上限",
"{width} 宽窗口的平均行宽 {:.1} 字符,超过 {MAX_MEASURE_CHARS}",
chars_per_line(column)
);
assert!(column <= measure_cap_px() + f32::EPSILON);
+1 -1
View File
@@ -1,4 +1,4 @@
//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤435)。
//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤424)。
//!
//! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态),
//! 高度走 `LayoutMetrics::page_header_tall_height`(84),这是 12b §16.1 那栏在骨架期的落地。
+1 -1
View File
@@ -15,7 +15,7 @@
//!
//! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]);
//! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`];
//! 3. **行宽上限**:正文列硬上限 **435px = 实测 72 字符**([`layout::measure_cap_px`]),
//! 3. **行宽上限**:正文列硬上限 **424px(逐行实测 67 字符 ≤72)**([`layout::measure_cap_px`]),
//! 由 [`layout::content_column_width`] / [`layout::frame_metrics`] **无条件施加**,
//! 页面拿不到「更宽」的选项。
//!
+3 -3
View File
@@ -1,4 +1,4 @@
//! 设置页:**supporting-pane** 的用例(左侧分组导航 240 + 右侧表单列 ≤435)。
//! 设置页:**supporting-pane** 的用例(左侧分组导航 240 + 右侧表单列 ≤424)。
//!
//! ## 为什么是 supporting-pane 而不是 list-detail
//!
@@ -14,7 +14,7 @@
//!
//! ## 退化(窗口 <720 或 compact 档)
//!
//! 侧列消失,分组导航退化成「按组堆叠」:同一列里每组一张卡 + 一段 12pt 组名。整页仍居中、正文列仍 ≤435。
//! 侧列消失,分组导航退化成「按组堆叠」:同一列里每组一张卡 + 一段 12pt 组名。整页仍居中、正文列仍 ≤424。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length, Padding};
@@ -177,7 +177,7 @@ fn group_item<'a>(ui: &'a Ui, group: &'a Group, selected: bool) -> Element<'a, M
// --------------------------------------------------------------- 表单列
/// 表单列(正文列宽 ≤435)。`only = Some(i)` 只渲染一组(supporting-pane),
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
fn form<'a>(ui: &'a Ui, only: Option<usize>) -> Element<'a, Message> {
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);