diff --git a/crates/app-iced/src/pages/frame.rs b/crates/app-iced/src/pages/frame.rs index 1d4b12b..e78c3c2 100644 --- a/crates/app-iced/src/pages/frame.rs +++ b/crates/app-iced/src/pages/frame.rs @@ -12,13 +12,17 @@ //! 页头带与内容区共用同一个 `gutter`(`Padding::from([0.0, metrics.gutter])` / `padding(gutter)`), //! 所以页头标题的左缘 = `rail 88 + gutter 24 = 112`。正文块必须从**同一条线**起: //! -//! - 单列页用 [`body`](贴左 + 吃满 `layout::available`); -//! - 成段散文再套一层 [`prose`](宽度 ≤424,保证一行 ≤72 字符); +//! - 单列页用 [`body`](贴左 + 宽 `layout::block::body_width` = pane 宽,封顶 1440); +//! - 成段散文再套一层 [`prose`](宽 `layout::block::prose_width` ≤424,保证一行 ≤72 字符); //! - 两列页的正文在 [`panes`] 的主列里,左缘 = `112 + 列表列 336 + 间隙 24 = 472`(有据的分栏,不是错位)。 //! //! 旧 [`single_column`] 把 424 的块**居中**摆进 1044,于是左缘 422、标题 112——视觉复审的 ① 就是这个。 //! 它保留给设置页(`pages/settings/**`,几何归设置页线)。 //! +//! **这三个量都在 [`super::layout::block`](纯函数 + 单测)**;[`body`] / [`prose`] 只是把 +//! 「从哪个数建盒」写死在一处,`pages/tests.rs` 有两条**源码结构断言**盯着这两个盒子 +//! (iced 的 Element 没有几何内省,量不到摆法,只能断言「照哪个数建的」)。 +//! //! ## 判据落点(`research/12b`) //! //! | 部件 | 参考 | 落点 | @@ -50,7 +54,7 @@ use unlucky_design::typography::TypeRole; use crate::theme::Ui; -use super::layout::{self, FrameMetrics, PageLayout}; +use super::layout::{self, block, FrameMetrics}; use super::Message; /// 页头操作区之间的间隔(`Space::X2`)。 @@ -162,22 +166,18 @@ pub fn single_column<'a>( centered(metrics.column, content) } -/// **页面块宽度**:单列版面 = 整条可用宽度;两列版面 = 主列 pane 宽度。 +/// **页面块宽度**:pane 宽(单列 = 可用宽度 / 两列 = 主列 pane)封顶 1440。 /// -/// 这是「表格 / 卡片 / 列表能有多宽」的答案,与 [`FrameMetrics::column`](一句话能有多宽, -/// ≤424)是**两个量**。M102 的根因就是把后者当成了前者:424 的块在 1044 里居中, -/// 于是正文左缘比页头标题右移了 310px(1180 窗宽下)。 -pub fn body_width(metrics: FrameMetrics) -> f32 { - match metrics.layout { - PageLayout::SingleColumn => metrics.available, - PageLayout::SupportingPane | PageLayout::ListDetail => metrics.main_pane, - } -} +/// 真源在 [`super::layout::block::body_width`](纯量 + 单测);这里只是转发,**不另算一份**。 +/// 与 [`FrameMetrics::column`](一句话多宽,≤424)是两个量。 +pub use super::layout::block::body_width; -/// **正文块**:从页边距起(= 页头标题那条左缘)、铺满 [`body_width`]。 +/// **正文块**:从页边距起(= 页头标题那条左缘)、宽 [`body_width`]、左内边距 [`body_offset`]。 /// -/// 与旧 `single_column` 的差别只有两点,但都是判据本身:**贴左**而不是居中, -/// 宽度取 pane 而不是行宽上限。散文仍要自己套 [`prose`],别把整页块拉回 424。 +/// 盒子**从纯量建**:宽度取 [`body_width`]、左内边距取 [`super::layout::block::body_offset`] +/// (恒 0)、对齐 `Start`。这三件事的顺序就是契约,`pages/tests.rs` 的 +/// `frame_body_is_built_from_the_block_quantities` 逐条扫这段源码(iced 的 Element 没有几何 +/// 内省,量不到「摆在哪」,只能断言「照哪个数建的」)。 pub fn body<'a>( metrics: FrameMetrics, content: impl Into>, @@ -187,10 +187,11 @@ pub fn body<'a>( .height(Length::Fill) .align_x(Alignment::Start) .align_y(Alignment::Start) + .padding(Padding::default().left(block::body_offset(metrics))) .into() } -/// **散文块**:宽度锁在 [`FrameMetrics::column`](≤424 = 逐行 ≤72 字符)。 +/// **散文块**:宽度锁在 [`super::layout::block::prose_width`](= `metrics.column`,≤424 ⇒ 一行 ≤72 字符)。 /// /// 只包「成段读的文字」(实例详情正文那样的 prose);卡片、列表、Tabs 吃的是 [`body_width`], /// 它们的宽度不承担行宽承诺(列宽只影响每行放几个字段,不影响读一句话的舒适度)。 @@ -199,7 +200,7 @@ pub fn prose<'a>( content: impl Into>, ) -> Element<'a, Message> { container(content) - .width(Length::Fixed(metrics.column)) + .width(Length::Fixed(block::prose_width(metrics))) .into() } diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 3c6f731..735ce15 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -36,12 +36,14 @@ //! //! | 改什么 | 值 | 结果 | //! |---|---|---| -//! | 页级行距 | 4 → **0**(见 `download/rows.rs` 同一条) | 行节奏回到组件的 60 | -//! | 滚动区底部内缩 | 0 → **`Space::X2` 8** | 切点落在卡片的内边距带里,不压描边 | +//! | 页级行距 | 4 → **0**(真源 [`ROW_SPACING`] = `None`) | 行节奏回到组件的 60 | +//! | 滚动区底部内缩 | 0 → **`Space::X2` 8**(真源 [`LIST_BOTTOM_INSET`]) | 切点落在卡片的内边距带里,不压描边 | //! //! 于是可视区 = 580 − 列头 32 − 内缩 8 = **540 = 9 × 60**,正好 9 整行:末行完整可见, -//! 第 10 行整体落在裁切线之外(`instances/tests.rs::list_viewport_fits_whole_rows_and_still_overflow` -//! 把这四个数钉住)。列表内容 12×60 = 720 仍高过可视区,独立滚动条照样可见。 +//! 第 10 行整体落在裁切线之外。两个数都**只有一个真源**,页面与护栏都从它取 +//! (`instances/tests.rs` 引 [`LIST_BOTTOM_INSET`].`dp()` 与 [`ROW_SPACING`] 算 pitch, +//! 另有一条源码结构断言盯着 `list_column()` 不许再叠字面量间距)。 +//! 列表内容 12×60 = 720 仍高过可视区,独立滚动条照样可见。 mod data; mod demo; @@ -75,11 +77,19 @@ use super::layout::{FrameMetrics, PageLayout}; use super::model::{Instances, Model, Row}; use super::{Action, Message}; -/// 列表滚动区的**底部内缩**(`Space::X2`):让裁切线落在卡片的内边距带里,而不是压在 1px 描边上。 +/// 列表行距:**`None` = 这一层不加间距**(页面与护栏**共用这一个真源**)。 /// -/// 行距归零(`list()` 不写 `.spacing(…)`)与这条内缩是一对,理由见模块文档「行距归零(M102)」 -/// 与本页 `instances.rs` 的同名条目。 -const LIST_BOTTOM_INSET: Space = Space::X2; +/// M102 的 ② 就是页级附加量:`ListItem` 自己的 60 高已含上下 8px 内边距,页面再叠一道 +/// `Space::X1`(4) 就把行节奏变成 64、与可视高度除不尽(1180×760 下末行被切半行)。 +/// 所以行距不写成字面量、也不散落在 `list_column` 里,而是这个常量—— +/// 页面 `match` 它,`instances/tests.rs` 也从它算 pitch(改这里,测试立刻红)。 +pub(super) const ROW_SPACING: Option = None; + +/// 列表滚动区的**底部内缩**:让裁切线落在卡片的内边距带里,而不是压在 1px 描边上。 +/// +/// 与 [`ROW_SPACING`] 是一对(行距管「切在哪」、内缩管「切在什么底色上」), +/// 两个数都由 `instances/tests.rs` 的几何断言直接引用。 +pub(super) const LIST_BOTTOM_INSET: Space = Space::X2; /// 整页(`block.detail = None` 时详情区显示空态)。 pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<'a, Message> { @@ -157,7 +167,7 @@ fn list_column<'a>( left: ui.space(Space::X3), }); - let rows = column( + let mut rows = column( block .rows .iter() @@ -167,6 +177,12 @@ fn list_column<'a>( .padding(Padding::from([0.0, ui.space(Space::X2)])) .width(Length::Fill); + // 行距只从 `ROW_SPACING` 来(当前是 None = 不加间距)。别在这里写字面量 `.spacing(…)`: + // 那正是视觉复审 ② 的那道页级附加量(`pages/tests.rs` 有一条源码结构断言盯着这段)。 + if let Some(spacing) = ROW_SPACING { + rows = rows.spacing(ui.space(spacing)); + } + // **独立滚动**:只有列表列这一条 scrollable,详情列不跟着滚。 let scroller = scrollable(rows) .direction(scrollable::Direction::Vertical( diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 394be6b..9ed915d 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -1,24 +1,28 @@ -//! `instances` 的断言:演示数据与窄窗退化。 +//! `instances` 的断言:演示数据、窄窗退化,以及**页面摆法**(源码结构断言)。 use super::*; +use super::super::layout::source; use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass}; /// **M102 几何判据**:列表可视区正好放下整数行——末行不会被卡片底边切半行。 /// -/// 算式全部取真源(1180×760,harness 路径): +/// 算式里的三个页面真源**直接引用**(不写字面量),所以改页面常量会让这条立刻红: +/// [`LIST_BOTTOM_INSET`](滚动区底部内缩)、[`ROW_SPACING`](页级行距,`None` = 0)。 /// /// ```text /// 卡片高 = 760 − 标题栏 48 − 两行页头 84 − 2×gutter 24 = 580 /// 列头 = 上 12 + LabelMedium 16 + 下 4 = 32 -/// 内缩 = Space::X2 = 8 +/// 内缩 = LIST_BOTTOM_INSET = Space::X2 = 8 /// 可视区 = 580 − 32 − 8 = 540 -/// 行高 = ListItem 两行档 44 + 16 = 60 (页级行距 0) +/// 行高 = ListItem 两行档 44 + 16 = 60 +/// pitch = 行高 + ROW_SPACING(None ⇒ 0) = 60 /// 整行数 = 540 / 60 = 9 行整 /// ``` /// -/// **抓得住**:页级行距被加回来(64 的节奏立刻除不尽 ⇒ 末行只剩半行,视觉复审 ② 的原样)、 -/// 列头/内缩算错、演示数据被缩到不够滚(独立滚动条消失); -/// **抓不住**:真实像素(报告里的前后对照图 + 量测表负责)。 +/// **抓得住**:页面把内缩换档(`LIST_BOTTOM_INSET` → `X1` 立刻除不尽)、行距真源被改、 +/// 列头算错、演示数据被缩到不够滚(独立滚动条消失); +/// **抓不住**:`list_column()` 里被另写一层 `.spacing(…)`(那一条由 +/// `list_column_spaces_rows_only_from_the_shared_constant` 扫源码守)。 #[test] fn list_viewport_fits_whole_rows_and_still_overflow() { use unlucky_design::spacing::LayoutMetrics; @@ -31,26 +35,72 @@ fn list_viewport_fits_whole_rows_and_still_overflow() { assert_eq!(card, 580.0, "1180×760 下列表卡片的内部高度"); let head = Space::X3.dp() + TypeRole::LabelMedium.style().line_height_px + Space::X1.dp(); - let viewport = card - head - Space::X2.dp(); - assert_eq!(viewport, 540.0, "列表滚动区高度"); + let viewport = card - head - LIST_BOTTOM_INSET.dp(); + assert_eq!(viewport, 540.0, "列表滚动区高度必须以页面的内缩真源算"); - // 行距归零(页级不再附加):行节奏 = ListItem 两行档 60 + // 页级行距的**唯一真源**:None = 这一层不加间距 + let gap = ROW_SPACING.map_or(0.0, Space::dp); let row = lm.row_height + Space::X4.dp(); - assert_eq!(row, 60.0); + let pitch = row + gap; + assert_eq!(pitch, 60.0, "行节奏 = ListItem 两行档 60(不留页级行距)"); assert_eq!( - viewport % row, + viewport % pitch, 0.0, - "可视区 {viewport} 不是 {row} 的整数倍 ⇒ 末行会被切半行" + "可视区 {viewport} 不是 {pitch} 的整数倍 ⇒ 末行会被切半行(ROW_SPACING={ROW_SPACING:?})" ); // 同时必须还能滚:12 行内容高过可视区,截图里才看得到独立滚动条 - let content = DEMO.len() as f32 * row; + let content = DEMO.len() as f32 * pitch; assert!( content > viewport, "列表内容 {content} 不比可视区 {viewport} 高,截图里看不到独立滚动条" ); } +/// **源码结构**:`list_column()` 的列表行只许用 [`ROW_SPACING`] 这一个间距真源。 +/// +/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 直接加回 `list_column()`, +/// 上面那条几何断言照样绿——因为它算的是常量,看不见页面又叠了一层。iced 的 Element 又没有 +/// 内省接口,所以只能断言「这段代码里不许出现别的间距写法」。 +/// +/// **抓得住**:任何在列表行上写字面量 `.spacing(…)` 的页级附加量(视觉复审 ② 的原样); +/// **抓不住**:换个写法绕过(先赋给局部变量再 `.spacing`)——它守的是这段 5 行代码的契约。 +#[test] +fn list_column_spaces_rows_only_from_the_shared_constant() { + let body = source::function_body(include_str!("../instances.rs"), "fn list_column"); + + assert!( + body.contains("ROW_SPACING"), + "列表行距必须从 ROW_SPACING 取,不许另写:\n{body}" + ); + assert!( + !body.contains(".spacing(ui.space(Space::"), + "`list_column()` 里又出现了字面量间距(M102 ② 的页级附加量):\n{body}" + ); + assert!( + body.contains("LIST_BOTTOM_INSET"), + "底部内缩必须从 LIST_BOTTOM_INSET 取:\n{body}" + ); +} + +/// **散文接线**:详情页的正文段必须进 `frame::prose`(424),不许直接塞进 `frame::body`(1044)。 +/// +/// M102 把「块宽」与「散文列宽」拆成两个量之后,接错一行代码就会让 361 字符的正文在 1180 窗宽下 +/// 排成 170 字符一行,而不红任何测试。这条断言把接线本身钉住。 +#[test] +fn the_detail_note_goes_through_the_prose_box() { + let body = source::function_body(include_str!("../instances/detail.rs"), "fn push_text"); + + assert!( + body.contains("frame::prose("), + "正文段没有走 frame::prose:\n{body}" + ); + assert!( + !body.contains("frame::body("), + "正文段被塞进了整页块(会失去 72 字符行宽承诺):\n{body}" + ); +} + /// 元信息卡必须装得下**卡内最长的那条值**(视觉 r2:136 漏算 padding,版本串折行让卡底不齐)。 #[test] fn meta_card_is_wide_enough_for_the_longest_value() { diff --git a/crates/app-iced/src/pages/layout.rs b/crates/app-iced/src/pages/layout.rs index 9de05c9..dc4c089 100644 --- a/crates/app-iced/src/pages/layout.rs +++ b/crates/app-iced/src/pages/layout.rs @@ -43,13 +43,10 @@ //! `DevDocs/ops/screens-m68/shell-light-cpu.png` 里那一行**实测 ink 712px**(x=133..845)吻合。 //! 视觉复审的判词因此是「**不能把全宽单卡当页面模板**,官方桌面口径是 supporting-pane / list-detail」。 //! -//! > **两个订正记录**(都是 P2 复审抓出来的): -//! > ① **712 是那一行的墨迹宽度,不是列宽**。早期文档写成「1180 − 88 − 2×24 = 712」, -//! > 那个减法得 1044,712 是 ink——列宽是 1004,ink 只占其中的 71%。 -//! > ② **字符数按实测算**:那一行是 **137 字符**(12px × 0.4323em = 5.19px/字符)。 -//! > 早期口径拿 `0.5em` 估算的「14px 正文步进」换算,既高估步进、也对错了字号,把事故说轻了。 -//! > 现在事故描述一律用**实测步进 × 实际字号**;这也是 -//! > `line_length_is_capped_at_72_characters` 里那条「旧口径 504px 能放 83 字符」护栏的由来。 +//! > **两个订正记录**(P2 复审抓出):① **712 是那行的墨迹宽,不是列宽**(列宽 1004;旧文档的减法得 1044); +//! > ② 那行是 **137 字符**(12px × 0.4323em = 5.19px/字符),早期拿 `0.5em` 估算既高估步进、也错了字号。 +//! > 现在事故描述一律用**实测步进 × 实际字号**——这也是 `line_length_is_capped_at_72_characters` +//! > 里那条「旧口径 504px 能放 83 字符」护栏的由来。 //! //! ### 逐行口径:72 字符是**最长一行**的上界,不是平均(r2 复审的教训) //! @@ -75,19 +72,8 @@ //! //! ### M102 订正:`column` 是**正文(prose)列宽**,不是页面块宽 //! -//! 视觉复审在 1180×760 的下载 / 日志页抓到「正文列从 x≈423 起、标题在 x≈110,左右各空 ~300px」。 -//! 根因不在 [`frame_metrics`] 的宽度分配,而在**把 424 这个「一句话多宽」的上界当成了整页块的宽度**: -//! `pages::frame` 的旧 `single_column` 取 `metrics.column` 并**水平居中**,于是 1044 里只用的 424 被摆到正中。 -//! -//! 现在两者分开,口径如下: -//! -//! | 量 | 谁给 | 用在哪 | -//! |---|---|---| -//! | [`FrameMetrics::column`] | 本模块(≤ 424) | **正文/散文**:`frame::prose` 包一层,保证一行 ≤72 字符 | -//! | `pages::frame::body_width` | 本模块的 `available` / `main_pane` | **页面块**:表格、卡片、列表、Tabs 吃满可用宽度 | -//! -//! 左缘一律是 `rail 88 + gutter 24 = 112`——与页头标题同一条线(判据: -//! `pages/tests.rs::body_block_starts_at_the_header_title_and_fills_the_page`)。 +//! 视觉复审抓到「正文列左缘 423 对标题 110」,根因是**把 424 当成整页块宽再居中**。 +//! 现在三个量分开,口径与常量全在 [`block`](本文件只留指针,免得文档把 file-size 门禁顶到红线)。 //! //! ### 步进是怎么量的(口径 + 可复现) //! @@ -105,8 +91,12 @@ use unlucky_design::spacing::{LayoutMetrics, Space}; use unlucky_design::typography::TypeRole; +pub mod block; pub mod wrap; +#[cfg(test)] +pub(crate) mod source; + /// 官方 M3 窗口档的 medium 断点(600dp)。 pub const BREAKPOINT_MEDIUM: f32 = 600.0; /// 官方 M3 窗口档的 expanded 断点(840dp)。 diff --git a/crates/app-iced/src/pages/layout/block.rs b/crates/app-iced/src/pages/layout/block.rs new file mode 100644 index 0000000..55e3138 --- /dev/null +++ b/crates/app-iced/src/pages/layout/block.rs @@ -0,0 +1,76 @@ +//! 页面块几何:**块宽 / 块偏移 / 散文列宽**(纯函数,不碰 iced 控件树)。 +//! +//! ## 为什么这三个量单独住一个文件(M102 r2) +//! +//! M102 把「一句话多宽」和「整页块多宽」拆成两个量,那段口径原本写在 `layout.rs` 的模块文档里, +//! 连同 1440 上限一起把主文件顶到 **399 行**(CI 的 file-size 门禁是 `wc -l`,只剩 1 行余量)。 +//! 挪到本文件:主文件回到安全线以下,**口径与常量同处一室**——谁改上限都得读同一段话。 +//! +//! ## 三个量(页面只许从这里取,不许自己算) +//! +//! | 量 | 值 | 用在哪 | +//! |---|---|---| +//! | [`body_width`] | `min(pane, 1440)`;pane = `available`(单列)/ `main_pane`(两列) | 页面块:Tabs / 列表 / 卡片 / 分隔线 | +//! | [`body_offset`] | **0**(贴左) | 块的左缘相对页边距的偏移 —— 0 就是「与页头标题同一条线」 | +//! | [`prose_width`] | [`FrameMetrics::column`](≤424) | 成段散文(`frame::prose`),保证一行 ≤72 字符 | +//! +//! ## 为什么 1440 是上限(tower 拍板,M102 r2) +//! +//! 块宽**没有上限**时,2560 窗宽下单列页的块会一路长到 2424px:一行 80 个英文字符的列表行 +//! 空得发慌,中文正文同一行更能排到 170 字。取 **1440**(落在 4px 栅格上,= 360 × 4): +//! 在 1180(可用 1044)与 900(764)上**都不生效**,只拦极宽窗。 +//! +//! 与 [`super::measure_cap_px`](424,用 FreeType 实测步进 × 逐行折行模拟推出来的)不同, +//! 1440 是**决策值**不是量出来的:它没有排版学推导,只有「别让列表行排成一条高速路」这一条理由。 +//! 所以它只该在这一处出现——谁想改,改这里并同时改 `block/tests.rs` 的断言。 +//! +//! 贴左不变:块左缘仍是 `rail 88 + gutter 24 = 112`,封顶后**右侧留白**(不是居中)。 +//! 这是有意的:页头标题在左,正文块跟着左缘;把块居中又会回到视觉复审 ① 的「内容缩在中间」。 +//! +//! ## 偏移为什么是个常量函数而不是 `align_x` +//! +//! iced 的 `Element` **没有几何内省接口**,「块摆在哪」在单测里量不到。所以把「贴左」写成一个 +//! 可断言的纯量 [`body_offset`](= 0),让 `frame::body` 从它建盒,再配一条**源码结构**断言 +//! (`pages/tests.rs::frame_body_is_built_from_the_block_quantities`)钉住「盒子的左内边距来自它」。 +//! 变异实测:把 `frame::body` 换回 `centered(metrics.column, …)`(视觉复审 ① 的原样)⇒ 该断言红。 + +use super::{FrameMetrics, PageLayout}; + +/// 页面块宽度上限(逻辑像素,4px 栅格的 1440 = 360dp × 4)。 +/// +/// 只拦极宽窗:1180 窗宽下可用 1044、900 下 764,都够不到它。 +pub const BODY_MAX_WIDTH: f32 = 1440.0; + +/// 页面块宽度(px):pane 宽封顶 1440。 +/// +/// 单列 = 整条可用宽度;两列 = 主列 pane(列表列 / 侧列已被扣掉)。 +pub fn body_width(metrics: FrameMetrics) -> f32 { + let pane = match metrics.layout { + PageLayout::SingleColumn => metrics.available, + PageLayout::SupportingPane | PageLayout::ListDetail => metrics.main_pane, + }; + + if pane < BODY_MAX_WIDTH { + pane + } else { + BODY_MAX_WIDTH + } +} + +/// 页面块相对页边距的**左偏移**(px):恒为 **0**(贴左)。 +/// +/// 「贴左」是 M102 的头号交付(视觉复审 ① 是「424 居中 ⇒ 左缘 422 对标题 112」), +/// 所以它必须是一个能被断言的数,而不是一处 `align_x` 的写法。 +pub const fn body_offset(_metrics: FrameMetrics) -> f32 { + 0.0 +} + +/// 散文块宽度(px)= [`FrameMetrics::column`](≤424 ⇒ 逐行 ≤72 字符)。 +/// +/// 与 [`body_width`] 是两个量:块可以是 1044,一句话仍然只排 424。 +pub const fn prose_width(metrics: FrameMetrics) -> f32 { + metrics.column +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/layout/block/tests.rs b/crates/app-iced/src/pages/layout/block/tests.rs new file mode 100644 index 0000000..6c2a652 --- /dev/null +++ b/crates/app-iced/src/pages/layout/block/tests.rs @@ -0,0 +1,89 @@ +//! `block` 的断言:三个纯量的数值口径。 +//! +//! 「块摆在哪」量不到(iced 无 Element 内省),所以这里只钉**数**; +//! 「盒子是不是照这个数摆的」由 `pages/tests.rs` 的源码结构断言负责 +//! (`frame_body_is_built_from_the_block_quantities`)。 + +use super::*; +use crate::pages::layout::{frame_metrics, measure_cap_px, rail_width, PaneKit}; + +/// 贴左:**任何**版面、任何窗宽,块偏移都是 0。 +/// +/// 抓得住:有人把偏移改成居中/右对齐(那它就不再是 0); +/// 抓不住:`frame::body` 有没有真的用这个数(源码结构断言负责)。 +#[test] +fn the_block_is_always_left_aligned() { + for kit in [PaneKit::Single, PaneKit::Supporting, PaneKit::ListDetail] { + for width in [320.0, 600.0, 720.0, 840.0, 900.0, 1180.0, 1440.0, 2560.0] { + assert_eq!( + body_offset(frame_metrics(kit, width)), + 0.0, + "{kit:?} @{width} 的块偏移必须贴左" + ); + } + } +} + +/// 块宽 = pane 宽(单列 = 可用宽度,两列 = 主列 pane),未到上限时不缩水。 +#[test] +fn body_width_is_the_pane_until_the_cap() { + for width in [320.0, 600.0, 840.0, 1180.0] { + let single = frame_metrics(PaneKit::Single, width); + assert_eq!(body_width(single), single.available, "单列 @{width}"); + assert_eq!(body_offset(single), 0.0, "@{width}"); + } + + // 两列版面:块宽 = 主列 pane(低于门槛时 list-detail 会退化成单列,那时块宽回到可用宽度) + let two = frame_metrics(PaneKit::ListDetail, 1180.0); + assert_eq!(body_width(two), two.main_pane); + assert!(two.main_pane < two.available); + let degraded = frame_metrics(PaneKit::ListDetail, 700.0); + assert_eq!(body_width(degraded), degraded.available); + + // 1440 上限只拦极宽窗:1180 可用 1044、2560 可用 2424 + assert_eq!(frame_metrics(PaneKit::Single, 1180.0).available, 1044.0); + assert_eq!(frame_metrics(PaneKit::Single, 2560.0).available, 2424.0); + assert_eq!( + body_width(frame_metrics(PaneKit::Single, 2560.0)), + BODY_MAX_WIDTH + ); + // 两列版面同样封顶(2560 下主列 pane 是 2064) + let wide_two = frame_metrics(PaneKit::ListDetail, 2560.0); + assert!(wide_two.main_pane > BODY_MAX_WIDTH); + assert_eq!(body_width(wide_two), BODY_MAX_WIDTH); + + // 封顶之后左缘不变、右侧留白(不是居中) + let huge = frame_metrics(PaneKit::Single, 2560.0); + let left = rail_width() + huge.gutter; + assert_eq!(left, 112.0); + let right_gap = 2560.0 - (left + body_width(huge)) - huge.gutter; + assert_eq!(right_gap, 984.0, "2560 宽下封顶后右侧留白"); + assert!(right_gap > 900.0); +} + +/// 散文列宽 = `FrameMetrics::column`,永远 ≤ 行宽上限(与块宽脱钩)。 +#[test] +fn prose_width_stays_at_the_measure_cap() { + for width in [600.0, 840.0, 900.0, 1180.0, 2560.0] { + let metrics = frame_metrics(PaneKit::Single, width); + assert_eq!(prose_width(metrics), metrics.column, "@{width}"); + assert!(prose_width(metrics) <= measure_cap_px()); + } + + // 块已经封顶的地方,散文列仍是 424 —— 两个量确实分开了 + let huge = frame_metrics(PaneKit::Single, 2560.0); + assert_eq!(body_width(huge), BODY_MAX_WIDTH); + assert_eq!(prose_width(huge), measure_cap_px()); + assert_eq!(prose_width(huge), 424.0); +} + +/// 上限本身:在 4px 栅格上,且远宽于散文列(否则「把两个量拆开」这件事是白做的)。 +#[test] +fn the_cap_is_on_the_grid_and_far_wider_than_the_measure() { + assert_eq!(BODY_MAX_WIDTH % 4.0, 0.0, "块宽上限必须在 4px 栅格上"); + assert!( + BODY_MAX_WIDTH >= measure_cap_px() * 3.0, + "块宽上限 {BODY_MAX_WIDTH} 必须远宽于散文列 {}", + measure_cap_px() + ); +} diff --git a/crates/app-iced/src/pages/layout/source.rs b/crates/app-iced/src/pages/layout/source.rs new file mode 100644 index 0000000..b406577 --- /dev/null +++ b/crates/app-iced/src/pages/layout/source.rs @@ -0,0 +1,39 @@ +//! **源码结构断言**的小工具:把某个函数的实现文本切出来(只给测试用)。 +//! +//! ## 为什么要读源码(而不是量 Element) +//! +//! iced 的 `Element` 没有几何内省接口(`size()` 只是 `Size`,取不到布局结果), +//! 所以「页面把块摆在哪 / 有没有在行之间叠间距」在单测里**量不到**。M102 r1 的教训正是这个: +//! 护栏只喂 `frame_metrics()` 与常量重算,把 `frame::body` 换回「居中 424」的实现, +//! 测试照样全绿——钉住的是公式,不是摆法。 +//! +//! 本仓已有同一条纪律的先例:`tests/no_hardcoded_tokens.rs` 直接扫 `src/**`, +//! `pages/download/tests.rs::every_download_key_is_referenced_by_the_page` 用 `include_str!` +//! 扫页面源码找 key 引用。这里只是把「切出某个函数」这件小事做成一个共用工具, +//! 让护栏能断言「盒子是不是照那个数建的」。 +//! +//! ## 边界(它抓不住什么) +//! +//! 它读的是**文本**:换个写法(先赋值给局部变量再 `.width(...)`)就可能绕过。 +//! 所以它只配一条**明确的契约**用:`frame::body` / `frame::prose` / `list_column` 这几处的 +//! 写法本身就是契约的一部分(它们各自只有 5~10 行),不是为了抠实现细节。 +//! 真要守像素,还是得靠软件渲染金图(`shot::diff`,绑 Windows + 字体栈,属另一个决定)。 + +/// 把 `source` 里第一个 `signature` 起始、到该函数**顶层**右花括号为止的文本切出来。 +/// +/// 「顶层」= 行首的 `}`(本仓函数都是顶格收尾,内层块都是缩进收尾),所以不会在内层 `}` 提前断。 +/// +/// # Panics +/// 签名不存在时 panic(护栏自己的前置条件坏了要立刻红,不能静默放行)。 +pub(crate) fn function_body<'a>(source: &'a str, signature: &str) -> &'a str { + let start = source + .find(signature) + .unwrap_or_else(|| panic!("源码里找不到签名 {signature:?}")); + + let rest = &source[start..]; + let end = rest.find("\n}").map_or(rest.len(), |index| index + 2); + &rest[..end] +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/layout/source/tests.rs b/crates/app-iced/src/pages/layout/source/tests.rs new file mode 100644 index 0000000..9a8f7ff --- /dev/null +++ b/crates/app-iced/src/pages/layout/source/tests.rs @@ -0,0 +1,48 @@ +//! `source` 自己的断言:切得准、切得住(门禁的门禁)。 +//! +//! 口径与 `tests/no_hardcoded_tokens.rs::the_gate_itself_catches_a_planted_violation` 一致: +//! 护栏工具自己先被一条断言钉住,否则它退化时没人发现。 + +use super::*; + +const SAMPLE: &str = "\ +/// doc +pub fn alpha() -> u8 { + let inner = |x: u8| { + x + }; + inner(1) +} + +pub fn beta<'a>(value: &'a str) -> &'a str { + value +} +"; + +#[test] +fn it_stops_at_the_top_level_closing_brace() { + let alpha = function_body(SAMPLE, "pub fn alpha"); + assert!(alpha.starts_with("pub fn alpha")); + assert!(alpha.trim_end().ends_with('}'), "{alpha}"); + // 内层闭包的花括号不会提前截断:`inner(1)` 与它的 `};` 都在 + assert!(alpha.contains("inner(1)"), "{alpha}"); + assert!(!alpha.contains("pub fn beta"), "切到了下一个函数:{alpha}"); + + let beta = function_body(SAMPLE, "pub fn beta<'a>("); + assert!(beta.contains("value"), "{beta}"); + assert!(!beta.contains("inner("), "{beta}"); +} + +#[test] +fn the_helper_also_works_on_signatures_with_generics_and_multiline_params() { + let source = "pub fn gamma<'a>(\n metrics: FrameMetrics,\n content: u8,\n) -> u8 {\n content\n}\n"; + let gamma = function_body(source, "pub fn gamma"); + assert!(gamma.contains("metrics: FrameMetrics"), "{gamma}"); + assert!(gamma.trim_end().ends_with('}'), "{gamma}"); +} + +#[test] +#[should_panic(expected = "找不到签名")] +fn a_missing_signature_fails_loudly() { + let _ = function_body(SAMPLE, "pub fn nope"); +} diff --git a/crates/app-iced/src/pages/layout/wrap.rs b/crates/app-iced/src/pages/layout/wrap.rs index 68346da..6ba1183 100644 --- a/crates/app-iced/src/pages/layout/wrap.rs +++ b/crates/app-iced/src/pages/layout/wrap.rs @@ -71,6 +71,11 @@ pub fn breakable(text: &str) -> String { /// 去掉 [`breakable`] 插进去的软断点(还原成数据原文)。 /// /// 只给测试与调试用:页面渲染时不需要还原(原文从未被改写)。 +/// +/// **限界(r1 复审指出)**:它删的是**所有** U+200B,所以原文自带的零宽空格也会被一并删掉 +/// (`strip(breakable(x)) == x` 只在「x 本身不含 U+200B」时成立;实测样例里没有这种串)。 +/// 这个函数因此只配「与 [`breakable`] 成对使用」的场景,不能当通用的原文还原器用—— +/// 产品侧从不调用它,剪贴板/视图模型拿的本来就是没动过的原文。 pub fn strip(text: &str) -> String { text.chars().filter(|ch| *ch != SOFT_BREAK).collect() } diff --git a/crates/app-iced/src/pages/layout/wrap/tests.rs b/crates/app-iced/src/pages/layout/wrap/tests.rs index 4527c2e..fac9d82 100644 --- a/crates/app-iced/src/pages/layout/wrap/tests.rs +++ b/crates/app-iced/src/pages/layout/wrap/tests.rs @@ -81,3 +81,22 @@ fn breaking_is_reversible_and_idempotent() { assert_eq!(breakable(&broken), broken, "幂等:再跑一遍不许再插"); } } + +/// **已知限界**(r1 复审 P2):`strip` 删的是**所有** U+200B,原文自带的也会被删。 +/// +/// 这条不修(产品侧从不调用 `strip`,剪贴板/模型拿的是未改写的原文),但要把行为钉住: +/// 将来谁想拿 `strip` 当通用还原器,这里会告诉他为什么不行。 +#[test] +fn strip_is_not_a_general_unbreaker() { + let original_with_zwsp = "a\u{200b}b"; + assert_eq!( + breakable(original_with_zwsp), + original_with_zwsp, + "没有分隔符就不插" + ); + assert_eq!( + strip(original_with_zwsp), + "ab", + "strip 会连同原文自带的 U+200B 一起删(已知限界,不是 bug 修复点)" + ); +} diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index 52ed29c..eacf9d4 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -81,14 +81,15 @@ fn the_width_comparison_really_changes_the_layout() { assert!(narrow.secondary.is_none(), "700 不许再有列表列"); } -/// **M102 的对齐判据**:正文块左缘 = 页头标题左缘,块宽 = 可用宽度(不再二次居中)。 +/// **M102 的对齐判据**:正文块左缘 = 页头标题左缘(`body_offset == 0`),块宽 = pane 宽(封顶 1440)。 /// -/// 页头标题的左缘 = `rail + gutter`(`frame::header_bar` 的 padding 与 `frame::page` 的 -/// 内容 padding 是同一个 `metrics.gutter`);正文块宽 = `frame::body_width`。 +/// 页头标题的左缘 = `rail + gutter`(`frame::header_bar` 的 padding 与 `frame::page` 的内容 +/// padding 是同一个 `metrics.gutter`);正文块宽 = `frame::body_width`。 /// -/// **抓得住**:谁把单列页的块宽改回 424(视觉复审 ①:左缘 422 对标题 112), -/// 或把块重新居中(左缘立刻偏离 `rail + gutter`); -/// **抓不住**:真实像素(`pages_demo` 的图与报告里的量测表负责)。 +/// **抓得住**:块宽被改回 424(视觉复审 ①:左缘 422 对标题 112)、偏移不再是 0(居中/右对齐)、 +/// 上限被挪动; +/// **抓不住**:`frame::body` 到底有没有照这两个数建盒子(那由 +/// [`frame_body_is_built_from_the_block_quantities`] 扫源码守)。 #[test] fn body_block_starts_at_the_header_title_and_fills_the_page() { for width in [600.0, 840.0, 900.0, 1180.0] { @@ -100,6 +101,11 @@ fn body_block_starts_at_the_header_title_and_fills_the_page() { block, metrics.available, "{width}: 单列页块宽必须是可用宽度" ); + assert_eq!( + layout::block::body_offset(metrics), + 0.0, + "{width}: 块必须贴左" + ); assert!( (title_x + block + metrics.gutter - width).abs() < f32::EPSILON, "{width}: 块从 {title_x} 起、宽 {block},没铺到右页边距" @@ -113,12 +119,75 @@ fn body_block_starts_at_the_header_title_and_fills_the_page() { assert_eq!(layout::rail_width() + wide.gutter, 112.0, "页头标题左缘"); assert_eq!(frame::body_width(wide), 1044.0, "正文块宽度"); + // 极宽窗:块宽封顶 1440(tower 拍板),仍然贴左 ⇒ 右侧留白而不是居中 + let huge = frame_metrics(PaneKit::Single, 2560.0); + assert_eq!(frame::body_width(huge), layout::block::BODY_MAX_WIDTH); + assert_eq!(frame::body_width(huge), 1440.0); + assert_eq!(layout::block::body_offset(huge), 0.0); + assert!(112.0 + frame::body_width(huge) < 2560.0); + // 两列版面的块宽 = 主列 pane(实例页正文在 panes 的主列里,不铺整页) let two = frame_metrics(PaneKit::ListDetail, 1180.0); assert_eq!(frame::body_width(two), two.main_pane); assert_eq!(two.main_pane, 1180.0 - 88.0 - 48.0 - 336.0 - 24.0); } +/// **源码结构**:`frame::body` 的盒子必须从「块宽 + 块偏移 + 贴左」这三个数建。 +/// +/// 为什么读源码:iced 的 `Element` 没有几何内省接口,`frame::body` 换成 +/// `centered(metrics.column, content)`(**就是视觉复审 ① 的改前写法**)不会红任何数值断言 —— +/// M102 r1 的变异实测就是这样漏掉的(`pages/tests.rs` 那条只喂 `frame_metrics()` 与 +/// `body_width()`)。所以这条盯的是**摆法**:宽度来自 `body_width(metrics)`、左内边距来自 +/// `body_offset(metrics)`、对齐 `Start`,且不出现居中/散文列宽。 +/// +/// **抓得住**:`body()` 被换回居中、`align_x` 改成 `Center`、宽度改回 `metrics.column`; +/// **抓不住**:绕过这段契约的重写(先取局部变量再建盒)——它守的是这 8 行的写法契约。 +#[test] +fn frame_body_is_built_from_the_block_quantities() { + let body = layout::source::function_body(include_str!("frame.rs"), "pub fn body<'a>("); + + for needle in [ + "body_width(metrics)", + "body_offset(metrics)", + "Alignment::Start", + ] { + assert!( + body.contains(needle), + "`frame::body` 必须从 {needle} 建盒(贴左 + 块宽 + 零偏移):\n{body}" + ); + } + for forbidden in [ + "centered(", + "single_column(", + "metrics.column", + "Alignment::Center", + ] { + assert!( + !body.contains(forbidden), + "`frame::body` 里出现了 {forbidden:?} —— 那是居中 / 散文列宽的老写法:\n{body}" + ); + } +} + +/// **源码结构**:`frame::prose` 的宽度必须来自 `prose_width`(= `metrics.column`),不来自块宽。 +/// +/// M102 把两个量拆开后,「把散文塞进 1044 的块」不会红任何数值断言;这条盯接线。 +#[test] +fn frame_prose_is_built_from_the_prose_width() { + let prose = layout::source::function_body(include_str!("frame.rs"), "pub fn prose<'a>("); + + assert!( + prose.contains("prose_width(metrics)"), + "`frame::prose` 必须从 prose_width 取宽:\n{prose}" + ); + for forbidden in ["body_width(", "metrics.available", "metrics.main_pane"] { + assert!( + !prose.contains(forbidden), + "`frame::prose` 里出现了 {forbidden:?} —— 散文会失去 72 字符行宽承诺:\n{prose}" + ); + } +} + /// **M84 的行宽红线**:任何页、任何窗口宽度、任何版面,正文列都不许超过 424px。 /// /// **抓得住**:`frame_metrics` 的 `column`(页面唯一的列宽来源)不会越界;