diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index fd4d0f6..03b9890 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -1,4 +1,4 @@ -//! 下载页:**单列**版面的用例(列宽 ≤504,水平居中)。 +//! 下载页:**单列**版面的用例(列宽 ≤435,水平居中)。 //! //! 这一页永远单列,理由不是「简单」,而是官方对「一次只做一件事」的入口(下载队列)的口径: //! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限), diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 20070b7..e0556a8 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -4,8 +4,8 @@ //! //! | 版面 | 结构 | 退化行为 | //! |---|---|---| -//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,正文列 ≤504 | — | -//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(≤504,居中) | 顶部给「Back to instances」,列表退成上一屏 | +//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,正文列 ≤435 | — | +//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(≤435,居中) | 顶部给「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 3a65e0d..cc2d773 100644 --- a/crates/app-iced/src/pages/layout.rs +++ b/crates/app-iced/src/pages/layout.rs @@ -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; diff --git a/crates/app-iced/src/pages/layout/tests.rs b/crates/app-iced/src/pages/layout/tests.rs index 51b1a5c..97f8adf 100644 --- a/crates/app-iced/src/pages/layout/tests.rs +++ b/crates/app-iced/src/pages/layout/tests.rs @@ -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); diff --git a/crates/app-iced/src/pages/logs.rs b/crates/app-iced/src/pages/logs.rs index 6acd66e..69c937c 100644 --- a/crates/app-iced/src/pages/logs.rs +++ b/crates/app-iced/src/pages/logs.rs @@ -1,4 +1,4 @@ -//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤504)。 +//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤435)。 //! //! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态), //! 高度走 `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 bef6208..e3ecb1a 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -14,7 +14,7 @@ //! //! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]); //! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`]; -//! 3. **行宽上限**:正文列硬上限 **504px ≈ 72 字符**([`layout::measure_cap_px`]), +//! 3. **行宽上限**:正文列硬上限 **435px = 实测 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 a05689d..7cd9852 100644 --- a/crates/app-iced/src/pages/settings.rs +++ b/crates/app-iced/src/pages/settings.rs @@ -1,4 +1,4 @@ -//! 设置页:**supporting-pane** 的用例(左侧分组导航 240 + 右侧表单列 ≤504)。 +//! 设置页:**supporting-pane** 的用例(左侧分组导航 240 + 右侧表单列 ≤435)。 //! //! ## 为什么是 supporting-pane 而不是 list-detail //! @@ -14,7 +14,7 @@ //! //! ## 退化(窗口 <720 或 compact 档) //! -//! 侧列消失,分组导航退化成「按组堆叠」:同一列里每组一张卡 + 一段 12pt 组名。整页仍居中、正文列仍 ≤504。 +//! 侧列消失,分组导航退化成「按组堆叠」:同一列里每组一张卡 + 一段 12pt 组名。整页仍居中、正文列仍 ≤435。 use iced::widget::{column, container, row}; use iced::{Alignment, Background, Border, Element, Length, Padding}; @@ -175,7 +175,7 @@ fn group_item<'a>(ui: &'a Ui, group: &'a Group, selected: bool) -> Element<'a, M // --------------------------------------------------------------- 表单列 -/// 表单列(正文列宽 ≤504)。`only = Some(i)` 只渲染一组(supporting-pane), +/// 表单列(正文列宽 ≤435)。`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);