diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index 03b9890..f8eea51 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -1,4 +1,4 @@ -//! 下载页:**单列**版面的用例(列宽 ≤435,水平居中)。 +//! 下载页:**单列**版面的用例(列宽 ≤424,水平居中)。 //! //! 这一页永远单列,理由不是「简单」,而是官方对「一次只做一件事」的入口(下载队列)的口径: //! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限), diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index c4dae1a..e8d4f5f 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -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(列表 → 详情, //! 整页切换)。上下堆会把详情挤到折叠线以下,等于详情不存在。 diff --git a/crates/app-iced/src/pages/layout.rs b/crates/app-iced/src/pages/layout.rs index f3fd33a..609f905 100644 --- a/crates/app-iced/src/pages/layout.rs +++ b/crates/app-iced/src/pages/layout.rs @@ -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()) } diff --git a/crates/app-iced/src/pages/layout/tests.rs b/crates/app-iced/src/pages/layout/tests.rs index e0e16fb..d3eea67 100644 --- a/crates/app-iced/src/pages/layout/tests.rs +++ b/crates/app-iced/src/pages/layout/tests.rs @@ -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); diff --git a/crates/app-iced/src/pages/logs.rs b/crates/app-iced/src/pages/logs.rs index 69c937c..32d2832 100644 --- a/crates/app-iced/src/pages/logs.rs +++ b/crates/app-iced/src/pages/logs.rs @@ -1,4 +1,4 @@ -//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤435)。 +//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤424)。 //! //! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态), //! 高度走 `LayoutMetrics::page_header_tall_height`(84),这是 12b §16.1 那栏在骨架期的落地。 diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index b4fbfff..2925e87 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -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`] **无条件施加**, //! 页面拿不到「更宽」的选项。 //! diff --git a/crates/app-iced/src/pages/settings.rs b/crates/app-iced/src/pages/settings.rs index 49ec5e8..c7b5cc6 100644 --- a/crates/app-iced/src/pages/settings.rs +++ b/crates/app-iced/src/pages/settings.rs @@ -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) -> Element<'a, Message> { let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);