From 89d98184c14be955d1da95d9cb4ed3f71802b4c7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 20:46:41 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(app-iced):=20M102=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=87=A0=E4=BD=95=20=E2=80=94=E2=80=94=20=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E5=9D=97=E8=B4=B4=E5=B7=A6=E5=90=83=E6=BB=A1=E9=A1=B5=E5=AE=BD?= =?UTF-8?q?=E3=80=81=E5=88=97=E8=A1=A8=E6=9C=AB=E8=A1=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A2=AB=E8=A3=81=E5=8D=8A=E8=A1=8C=E3=80=81=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=8C=89=E8=AF=8D=E6=96=AD=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 正文列与页头不对齐(下载 / 日志):根因是 `frame::single_column` 把**行宽上限 424** 当成**整页块宽度**并水平居中(1180 下 1044 里只摆 424,左缘 422 对页头标题 112)。 现在两个量分开:`layout` 的 `column` 只管成段散文(≤424),页面块宽走新的 `frame::body_width`(= available / main_pane)。新增 `frame::body`(贴左吃满)与 `frame::prose`(散文锁 424)。1180×760 下下载 / 日志的块左缘 = 112 = 页头标题左缘。 ② 实例列表末行被裁半行:页级行距 `Space::X1`(4) 让行节奏变成 64、与可视高度除不尽, 且滚动区直贴卡片 1px 圆角描边。行距归零(下载页同改)+ 滚动区底部内缩 `Space::X2`(8) ⇒ 可视区 580−32−8 = 540 = 9×60,Redstone Lab 及其副标题完整可见,第 10 行整体在裁切线外。 ③ 日志断行把单词劈开(`graphics.gl_o` / `peration_failure`):新增 `layout::wrap::breakable`,在 `.` `_` `:` `/` `\` 后插零宽空格(U+200B 软断点), 日志行正文与一行摘要都过它;悬挂缩进不变(前缀仍是独立文本控件), `Line::text` 与 `Logs::summary()`(剪贴板)仍是原文。工具函数放 `pages/layout/`: 断点是「列宽之后」的另一半排版判据,纯函数、可单测、不碰控件树。 护栏:`body_block_starts_at_the_header_title_and_fills_the_page`、 `list_viewport_fits_whole_rows_and_still_overflow`(把 580/32/8/540/60 五个数钉住)、 `wrap::tests`(断点位置 / 不重复插 / 可还原 / 幂等)、`log_text_carries_soft_breaks_but_keeps_the_original`。 `download/tests.rs` 的「宽窗留白」口径按新分工改成「宽度归块、不归散文列」。 --- crates/app-iced/src/pages/download.rs | 22 +++-- crates/app-iced/src/pages/download/rows.rs | 11 ++- crates/app-iced/src/pages/download/tests.rs | 16 +++- crates/app-iced/src/pages/frame.rs | 61 +++++++++++++- crates/app-iced/src/pages/instances.rs | 31 +++++-- crates/app-iced/src/pages/instances/detail.rs | 25 ++++-- crates/app-iced/src/pages/instances/tests.rs | 50 +++++++++-- crates/app-iced/src/pages/layout.rs | 19 ++++- crates/app-iced/src/pages/layout/wrap.rs | 79 ++++++++++++++++++ .../app-iced/src/pages/layout/wrap/tests.rs | 83 +++++++++++++++++++ crates/app-iced/src/pages/logs.rs | 18 ++-- crates/app-iced/src/pages/logs/diagnostic.rs | 11 ++- crates/app-iced/src/pages/logs/lines.rs | 18 +++- crates/app-iced/src/pages/logs/tests.rs | 37 ++++++++- crates/app-iced/src/pages/tests.rs | 38 +++++++++ 15 files changed, 466 insertions(+), 53 deletions(-) create mode 100644 crates/app-iced/src/pages/layout/wrap.rs create mode 100644 crates/app-iced/src/pages/layout/wrap/tests.rs diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index 38ceca2..eef7cae 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -1,8 +1,12 @@ -//! 下载页:**单列**版面的用例(列宽 ≤424,水平居中)。 +//! 下载页:**单列**版面的用例(页面块吃满页宽,散文才受 424 行宽约束)。 //! //! 这一页永远单列,理由不是「简单」,而是官方对「一次只做一件事」的入口(下载队列)的口径: -//! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限), -//! 多出来的宽度留白——这就是 M68「全宽单卡」的反面。 +//! 内容是一串线性进度项,分栏没有第二个阅读对象。 +//! +//! **M102 订正**:旧版把整页块压到 424 并水平居中(1180 宽下左缘 422,页头标题在 112, +//! 左右各空 ~300px,视觉复审判为「内容缩在中间一条窄栏」)。现在页面块走 [`frame::body`] +//! ——左缘与页头标题同一条线(= 页边距 24),宽度 = 可用宽度(1180 下 1044)。 +//! 424 只约束**成段散文**(本页没有,散文在实例详情页),列表行 / Tabs / 分隔线吃满页宽。 //! //! ## M94:占位控件 → 真组件 + 真数据 + 真指令(逐项对照) //! @@ -44,10 +48,12 @@ //! 进度条只读 `model.install`:它由 presenter 的 `InstallProgress` 事件经 `apply` 灌进来。 //! 于是「点了没变」只有一种解释——后端还没回事件,这正是要的(P3-2 的纪律)。 //! -//! ## 版面判据(一条没动) +//! ## 版面判据(M102 修订了「页头 vs 正文」那一条) //! -//! 正文列 424 硬上限、断点 600 / 840、rail 88 都由 [`super::layout`] 与 `shell` 无条件施加, -//! 本页拿不到「更宽」的选项;页头仍按 M3 口径铺满页宽(偏离 #1,未变)。 +//! 正文块左缘 = 页头标题左缘 = `rail 88 + gutter 24 = 112`([`frame::body`]); +//! 断点 600 / 840、rail 88、散文行宽 ≤424([`super::layout::measure_cap_px`])都由 +//! [`super::layout`] 与 `shell` 无条件施加。视觉复审偏离 #1(页头铺满、不与正文列对齐) +//! 因此**收口**:页头与正文块现在共用同一条左缘,右缘也都停在页边距上。 mod demo; mod install; @@ -129,7 +135,7 @@ fn body<'a>( } else { placeholder }; - return frame::single_column(metrics, content); + return frame::body(metrics, content); } let content: Element<'a, Message> = column![ @@ -145,5 +151,5 @@ fn body<'a>( .height(Length::Fill) .into(); - frame::single_column(metrics, content) + frame::body(metrics, content) } diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index a5c632e..3b76baf 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -8,6 +8,13 @@ //! | 已装标记 | 无 | overline `Installed` + trailing `Check`(`Primary`) | //! //! 行高从「手搓 56」变成 60 的理由与实例页完全一样:真 `ListItem` 自己的几何真源,页面不覆盖。 +//! +//! ## 行距归零(M102) +//! +//! 原先页面在 `ListItem` 的 60/76 高之外又加了 `Space::X1`(4)的行距,行节奏因此与可视高度 +//! 除不尽——实例页复现成「末行被卡片底边切半行」(视觉复审 ②)。两页一起归零:行距只由组件的 +//! 几何真源决定(`ListItem` 自己的上下 8px 内边距已经够了),`Space` 里也没有 0 这一档, +//! 所以 `list()` 里**不写** `.spacing(…)`。 use iced::widget::{column, scrollable}; use iced::{Element, Length, Padding}; @@ -27,9 +34,6 @@ use super::super::frame; use super::super::Message; use super::model::{Category, Download, VersionRow}; -/// 行与行的间距(`Space::X1`;与实例页同值,行高差异全部来自 `ListItem` 自己的几何)。 -const ROW_GAP: Space = Space::X1; - /// 分类筛选(Primary Tabs:选中项下方 3px `Primary` 指示条)。 /// /// 点一个分类 = `Message::SetCategory`,由 app 层翻成 `AppCommand::ListVersions`—— @@ -77,7 +81,6 @@ pub(super) fn list<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Ele .map(|entry| version_row(ui, catalog, model, entry)) .collect::>(), ) - .spacing(ui.space(ROW_GAP)) .padding(Padding::from([0.0, ui.space(Space::X2)])) .width(Length::Fill); diff --git a/crates/app-iced/src/pages/download/tests.rs b/crates/app-iced/src/pages/download/tests.rs index 64fa419..4b25220 100644 --- a/crates/app-iced/src/pages/download/tests.rs +++ b/crates/app-iced/src/pages/download/tests.rs @@ -59,14 +59,22 @@ fn download_is_single_column_at_every_width() { } } -/// 宽窗下多出来的宽度必须留白(不是把卡片拉宽)——1180 的正文列 < 可用宽度。 +/// 宽窗下多出来的宽度归**页面块**,不归散文列(M102 订正;旧口径是「留白」)。 +/// +/// 抓得住:谁把整页块又压回 424(视觉复审 ① 的原样:块宽 = `column`,1180 下左右各留 ~300px); +/// 抓不住:真实像素(报告里的量测表负责)。 #[test] -fn extra_width_goes_to_margin_not_to_the_column() { +fn extra_width_goes_to_the_page_block_not_to_the_prose_column() { let metrics = frame_metrics(PaneKit::Single, 1180.0); - assert_eq!(metrics.column, measure_cap_px()); + assert_eq!(metrics.column, measure_cap_px(), "散文列仍是行宽上限 424"); + assert_eq!( + super::super::frame::body_width(metrics), + metrics.available, + "页面块必须吃满可用宽度(1044),不是被居中压成 424" + ); assert!( metrics.available - metrics.column >= 400.0, - "1180 宽下应留出大片留白(M68 的全宽卡就是没有这段留白)" + "两个量要分得开:块宽 1044 比散文列 424 宽出 620" ); } diff --git a/crates/app-iced/src/pages/frame.rs b/crates/app-iced/src/pages/frame.rs index 1bf8623..1d4b12b 100644 --- a/crates/app-iced/src/pages/frame.rs +++ b/crates/app-iced/src/pages/frame.rs @@ -3,10 +3,22 @@ //! //! ## 边界 //! -//! 这里只放「页面级」的东西:页头、页边距、内容列、两列骨架、空态位的摆放。 +//! 这里只放「页面级」的东西:页头、页边距、内容块、两列骨架、空态位的摆放。 //! 具体控件(卡片 / 分隔线 / 文本按钮 / 图标)在 [`pieces`],它们是**占位实现** //! (同一份令牌,没有完整状态机),等 M75 的 `components/` 家族合入后由另一条 mission 换掉。 //! +//! ## 对齐口径(M102):页头标题左缘 = 正文块左缘 +//! +//! 页头带与内容区共用同一个 `gutter`(`Padding::from([0.0, metrics.gutter])` / `padding(gutter)`), +//! 所以页头标题的左缘 = `rail 88 + gutter 24 = 112`。正文块必须从**同一条线**起: +//! +//! - 单列页用 [`body`](贴左 + 吃满 `layout::available`); +//! - 成段散文再套一层 [`prose`](宽度 ≤424,保证一行 ≤72 字符); +//! - 两列页的正文在 [`panes`] 的主列里,左缘 = `112 + 列表列 336 + 间隙 24 = 472`(有据的分栏,不是错位)。 +//! +//! 旧 [`single_column`] 把 424 的块**居中**摆进 1044,于是左缘 422、标题 112——视觉复审的 ① 就是这个。 +//! 它保留给设置页(`pages/settings/**`,几何归设置页线)。 +//! //! ## 判据落点(`research/12b`) //! //! | 部件 | 参考 | 落点 | @@ -38,7 +50,7 @@ use unlucky_design::typography::TypeRole; use crate::theme::Ui; -use super::layout::{self, FrameMetrics}; +use super::layout::{self, FrameMetrics, PageLayout}; use super::Message; /// 页头操作区之间的间隔(`Space::X2`)。 @@ -139,6 +151,10 @@ fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Elem } /// 单列版面的正文列:宽 = 行宽上限内的实际列宽,**水平居中**。 +/// +/// > **M102 起产品页不再用它**:居中会把 424 摆到 1044 的正中(左缘 422 对页头标题 112), +/// > 视觉复审判为「内容缩在中间一条窄栏」。设置页(`pages/settings/**`)仍走这条, +/// > 它的几何归设置页线,本 mission 不动;下载 / 日志 / 窄窗实例改用 [`body`]。 pub fn single_column<'a>( metrics: FrameMetrics, content: impl Into>, @@ -146,6 +162,47 @@ pub fn single_column<'a>( centered(metrics.column, content) } +/// **页面块宽度**:单列版面 = 整条可用宽度;两列版面 = 主列 pane 宽度。 +/// +/// 这是「表格 / 卡片 / 列表能有多宽」的答案,与 [`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, + } +} + +/// **正文块**:从页边距起(= 页头标题那条左缘)、铺满 [`body_width`]。 +/// +/// 与旧 `single_column` 的差别只有两点,但都是判据本身:**贴左**而不是居中, +/// 宽度取 pane 而不是行宽上限。散文仍要自己套 [`prose`],别把整页块拉回 424。 +pub fn body<'a>( + metrics: FrameMetrics, + content: impl Into>, +) -> Element<'a, Message> { + container(content) + .width(Length::Fixed(body_width(metrics))) + .height(Length::Fill) + .align_x(Alignment::Start) + .align_y(Alignment::Start) + .into() +} + +/// **散文块**:宽度锁在 [`FrameMetrics::column`](≤424 = 逐行 ≤72 字符)。 +/// +/// 只包「成段读的文字」(实例详情正文那样的 prose);卡片、列表、Tabs 吃的是 [`body_width`], +/// 它们的宽度不承担行宽承诺(列宽只影响每行放几个字段,不影响读一句话的舒适度)。 +pub fn prose<'a>( + metrics: FrameMetrics, + content: impl Into>, +) -> Element<'a, Message> { + container(content) + .width(Length::Fixed(metrics.column)) + .into() +} + /// 两列版面:左列 `secondary`(独立滚动区由调用方自己加 `scrollable`)+ 右列 `main`。 /// /// 两列都**显式**取布局系统给的宽度,不吃 Fill:列表列必须固定 336, diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index ee720c1..3c6f731 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -4,8 +4,8 @@ //! //! | 版面 | 结构 | 退化行为 | //! |---|---|---| -//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,正文列 ≤424 | — | -//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(≤424,居中) | 顶部给「Back to instances」,列表退成上一屏 | +//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,散文 ≤424 | — | +//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(块贴左吃满页宽,散文 ≤424) | 顶部给「Back to instances」,列表退成上一屏 | //! //! 为什么窄窗不是「上下堆」:官方 compact 下 list-detail 的口径是一次只显示一个 pane(列表 → 详情, //! 整页切换)。上下堆会把详情挤到折叠线以下,等于详情不存在。 @@ -27,6 +27,21 @@ //! M84 的行高是「`row_height` 44 + `Space::X3` 12 = 56」的手工值;真 `ListItem` 的两行档是 //! **60**(令牌 `row_height` 44 + 每多一行 16,A1 是 72)。差异来自组件自己的几何真源, //! 页面不许覆盖——这正是「接真组件」的应有结果。列表仍高于可视区,独立滚动条照样可见。 +//! +//! ## 行距归零 + 底部内缩(M102,视觉复审 ②) +//! +//! 视觉复审在 1180×760 的截图里抓到「末行 Redstone Lab 被卡片底边切掉半行(副标题消失)」。 +//! 根因是**两道页级附加量**:`Space::X1` 的行距让行节奏变成 64,与可视高度除不尽; +//! 列表滚动区贴到卡片的 1px 圆角描边上,切点正好落在描边线上。修法两件: +//! +//! | 改什么 | 值 | 结果 | +//! |---|---|---| +//! | 页级行距 | 4 → **0**(见 `download/rows.rs` 同一条) | 行节奏回到组件的 60 | +//! | 滚动区底部内缩 | 0 → **`Space::X2` 8** | 切点落在卡片的内边距带里,不压描边 | +//! +//! 于是可视区 = 580 − 列头 32 − 内缩 8 = **540 = 9 × 60**,正好 9 整行:末行完整可见, +//! 第 10 行整体落在裁切线之外(`instances/tests.rs::list_viewport_fits_whole_rows_and_still_overflow` +//! 把这四个数钉住)。列表内容 12×60 = 720 仍高过可视区,独立滚动条照样可见。 mod data; mod demo; @@ -60,8 +75,11 @@ use super::layout::{FrameMetrics, PageLayout}; use super::model::{Instances, Model, Row}; use super::{Action, Message}; -/// 列表行的间距(`Space::X1`;与 M84 同值,行高差异全部来自 `ListItem` 自己的几何)。 -const ROW_GAP: Space = Space::X1; +/// 列表滚动区的**底部内缩**(`Space::X2`):让裁切线落在卡片的内边距带里,而不是压在 1px 描边上。 +/// +/// 行距归零(`list()` 不写 `.spacing(…)`)与这条内缩是一对,理由见模块文档「行距归零(M102)」 +/// 与本页 `instances.rs` 的同名条目。 +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> { @@ -95,7 +113,7 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element< ), _ => match &block.detail { Some(detail) => detail::narrow_detail(ui, metrics, detail, forced), - None => frame::single_column(metrics, detail::detail_empty(ui)), + None => frame::body(metrics, detail::detail_empty(ui)), }, }; @@ -146,7 +164,6 @@ fn list_column<'a>( .map(|entry| list_row(ui, entry, is_selected(block, entry), forced)) .collect::>(), ) - .spacing(ui.space(ROW_GAP)) .padding(Padding::from([0.0, ui.space(Space::X2)])) .width(Length::Fill); @@ -161,9 +178,11 @@ fn list_column<'a>( .height(Length::Fill) .style(frame::scroll_style(ui)); + // 底部内缩:裁切线落在卡片的内边距带里,而不是压在圆角描边上(M102 / 视觉复审 ②)。 container(column![head, scroller].height(Length::Fill)) .width(Length::Fill) .height(Length::Fill) + .padding(Padding::default().bottom(ui.space(LIST_BOTTOM_INSET))) .style(move |_theme| container::Style { background: Some(Background::Color(ui.layer(MaterialLayer::Card))), border: Border { diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index fe7bd3f..59a12e3 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -40,7 +40,7 @@ pub(super) fn detail_column<'a>( .spacing(ui.space(Space::X4)) .width(Length::Fill); - body = push_text(ui, body, detail); + body = push_text(ui, metrics, body, detail); // 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留) frame::left_aligned(metrics.column, body.push(action_row(ui, forced))) @@ -70,7 +70,7 @@ pub(super) fn narrow_detail<'a>( .spacing(ui.space(Space::X4)) .width(Length::Fill); - body = push_text(ui, body, detail); + body = push_text(ui, metrics, body, detail); body = body.push(frame::text_role( ui, @@ -81,22 +81,29 @@ pub(super) fn narrow_detail<'a>( ui.color(Role::OnSurfaceVariant), )); - frame::single_column(metrics, body.push(action_row(ui, forced))) + frame::body(metrics, body.push(action_row(ui, forced))) } /// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。 +/// +/// 散文段套 [`frame::prose`]:M102 起页面块吃满页宽,但「一行 ≤72 字符」的承诺仍然只由 +/// 散文承担(`frame::body_width` 管块、`metrics.column` 管一句话)。 fn push_text<'a>( ui: &'a Ui, + metrics: FrameMetrics, mut body: iced::widget::Column<'a, Message>, detail: &'a Detail, ) -> iced::widget::Column<'a, Message> { if let Some(note) = &detail.note { - body = body.push(frame::text_role( - ui, - TypeRole::BodyMedium, - false, - note.as_str(), - ui.color(Role::OnSurfaceVariant), + body = body.push(frame::prose( + metrics, + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + note.as_str(), + ui.color(Role::OnSurfaceVariant), + ), )); } if let Some(status) = &detail.status { diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index f611bfd..394be6b 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -3,15 +3,51 @@ use super::*; use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass}; +/// **M102 几何判据**:列表可视区正好放下整数行——末行不会被卡片底边切半行。 +/// +/// 算式全部取真源(1180×760,harness 路径): +/// +/// ```text +/// 卡片高 = 760 − 标题栏 48 − 两行页头 84 − 2×gutter 24 = 580 +/// 列头 = 上 12 + LabelMedium 16 + 下 4 = 32 +/// 内缩 = Space::X2 = 8 +/// 可视区 = 580 − 32 − 8 = 540 +/// 行高 = ListItem 两行档 44 + 16 = 60 (页级行距 0) +/// 整行数 = 540 / 60 = 9 行整 +/// ``` +/// +/// **抓得住**:页级行距被加回来(64 的节奏立刻除不尽 ⇒ 末行只剩半行,视觉复审 ② 的原样)、 +/// 列头/内缩算错、演示数据被缩到不够滚(独立滚动条消失); +/// **抓不住**:真实像素(报告里的前后对照图 + 量测表负责)。 #[test] -fn demo_rows_overflow_the_visible_list() { - // 列表列要能滚动:条目数必须让内容高过可视区(760 窗高 − 标题栏 48 − 页头 84 − 页边距) - let visible = 760.0 - 48.0 - 84.0 - 2.0 * WidthClass::Expanded.gutter(); - let row_height = 44.0 + Space::X3.dp(); - let content = DEMO.len() as f32 * row_height; +fn list_viewport_fits_whole_rows_and_still_overflow() { + use unlucky_design::spacing::LayoutMetrics; + use unlucky_design::typography::TypeRole; + + let lm = LayoutMetrics::DESKTOP; + let gutter = WidthClass::Expanded.gutter(); + + let card = 760.0 - lm.titlebar_height - lm.header_height(true) - 2.0 * gutter; + 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, "列表滚动区高度"); + + // 行距归零(页级不再附加):行节奏 = ListItem 两行档 60 + let row = lm.row_height + Space::X4.dp(); + assert_eq!(row, 60.0); + assert_eq!( + viewport % row, + 0.0, + "可视区 {viewport} 不是 {row} 的整数倍 ⇒ 末行会被切半行" + ); + + // 同时必须还能滚:12 行内容高过可视区,截图里才看得到独立滚动条 + let content = DEMO.len() as f32 * row; assert!( - content > visible, - "列表内容 {content} 不比可视区 {visible} 高,截图里看不到独立滚动条" + content > viewport, + "列表内容 {content} 不比可视区 {viewport} 高,截图里看不到独立滚动条" ); } diff --git a/crates/app-iced/src/pages/layout.rs b/crates/app-iced/src/pages/layout.rs index 609f905..9de05c9 100644 --- a/crates/app-iced/src/pages/layout.rs +++ b/crates/app-iced/src/pages/layout.rs @@ -72,7 +72,22 @@ //! **作用域**:这条承诺只覆盖 `BodyMedium` 14px 的正文。`BodySmall` 12px 的辅助文案 //! (空态副文案、窄窗提示)在同一列宽下每行可到 ~82 字符——它们都是 1~2 行的短句,不做逐行承诺; //! 中文(全角 1em)另算,等 i18n mission 定。 - +//! +//! ### 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`)。 //! //! ### 步进是怎么量的(口径 + 可复现) //! @@ -90,6 +105,8 @@ use unlucky_design::spacing::{LayoutMetrics, Space}; use unlucky_design::typography::TypeRole; +pub mod wrap; + /// 官方 M3 窗口档的 medium 断点(600dp)。 pub const BREAKPOINT_MEDIUM: f32 = 600.0; /// 官方 M3 窗口档的 expanded 断点(840dp)。 diff --git a/crates/app-iced/src/pages/layout/wrap.rs b/crates/app-iced/src/pages/layout/wrap.rs new file mode 100644 index 0000000..68346da --- /dev/null +++ b/crates/app-iced/src/pages/layout/wrap.rs @@ -0,0 +1,79 @@ +//! **断行工具**:给没有空格的长标识符补上「软断点」。 +//! +//! ## 为什么在这里而不是日志页里 +//! +//! 这是**排版度量**问题,不是日志页的私事:`layout.rs` 已经是页面级几何的唯一真源 +//! (档位 / 版面 / 列宽 / 行宽上限),「一行在哪儿能断」属于同一类判据的第二半—— +//! 列宽决定**能放多宽**,断点决定**放不下时从哪儿折**。放在 `layout/` 下面: +//! +//! - 日志页(`pages/logs/**`)与以后任何要排长串的页都能用,不产生第二份实现; +//! - 它是纯函数(`&str → String`),不碰 iced 控件树,能在单测里逐字符钉死。 +//! +//! ## 修的是什么(M102 视觉复审的 ③) +//! +//! 崩溃日志的一行摘要与堆栈里有 `graphics.gl_operation_failure:medium(70)` 这种 +//! **完全没有空格**的长串:iced 的 `Wrapping::Word` 找不到断点,只能退化成按字形硬断, +//! 于是图里出现了 `graphics.gl_o` / `peration_failure`——单词被从中间劈开。 +//! +//! 修法不是改字号也不是截断(截断会让用户拿不回原始堆栈),而是往 +//! `.` `_` `:` `/` `\` 这些**标识符/路径分隔符后面**插一个**零宽空格**(U+200B): +//! +//! - U+200B 是 Unicode 的**软断点**(UAX #14 `ZW`):排版时可以在这里折行,但不占宽度、 +//! 不产生字形,`Wrapping::WordOrGlyph` 会优先在它那里折; +//! - 它只进**显示**用的字符串,不进数据:`Logs::summary()`(剪贴板)与 `Line::text` +//! (视图模型)仍是原文,`strip` 一下就能还原(见单测)。 +//! +//! ## 边界(没做什么) +//! +//! - 只在**分隔符之后**插,不在之前:「`a.b`」断成「`a.` / `b`」比「`a` / `.b`」好读; +//! - 分隔符后面紧跟空白、或结尾,就不插(`Wrapping` 本来就能在空白处折); +//! - 连续分隔符(`a..b`、`a/_b`)只在最后那个后面插一个,不制造一串空断点; +//! - `-` **不插**:时间戳 `2026-11-02 19:40` 断在日期中间比不断更难看, +//! 而 `-` 两侧几乎都有空白/标点兜底。 + +/// 零宽空格(U+200B):可断行、不占宽度的软断点。 +pub const SOFT_BREAK: char = '\u{200b}'; + +/// 软断点允许落在哪些字符**之后**。 +/// +/// 取标识符 / 路径 / 规则 id 的分隔符:`integrity.unsatisfied_link`、 +/// `graphics.gl_operation_failure:medium(70)`、`logs/2026-11-02/client.txt`、 +/// `AppData\Roaming\.unlucky\instances\dev-test\natives`(Windows 路径,反斜杠也断)。 +pub const BREAK_AFTER: [char; 5] = ['.', '_', ':', '/', '\\']; + +/// 给 `text` 补上软断点(返回**显示用**的新串;数据侧原文不动)。 +/// +/// 幂等:对已经补过断点的串再跑一遍不会插更多(U+200B 不是分隔符,也不是空白以外的可断字符)。 +pub fn breakable(text: &str) -> String { + let mut out = String::with_capacity(text.len() + text.len() / 8); + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + out.push(ch); + + if !BREAK_AFTER.contains(&ch) { + continue; + } + + // 只在「分隔符 + 真内容」之间插:结尾、空白、连续分隔符、已有软断点都不插。 + let worth_breaking = chars.peek().is_some_and(|next| { + !next.is_whitespace() && *next != SOFT_BREAK && !BREAK_AFTER.contains(next) + }); + + if worth_breaking { + out.push(SOFT_BREAK); + } + } + + out +} + +/// 去掉 [`breakable`] 插进去的软断点(还原成数据原文)。 +/// +/// 只给测试与调试用:页面渲染时不需要还原(原文从未被改写)。 +pub fn strip(text: &str) -> String { + text.chars().filter(|ch| *ch != SOFT_BREAK).collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/layout/wrap/tests.rs b/crates/app-iced/src/pages/layout/wrap/tests.rs new file mode 100644 index 0000000..4527c2e --- /dev/null +++ b/crates/app-iced/src/pages/layout/wrap/tests.rs @@ -0,0 +1,83 @@ +//! `wrap` 的断言:软断点插在哪、插完还能不能还原。 +//! +//! 口径与 `layout/tests.rs` 一致:只钉**纯函数**(断点位置 / 原文可还原), +//! 「渲染器是不是真在那里折」由截图与报告里的前后对照图负责。 + +use super::*; + +/// 视觉复审 ③ 的原话样本:这两个被劈开的词必须重新可断。 +#[test] +fn identifiers_break_after_their_separators() { + let summary = + "findings=integrity.unsatisfied_link:high(85),graphics.gl_operation_failure:medium(70)"; + let broken = breakable(summary); + + // 抓得住:断点漏掉 `_` / `.` / `:`(那就退化成按字形硬断,`graphics.gl_o` + `peration_failure` 复现); + // 抓不住:渲染器最终在哪一列折行(那要量图,见报告)。 + for seam in [ + "integrity.\u{200b}", + "unsatisfied_\u{200b}", + "link:\u{200b}", + "graphics.\u{200b}", + "gl_\u{200b}", + "operation_\u{200b}", + "failure:\u{200b}", + ] { + assert!(broken.contains(seam), "缺断点 {seam:?}:{broken}"); + } +} + +/// 路径样本:`logs/2026-11-02/client.txt` 在 `/` 与 `.` 后能断,日期里的 `-` 不许断。 +#[test] +fn paths_break_after_slashes_but_not_inside_dates() { + let broken = breakable("logs/2026-11-02/client.txt"); + assert!(broken.contains("logs/\u{200b}"), "{broken}"); + assert!(broken.contains("client.\u{200b}txt"), "{broken}"); + // 日期整体不拆:`-` 不在断点表里,视觉复审没要求、拆开反而难读。 + assert!(broken.contains("2026-11-02"), "{broken}"); + + // Windows 路径:反斜杠同样是断点(日志里 `AppData\Roaming\...\dev-test\natives\...`) + let win = + breakable("C:\\Users\\Player\\AppData\\Roaming\\.unlucky\\instances\\dev-test\\natives"); + assert!(win.contains("Player\\\u{200b}"), "{win}"); + assert!(win.contains("natives"), "{win}"); +} + +/// 不该插的地方一个都不插:结尾、空白之后、连续分隔符。 +#[test] +fn no_dangling_or_doubled_breaks() { + assert_eq!(breakable(""), ""); + assert_eq!(breakable("plain text"), "plain text"); + assert_eq!(breakable("trailing."), "trailing.", "结尾不插"); + assert_eq!( + breakable("a. b"), + "a. b", + "分隔符后面是空白:Wrapping 本来就能在空白处折" + ); + assert_eq!( + breakable("a..b"), + "a..\u{200b}b", + "连续分隔符只在最后一处断" + ); + assert_eq!(breakable("a:/b"), "a:/\u{200b}b"); + + // 抓得住:有人在每个字符后面都插(那就等于按字形断,本 mission 的修法失效)。 + assert!( + !breakable("abcdef").contains(SOFT_BREAK), + "没有任何分隔符的串不该出现断点" + ); +} + +/// 只改显示:`strip` 还原后与原文逐字符相同(剪贴板/日志原文不许被污染)。 +#[test] +fn breaking_is_reversible_and_idempotent() { + for text in [ + "graphics.gl_operation_failure:medium(70)", + "[19:40:12] [main/ERROR] 启动失败: exit code 1", + "at com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64)", + ] { + let broken = breakable(text); + assert_eq!(strip(&broken), text, "还原失败:{broken}"); + assert_eq!(breakable(&broken), broken, "幂等:再跑一遍不许再插"); + } +} diff --git a/crates/app-iced/src/pages/logs.rs b/crates/app-iced/src/pages/logs.rs index e9e46bd..eca01ab 100644 --- a/crates/app-iced/src/pages/logs.rs +++ b/crates/app-iced/src/pages/logs.rs @@ -24,7 +24,7 @@ //! //! | # | 取舍 | 为什么 | //! |---|---|---| -//! | 1 | **折行**而不是横向滚动 | 见 [`lines`] 模块文档:424px × 200 字符的现实下,横滚与外壳竖向滚动打架,且等宽族的 CJK 回退会毁掉「按列对齐」这个横滚唯一的好处 | +//! | 1 | **折行**而不是横向滚动 | 见 [`lines`] 模块文档:一行 200+ 字符的现实下,横滚与外壳竖向滚动打架,且等宽族的 CJK 回退会毁掉「按列对齐」这个横滚唯一的好处 | //! | 2 | 诊断卡片在日志列表**之上** | 崩溃时用户先要结论;把结论放在 14 行日志下面等于让人先翻页 | //! | 3 | 规则原文(中文)当**数据**直接画 | 界面文案全走 i18n key;规则表的 `cause.summary` 是诊断结论本身,翻译要等 `rules.d` 带 `cause.key` 进 DTO(已报 finding) | //! | 4 | 分组用**置信度档**(High/Medium/Low),不借 core 的严重度词汇 | `dto::Diagnosis` 没带 core 的 `severity`;拿置信度凑严重度会让 64 条规则里 12 条进错组(两个方向都有,复审实测)。档位由 `Confidence::of` 定,DTO 补齐后应换回真严重度(已报 finding) | @@ -40,6 +40,14 @@ //! | 空态的 CTA | [`Message::Navigate`] | 压一层路由栈回实例页(真导航,不是占位动作) | //! //! 截图的强弱边界写在 `logs/tests.rs` 每条断言下面。 +//! +//! ## M102:页面块吃满页宽 + 单词不再被劈开 +//! +//! - 正文块走 [`frame::body`](左缘 = 页头标题左缘 = 112,宽 = 可用宽度 1044 @1180), +//! 旧版把整页块压成 424 居中,视觉复审 ① 就是它; +//! - 日志行与一行摘要先过 [`super::layout::wrap::breakable`]:在 `.` `_` `:` `/` `\` 后面插零宽空格, +//! 于是 `graphics.gl_operation_failure` 断在 `_` 后面,而不是被按字形劈成 `gl_o` / `peration`。 +//! 零宽空格只进显示串,`Line::text` 与 `Logs::summary()`(剪贴板)仍是原文。 mod demo; mod diagnostic; @@ -106,12 +114,12 @@ pub fn view<'a>( )); let body = if logs.is_empty() { - frame::single_column(metrics, empty(ui, &catalog)) + frame::body(metrics, empty(ui, &catalog)) } else if logs.findings.is_empty() { // 没有结论:日志卡片独吞整页(这样「正常运行的一场」不会留一块空卡片)。 - frame::single_column(metrics, lines::card(ui, logs, lang, Length::Fill)) + frame::body(metrics, lines::card(ui, logs, lang, Length::Fill)) } else { - // 有结论:两块**都定高**、各自独立滚动(诊断 3 : 日志 2)。 + // 有结论:两块**都定高**、各自独立滚动(诊断 4 : 日志 2)。 // 定高不是偷懒:第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(截图见报告)。 let stack = column![ diagnostic::card(ui, logs, lang, Length::FillPortion(DIAGNOSIS_SHARE)), @@ -121,7 +129,7 @@ pub fn view<'a>( .height(Length::Fill) .spacing(ui.space(Space::X4)); - frame::single_column(metrics, stack) + frame::body(metrics, stack) }; frame::page(ui, metrics, header, body) diff --git a/crates/app-iced/src/pages/logs/diagnostic.rs b/crates/app-iced/src/pages/logs/diagnostic.rs index 8466f06..ba3143c 100644 --- a/crates/app-iced/src/pages/logs/diagnostic.rs +++ b/crates/app-iced/src/pages/logs/diagnostic.rs @@ -31,6 +31,7 @@ use crate::app::components::buttons::{self, Button}; use crate::theme::Ui; use super::super::frame; +use super::super::layout::wrap; use super::super::Message; use super::lines::{mono, mono_owned}; use super::model::{Confidence, Finding, Logs, GROUPS}; @@ -176,9 +177,13 @@ fn summary_block<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, ] .width(Length::Fill) .align_y(Alignment::Center), - mono_owned(ui, logs.summary(), ui.color(Role::OnSurface)) - .width(Length::Fill) - .wrapping(Wrapping::WordOrGlyph), + mono_owned( + ui, + wrap::breakable(&logs.summary()), + ui.color(Role::OnSurface) + ) + .width(Length::Fill) + .wrapping(Wrapping::WordOrGlyph), ] .spacing(ui.space(Space::X1)) .width(Length::Fill) diff --git a/crates/app-iced/src/pages/logs/lines.rs b/crates/app-iced/src/pages/logs/lines.rs index c1a0f1e..2e10e8b 100644 --- a/crates/app-iced/src/pages/logs/lines.rs +++ b/crates/app-iced/src/pages/logs/lines.rs @@ -2,11 +2,12 @@ //! //! ## 为什么折行而不是横向滚动(tower 要求二选一,这里选折行) //! -//! 正文列硬上限 424px,而崩溃堆栈动辄 200+ 字符(样例里最长的一行 214 字符)。两条路: +//! 一行崩溃堆栈动辄 200+ 字符(样例里最长的一行 214 字符)。M102 起卡片吃满页宽 +//! (1180 窗宽 → 卡内约 1004px),但 214 字符 × 等宽 12px ≈ 1412px 仍要折一次。两条路: //! //! | 方案 | 代价 | //! |---|---| -//! | 横向滚动 | 424px 里 200 字符 ≈ 8 屏,读一条 `Caused by:` 要来回甩;外壳已经是竖向滚动,横竖两向嵌套后滚轮归属会打架 | +//! | 横向滚动 | 一条日志要横甩两屏;外壳已经是竖向滚动,横竖两向嵌套后滚轮归属会打架 | //! | **折行(本页选)** | 一条日志可能占 2~3 行,行号/等级栏必须能标明「这是同一条」 | //! //! 选折行的决定性理由:**折行让等宽族只承担「视觉密度」,不承担「列对齐」**。等宽栈在 @@ -34,6 +35,13 @@ //! 代价与边界:续行缩进量 = **该行前缀宽度**(时间戳行 ≈ 10 字符、栈帧 `at` 行 ≈ 3 字符), //! 所以不同行的续行缩进不一样——这是悬挂缩进的正常形态(按行首标签缩进),不是 bug。 //! +//! ## 软断点(M102,视觉复审 ③) +//! +//! 没有空格的长串(`graphics.gl_operation_failure:medium(70)`)在原版里只能按字形硬断, +//! 图里出现 `gl_o` / `peration_failure`。现在正文先过 +//! [`super::super::layout::wrap::breakable`]:在 `.` `_` `:` `/` `\` 后插零宽空格,折行落回 +//! 词边界;悬挂缩进不受影响(前缀仍是独立文本控件),`Line::text` 仍是原文。 +//! //! ## 贴底(M95 r2,视觉复审 P2-4) //! //! 崩溃排查看的是**最后几行**,所以行列表用 `panel::scroll_tail`(`Anchor::End`):偏移按 @@ -61,6 +69,7 @@ use unlucky_i18n::Lang; use crate::theme::Ui; use super::super::frame; +use super::super::layout::wrap; use super::super::Message; use super::model::{Level, Line, Logs}; use super::panel; @@ -130,9 +139,12 @@ fn entry<'a>(ui: &'a Ui, line: &'a Line) -> Element<'a, Message> { // 续行一定回到自己的左边距,没有 `text-indent` 之类的排版钩子): // 前缀占第一行开头,正文从它右边开始、带着 `Fill` 宽度自己折行 ⇒ 续行自然落在 // `前缀宽度` 之后,与下一条日志的起点(x = 等级条右边)分开。 + // + // M102:正文先过 `wrap::breakable`——`graphics.gl_operation_failure:medium(70)` 这种没有空格的 + // 长串在原版里只能按字形硬断(`gl_o` / `peration`),补上软断点后才断在 `.` `_` `:` 后面。 let text = row![ mono(ui, prefix, color), - mono(ui, body, color) + mono_owned(ui, wrap::breakable(body), color) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] diff --git a/crates/app-iced/src/pages/logs/tests.rs b/crates/app-iced/src/pages/logs/tests.rs index 5d650e2..f5841f3 100644 --- a/crates/app-iced/src/pages/logs/tests.rs +++ b/crates/app-iced/src/pages/logs/tests.rs @@ -11,7 +11,7 @@ use unlucky_i18n::Lang; use crate::theme::Families; -use super::super::layout::{frame_metrics, measure_cap_px, PaneKit}; +use super::super::layout::{frame_metrics, measure_cap_px, wrap, PaneKit}; use super::model::{self, GROUPS, SUMMARY_HEADER}; use super::{demo, lines, *}; @@ -44,6 +44,41 @@ fn page_is_single_column_and_never_wider_than_the_measure_cap() { } } +/// **M102 ③**:日志行与一行摘要都带软断点——否则 iced 只能按字形硬断,`gl_o` / `peration` 复现。 +/// +/// **抓得住**:`entry()` / `summary_block()` 又把原文直接喂给 `mono_owned`(软断点不见了); +/// **抓不住**:渲染器最终断在哪一列(那要量图,报告里有前后对照)。 +#[test] +fn log_text_carries_soft_breaks_but_keeps_the_original() { + let logs = demo::crash(); + let summary = wrap::breakable(&logs.summary()); + + assert!( + !summary.contains("gl_operation_failure"), + "摘要里没有软断点,长标识符会被按字形劈开:{summary}" + ); + assert_eq!( + wrap::strip(&summary), + logs.summary(), + "剪贴板拿到的仍是原文" + ); + + // 每条日志行的正文(拆前缀后的那一半)都要过同一件事:至少最长的那几行里有断点。 + let broken = logs + .lines + .iter() + .filter(|line| { + !wrap::breakable(lines::split_prefix(&line.text).1).contains(wrap::SOFT_BREAK) + }) + .count(); + assert!( + broken * 2 < logs.lines.len(), + "{} / {} 行日志没有任何软断点(断点表可能被掏空了)", + broken, + logs.lines.len() + ); +} + #[test] fn mono_lines_use_the_mono_stack_with_the_body_small_metrics() { let ui = ui(); diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index 1eb2c99..52ed29c 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -81,6 +81,44 @@ fn the_width_comparison_really_changes_the_layout() { assert!(narrow.secondary.is_none(), "700 不许再有列表列"); } +/// **M102 的对齐判据**:正文块左缘 = 页头标题左缘,块宽 = 可用宽度(不再二次居中)。 +/// +/// 页头标题的左缘 = `rail + gutter`(`frame::header_bar` 的 padding 与 `frame::page` 的 +/// 内容 padding 是同一个 `metrics.gutter`);正文块宽 = `frame::body_width`。 +/// +/// **抓得住**:谁把单列页的块宽改回 424(视觉复审 ①:左缘 422 对标题 112), +/// 或把块重新居中(左缘立刻偏离 `rail + gutter`); +/// **抓不住**:真实像素(`pages_demo` 的图与报告里的量测表负责)。 +#[test] +fn body_block_starts_at_the_header_title_and_fills_the_page() { + for width in [600.0, 840.0, 900.0, 1180.0] { + let metrics = frame_metrics(PaneKit::Single, width); + let title_x = layout::rail_width() + metrics.gutter; + let block = frame::body_width(metrics); + + assert_eq!( + block, metrics.available, + "{width}: 单列页块宽必须是可用宽度" + ); + assert!( + (title_x + block + metrics.gutter - width).abs() < f32::EPSILON, + "{width}: 块从 {title_x} 起、宽 {block},没铺到右页边距" + ); + // 块变宽不等于一句话变宽:散文列仍 ≤424 + assert!(metrics.column <= measure_cap_px() + f32::EPSILON); + } + + // 验收尺寸 1180:标题左缘 112、块宽 1044(旧版居中时正文左缘是 422) + let wide = frame_metrics(PaneKit::Single, 1180.0); + assert_eq!(layout::rail_width() + wide.gutter, 112.0, "页头标题左缘"); + assert_eq!(frame::body_width(wide), 1044.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); +} + /// **M84 的行宽红线**:任何页、任何窗口宽度、任何版面,正文列都不许超过 424px。 /// /// **抓得住**:`frame_metrics` 的 `column`(页面唯一的列宽来源)不会越界; From 824bb36f8ee2647f860a2a0c4179fe1edd1dfe88 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 22:41:41 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(app-iced):=20M102=20r2=20=E6=8A=A4?= =?UTF-8?q?=E6=A0=8F=E6=94=B9=E6=88=90=E3=80=8C=E6=8A=93=E5=BE=97=E4=BD=8F?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=8D=A2=E6=91=86=E6=B3=95=E3=80=8D=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=9D=97=E5=81=8F=E7=A7=BB/=E8=A1=8C=E8=B7=9D/?= =?UTF-8?q?=E6=95=A3=E6=96=87=E6=8E=A5=E7=BA=BF=E5=85=A8=E9=83=A8=E6=9C=89?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E7=BB=93=E6=9E=84=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 复审的两条 P1 都是假护栏:把修复原样回滚(A:frame::body 换回居中 424; C:内缩 X2→X1;D:行距加回 4px)后测试仍全绿,因为断言只喂 frame_metrics() 与常量重算, 既不观察 frame::body,也不引用页面常量。本轮把「摆法」做成可断言的东西: - 新增 `pages/layout/block.rs`:body_width(pane 宽封顶 1440)、body_offset(恒 0 = 贴左)、 prose_width(= metrics.column ≤424)三个纯量 + 单测。frame::body / frame::prose 从它们建盒, frame::body_width 改成 pub use(不另算一份)。 - 新增 `pages/layout/source.rs`(cfg(test)):把某个函数的实现文本切出来。iced 的 Element 没有几何内省,量不到「块摆在哪」,只能断言「照哪个数建的」;仓里已有 no_hardcoded_tokens 扫源码、download/tests 用 include_str! 扫 key 的先例。 - 新护栏(每条都做过变异实测,见报告): · frame_body_is_built_from_the_block_quantities(A 变异红) · frame_prose_is_built_from_the_prose_width(E 变异红) · list_column_spaces_rows_only_from_the_shared_constant(D 变异红) · instances 几何断言改用页面真源 LIST_BOTTOM_INSET.dp() / ROW_SPACING(C 变异红) · the_detail_note_goes_through_the_prose_box(散文接线) - P2:wrap::strip 的「会删原文自带 U+200B」写成限界 + 一条断言;docs 口径从 layout.rs 挪到 layout/block.rs(layout.rs 399 → 389)。 零像素变化:改后 9 张图与 r1 的 m102-after 逐张 cmp 完全相同;2560×760 新出三张证明块宽封顶 1440(112..1551)且仍贴左。 --- crates/app-iced/src/pages/frame.rs | 37 ++++---- crates/app-iced/src/pages/instances.rs | 34 +++++-- crates/app-iced/src/pages/instances/tests.rs | 78 +++++++++++++--- crates/app-iced/src/pages/layout.rs | 30 +++---- crates/app-iced/src/pages/layout/block.rs | 76 ++++++++++++++++ .../app-iced/src/pages/layout/block/tests.rs | 89 +++++++++++++++++++ crates/app-iced/src/pages/layout/source.rs | 39 ++++++++ .../app-iced/src/pages/layout/source/tests.rs | 48 ++++++++++ crates/app-iced/src/pages/layout/wrap.rs | 5 ++ .../app-iced/src/pages/layout/wrap/tests.rs | 19 ++++ crates/app-iced/src/pages/tests.rs | 81 +++++++++++++++-- 11 files changed, 469 insertions(+), 67 deletions(-) create mode 100644 crates/app-iced/src/pages/layout/block.rs create mode 100644 crates/app-iced/src/pages/layout/block/tests.rs create mode 100644 crates/app-iced/src/pages/layout/source.rs create mode 100644 crates/app-iced/src/pages/layout/source/tests.rs 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`(页面唯一的列宽来源)不会越界; From f499961e5baca3fa2dc88dd2bf5a40f70f49b363 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 12:47:18 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(app-iced):=20M102=20r3=20=E6=8A=A4?= =?UTF-8?q?=E6=A0=8F=E8=A1=A5=E5=88=B0=E3=80=8C=E9=A1=B5=E9=9D=A2=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=82=B9=E3=80=8D=E2=80=94=E2=80=94=E5=9B=9E=E9=80=80?= =?UTF-8?q?=20body/=E8=BD=AF=E6=96=AD=E7=82=B9=E6=8E=A5=E7=BA=BF=E4=B8=80?= =?UTF-8?q?=E5=BE=8B=E7=BA=A2=EF=BC=8C=E7=AD=89=E4=BB=B7=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=81=87=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 复审抓的两条 p1 都是「守卫只到函数内部」:把页面调用点改回 frame::single_column(变体 G)、 或把日志页两处 wrap::breakable 接线摘掉(变体 I),420 条全绿,而视觉复审 ① / ③ 原样回来。 r3 用 r2 自建的 layout/source.rs 把守卫推进到调用点,并把三条 p2 的假红/假强一并修掉: - 新增 `single_column_pages_call_the_body_box_not_the_centred_one`:四个页面文件 (download / logs / instances / instances/detail,共 7 个调用点)必须出现 `frame::body(`、 不许出现 `frame::single_column(`;narrow_detail 单独再断言一次。(变体 G → 红) - 新增 `the_page_wires_the_soft_breaks_into_both_rendering_paths`:扫 `lines::entry` 与 `diagnostic::summary_block`,必须含 `wrap::breakable(`(变体 I → 红); 同时把 `log_text_carries_soft_breaks_but_keeps_the_original` 的 docstring 改成它实际守的东西 (断点表 + 原文可还原),不再冒充「抓得住接线」。 - p2-1:`frame_body_is_built_from_the_block_quantities` 改用新的 `source::function_region`(函数体 + 本文件内被转调的函数,最多两层)⇒ 把 body 拆成 `body` → `body_impl` 的等价重构不再假红(H1 → 绿),换回 centered 仍红(A → 红)。 顺带修掉 region 的泛型签名 bug:早先拿 `fn {name}(` 当定义 needle, 遇到 `fn body_impl<'a>(` 会 panic(护栏自己假红);现在按 `fn {name}` 找定义,并补了一条 `the_region_follows_delegation_into_a_generic_function` 自测。 - p2-2:`list_column_spaces_rows_only_from_the_shared_constant` 的禁词收窄到 `let mut rows = column(…)` **这一条**构建链(新工具 `source::statement`,停在第一个 `;`), 于是 `column![head, scroller]` 上加合法间距不再假红(J → 绿),行容器叠间距仍红(D → 红)。 - p2-3:`frame::body` 的 align_x 改成「出现就必须是 `.align_x(Alignment::Start)`」, 不再被 `.align_y(Alignment::Start)` 顺手满足;「干脆不写 align_x」(默认 Start)视为等价重构放行。 - p2-4(措辞):`layout/block.rs` 文档写明 1440 上限是**有意行为变化**(≥1576 窗宽), 1180/900 上才是零像素变化。 变异矩阵(本轮自跑,逐条回滚,`git status` 已核):A/C/D/E/G/I 全红且各自只红对应那一条; H1(body_impl 转调)/H2b(删显式 align_x)/H3(ROW_SPACING 经局部变量)/J(head/scroller 加合法间距) 全绿。基线 `cargo test -p unlucky-app-iced --lib` = 426 passed / 0 failed。 --- crates/app-iced/src/pages/instances/tests.rs | 22 +++--- crates/app-iced/src/pages/layout/block.rs | 7 ++ crates/app-iced/src/pages/layout/source.rs | 65 ++++++++++++++++++ .../app-iced/src/pages/layout/source/tests.rs | 62 +++++++++++++++++ crates/app-iced/src/pages/logs/tests.rs | 36 ++++++++-- crates/app-iced/src/pages/tests.rs | 67 ++++++++++++++++--- 6 files changed, 238 insertions(+), 21 deletions(-) diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 9ed915d..2053713 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -57,25 +57,31 @@ fn list_viewport_fits_whole_rows_and_still_overflow() { ); } -/// **源码结构**:`list_column()` 的列表行只许用 [`ROW_SPACING`] 这一个间距真源。 +/// **源码结构**:列表**行容器**的构建链上不许叠页级间距,行距只能来自 [`ROW_SPACING`]。 /// -/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 直接加回 `list_column()`, -/// 上面那条几何断言照样绿——因为它算的是常量,看不见页面又叠了一层。iced 的 Element 又没有 -/// 内省接口,所以只能断言「这段代码里不许出现别的间距写法」。 +/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 直接加回 `list_column()` +/// 的行容器上,上面那条几何断言照样绿——它算的是常量,看不见页面又叠了一层。iced 的 Element +/// 又没有内省接口,所以只能断言这段代码的形状。 /// -/// **抓得住**:任何在列表行上写字面量 `.spacing(…)` 的页级附加量(视觉复审 ② 的原样); -/// **抓不住**:换个写法绕过(先赋给局部变量再 `.spacing`)——它守的是这段 5 行代码的契约。 +/// **禁词范围只到「`let mut rows = column(…);` 这一条语句链」**(r2 复审 P2-2 的订正): +/// 早先扫整个 `list_column()` 并禁一切 `.spacing(ui.space(Space::`,那样将来在 +/// `column![head, scroller]` 上加一条合法间距会**假红**。现在只钉行容器那条链。 +/// +/// **抓得住**:行容器上又写字面量 `.spacing(…)`(视觉复审 ② 的页级附加量,含「先存局部变量 +/// 再 `.spacing(gap)`」的写法);`ROW_SPACING` / `LIST_BOTTOM_INSET` 两个真源被摘掉; +/// **抓不住**:行距被算进别处再传进 `list_row()`(那要改组件几何,代码评审能看出来)。 #[test] fn list_column_spaces_rows_only_from_the_shared_constant() { let body = source::function_body(include_str!("../instances.rs"), "fn list_column"); + let rows_chain = source::statement(include_str!("../instances.rs"), "let mut rows = column("); assert!( body.contains("ROW_SPACING"), "列表行距必须从 ROW_SPACING 取,不许另写:\n{body}" ); assert!( - !body.contains(".spacing(ui.space(Space::"), - "`list_column()` 里又出现了字面量间距(M102 ② 的页级附加量):\n{body}" + !rows_chain.contains(".spacing("), + "行容器的构建链上又出现了间距(M102 ② 的页级附加量):\n{rows_chain}" ); assert!( body.contains("LIST_BOTTOM_INSET"), diff --git a/crates/app-iced/src/pages/layout/block.rs b/crates/app-iced/src/pages/layout/block.rs index 55e3138..7b4fae6 100644 --- a/crates/app-iced/src/pages/layout/block.rs +++ b/crates/app-iced/src/pages/layout/block.rs @@ -27,6 +27,13 @@ //! 贴左不变:块左缘仍是 `rail 88 + gutter 24 = 112`,封顶后**右侧留白**(不是居中)。 //! 这是有意的:页头标题在左,正文块跟着左缘;把块居中又会回到视觉复审 ① 的「内容缩在中间」。 //! +//! ## 这一层唯一的行为变化,以及它从哪一版开始 +//! +//! 封顶是 M102 r2 引入的**有意行为变化**:`≥ 1576` 窗宽(`112 + 1440 + 24`)下块宽不再跟随可用宽度。 +//! 在 1180 / 900 上它**不生效**——那两张验收图与 r1 逐字节相同(9/9 `cmp`)。所以对外口径是 +//! 「**≤1440 窗宽零像素变化;更宽的窗口按本上限有意改变**」,不是笼统的「零变化」。 +//! (`frame::body` 那行 `padding(left(body_offset))` 在同一轮加进来,值恒 0 ⇒ 像素中性。) +//! //! ## 偏移为什么是个常量函数而不是 `align_x` //! //! iced 的 `Element` **没有几何内省接口**,「块摆在哪」在单测里量不到。所以把「贴左」写成一个 diff --git a/crates/app-iced/src/pages/layout/source.rs b/crates/app-iced/src/pages/layout/source.rs index b406577..0a592e8 100644 --- a/crates/app-iced/src/pages/layout/source.rs +++ b/crates/app-iced/src/pages/layout/source.rs @@ -35,5 +35,70 @@ pub(crate) fn function_body<'a>(source: &'a str, signature: &str) -> &'a str { &rest[..end] } +/// 从 `start` 起到**第一个 `;`** 为止的一条语句(构建链断言用)。 +/// +/// 例如 `statement(instances.rs, "let mut rows = column(")` 拿到的是 +/// `let mut rows = column(...).padding(...).width(Length::Fill);` 这一整条链—— +/// 于是「这条链上不许叠间距」可以精确到**这一个**容器,不会连带禁掉旁边 `column![head, scroller]` +/// 上的合法间距。 +/// +/// # Panics +/// `start` 不存在时 panic(守卫的前置条件坏了要立刻红)。 +pub(crate) fn statement<'a>(source: &'a str, start: &str) -> &'a str { + let from = source + .find(start) + .unwrap_or_else(|| panic!("源码里找不到语句起点 {start:?}")); + + let rest = &source[from..]; + let end = rest.find(';').map_or(rest.len(), |index| index + 1); + &rest[..end] +} + +/// `signature` 的函数体 **+ 它内部转调的同文件函数体**(最多两层)。 +/// +/// 为什么不只切一个函数:**等价重构不该假红**。把 `frame::body` 拆成 +/// `body` → `body_impl` 两层再转调,盒子一个像素都没变,`function_body` 却会拿到三行转调桩、 +/// needle 全缺(r2 复审的变异 H1 就是这样假红的)。这里把「本文件内被它调用的函数」也算进契约, +/// 于是「拆一层再转调」放行,「换成居中盒子」照样红。 +/// +/// 边界:只跟**同一文件内**的转调(跨文件转调、先赋局部变量再建盒不在覆盖内,见调用点 docstring)。 +pub(crate) fn function_region(source: &str, signature: &str) -> String { + let mut region = function_body(source, signature).to_owned(); + + for _ in 0..2 { + let mut grew = false; + let mut cursor = 0; + + while let Some(found) = source[cursor..].find("fn ") { + let at = cursor + found + "fn ".len(); + cursor = at; + + let name: String = source[at..] + .chars() + .take_while(|ch| ch.is_alphanumeric() || *ch == '_') + .collect(); + if name.is_empty() { + continue; + } + + let called = format!("{name}("); + let defined = format!("fn {name}"); + if region.contains(&called) && !region.contains(&defined) && source.contains(&defined) { + region.push('\n'); + // `defined` 不带 `(`:签名可能带泛型(`fn body_impl<'a>(`), + // 带括号的 needle 会找不到定义、直接把护栏自己搞 panic。 + region.push_str(function_body(source, &defined)); + grew = true; + } + } + + if !grew { + break; + } + } + + region +} + #[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 index 9a8f7ff..85c5855 100644 --- a/crates/app-iced/src/pages/layout/source/tests.rs +++ b/crates/app-iced/src/pages/layout/source/tests.rs @@ -41,8 +41,70 @@ fn the_helper_also_works_on_signatures_with_generics_and_multiline_params() { assert!(gamma.trim_end().ends_with('}'), "{gamma}"); } +/// `statement` 停在第一个 `;`:构建链断言靠它精确到一个容器。 +#[test] +fn a_statement_stops_at_the_first_semicolon() { + let source = "\ +let mut rows = column(entries) + .padding(Padding::from([0.0, ui.space(Space::X2)])) + .width(Length::Fill); +let other = 1; +"; + let chain = statement(source, "let mut rows = column("); + assert!(chain.starts_with("let mut rows"), "{chain}"); + assert!(chain.ends_with(".width(Length::Fill);"), "{chain}"); + assert!(!chain.contains("let other"), "越过语句边界:{chain}"); +} + +/// `function_region` 把**本文件内被调用的函数**也算进契约(等价重构不假红)。 +#[test] +fn the_region_follows_one_level_of_delegation() { + let source = "\ +pub fn outer(a: u8) -> u8 { + inner(a) + 1 +} + +fn inner(a: u8) -> u8 { + a * 2 +} + +fn unrelated(a: u8) -> u8 { + a - 9 +} +"; + let region = function_region(source, "pub fn outer("); + assert!(region.contains("inner(a) + 1"), "{region}"); + assert!(region.contains("a * 2"), "转调目标没进来:{region}"); + assert!(!region.contains("a - 9"), "把无关函数也扫进来了:{region}"); +} + +/// 转调目标带**泛型签名**时也要跟进去(`frame::body` → `body_impl<'a>` 就是这种形状)。 +/// +/// 这条是变异 H1 抓出来的:早先的实现拿 `fn {name}(` 当定义 needle,遇到 +/// `fn body_impl<'a>(` 找不到 ⇒ 护栏自己 panic ⇒ 对等价重构**假红**。 +#[test] +fn the_region_follows_delegation_into_a_generic_function() { + let source = "\ +pub fn wrapper(v: u8) -> u8 { + helper(v) + 1 +} + +fn helper<'a>(v: u8) -> u8 { + v * 3 +} +"; + let region = function_region(source, "pub fn wrapper("); + assert!(region.contains("v * 3"), "泛型签名没跟进去:{region}"); +} + #[test] #[should_panic(expected = "找不到签名")] fn a_missing_signature_fails_loudly() { let _ = function_body(SAMPLE, "pub fn nope"); } + +#[test] +#[should_panic(expected = "找不到语句起点")] +fn a_missing_statement_start_fails_loudly() { + let _ = statement(SAMPLE, "let nope = 1"); +} diff --git a/crates/app-iced/src/pages/logs/tests.rs b/crates/app-iced/src/pages/logs/tests.rs index f5841f3..64a4f60 100644 --- a/crates/app-iced/src/pages/logs/tests.rs +++ b/crates/app-iced/src/pages/logs/tests.rs @@ -11,7 +11,7 @@ use unlucky_i18n::Lang; use crate::theme::Families; -use super::super::layout::{frame_metrics, measure_cap_px, wrap, PaneKit}; +use super::super::layout::{frame_metrics, measure_cap_px, source, wrap, PaneKit}; use super::model::{self, GROUPS, SUMMARY_HEADER}; use super::{demo, lines, *}; @@ -44,10 +44,13 @@ fn page_is_single_column_and_never_wider_than_the_measure_cap() { } } -/// **M102 ③**:日志行与一行摘要都带软断点——否则 iced 只能按字形硬断,`gl_o` / `peration` 复现。 +/// **M102 ③**:`wrap::breakable` 这张表本身是好的,且**只改显示**(原文能还原)。 /// -/// **抓得住**:`entry()` / `summary_block()` 又把原文直接喂给 `mono_owned`(软断点不见了); -/// **抓不住**:渲染器最终断在哪一列(那要量图,报告里有前后对照)。 +/// **抓得住**:断点表被掏空(`BREAK_AFTER` 少了一档 ⇒ 长标识符又没有断点)、 +/// `breakable` 开始污染原文(`strip` 还不了原); +/// **抓不住**:页面到底有没有把 `breakable` 接在渲染路径上——那条由 +/// [`the_page_wires_the_soft_breaks_into_both_rendering_paths`] 扫调用点守 +/// (r2 复审的变异 I:两处接线去掉后这条仍全绿,所以 docstring 不能替它吹牛)。 #[test] fn log_text_carries_soft_breaks_but_keeps_the_original() { let logs = demo::crash(); @@ -79,6 +82,31 @@ fn log_text_carries_soft_breaks_but_keeps_the_original() { ); } +/// **源码结构(调用点)**:日志页必须把 `wrap::breakable` 接在**两条渲染路径**上。 +/// +/// r2 复审的变异 I:把 `lines::entry()` 的 `mono_owned(ui, wrap::breakable(body), …)` 改回 +/// `mono(ui, body, …)`、把 `diagnostic::summary_block()` 的 `wrap::breakable(&logs.summary())` +/// 改回原文 ⇒ 420 条全绿,而视觉复审 ③(`graphics.gl_o` / `peration_failure`)原样回来。 +/// 断点表那侧已经 [`log_text_carries_soft_breaks_but_keeps_the_original`] 守着了, +/// 这里守的是**接线**:页面真把显示串过了一遍它。 +/// +/// **抓得住**:任一处接线被摘掉(日志行 / 一行摘要); +/// **抓不住**:接线写得对但断点表被掏空(那是上一条的事)。 +#[test] +fn the_page_wires_the_soft_breaks_into_both_rendering_paths() { + let entry = source::function_body(include_str!("lines.rs"), "fn entry"); + assert!( + entry.contains("wrap::breakable("), + "日志行没接软断点(`graphics.gl_operation_failure` 会被按字形劈开):\n{entry}" + ); + + let summary = source::function_body(include_str!("diagnostic.rs"), "fn summary_block"); + assert!( + summary.contains("wrap::breakable("), + "一行摘要没接软断点:\n{summary}" + ); +} + #[test] fn mono_lines_use_the_mono_stack_with_the_body_small_metrics() { let ui = ui(); diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index eacf9d4..6d7ca0f 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -138,24 +138,34 @@ fn body_block_starts_at_the_header_title_and_fills_the_page() { /// `centered(metrics.column, content)`(**就是视觉复审 ① 的改前写法**)不会红任何数值断言 —— /// M102 r1 的变异实测就是这样漏掉的(`pages/tests.rs` 那条只喂 `frame_metrics()` 与 /// `body_width()`)。所以这条盯的是**摆法**:宽度来自 `body_width(metrics)`、左内边距来自 -/// `body_offset(metrics)`、对齐 `Start`,且不出现居中/散文列宽。 +/// `body_offset(metrics)`、`align_x` 只许是 `Start`(不写 = container 默认 Start,等价), +/// 且不出现居中/散文列宽。 /// -/// **抓得住**:`body()` 被换回居中、`align_x` 改成 `Center`、宽度改回 `metrics.column`; -/// **抓不住**:绕过这段契约的重写(先取局部变量再建盒)——它守的是这 8 行的写法契约。 +/// 扫的是 [`layout::source::function_region`](函数体 + 本文件内被它转调的函数), +/// 所以「拆成 `body` → `body_impl` 两层再转调」这种**等价重构不假红**(r2 复审的变异 H1)。 +/// +/// **抓得住**:`body()` 被换回居中(变异 A)、宽度改回 `metrics.column`、`align_x` 改成 `Center`; +/// **抓不住**:跨文件转调、或先赋局部变量再建盒的写法(见 `layout/source.rs` 的边界说明)。 #[test] fn frame_body_is_built_from_the_block_quantities() { - let body = layout::source::function_body(include_str!("frame.rs"), "pub fn body<'a>("); + let body = layout::source::function_region(include_str!("frame.rs"), "pub fn body<'a>("); - for needle in [ - "body_width(metrics)", - "body_offset(metrics)", - "Alignment::Start", - ] { + for needle in ["body_width(metrics)", "body_offset(metrics)"] { assert!( body.contains(needle), "`frame::body` 必须从 {needle} 建盒(贴左 + 块宽 + 零偏移):\n{body}" ); } + + // `align_x` 出现就必须是 Start:`.align_y(Alignment::Start)` 单独存在不算数(r2 复审 P2-3), + // 而「干脆不写 align_x」(container 默认 Start)视为等价重构,放行。 + if let Some(at) = body.find(".align_x(") { + assert!( + body[at..].starts_with(".align_x(Alignment::Start)"), + "`frame::body` 的 align_x 只许是 Start:\n{body}" + ); + } + for forbidden in [ "centered(", "single_column(", @@ -188,6 +198,45 @@ fn frame_prose_is_built_from_the_prose_width() { } } +/// **源码结构(调用点)**:单列页必须调 `frame::body`,不许退回 `frame::single_column`。 +/// +/// r2 复审的变异 G:把四个页面文件里的 7 个调用点(`download.rs` 2 / `logs.rs` 3 / +/// `instances.rs` 1 / `instances/detail.rs` 1)一键改回 `frame::single_column` ⇒ 420 条全绿, +/// 而视觉复审 ① 原样回来。r2 的护栏只盯 `frame::body` **内部**,`single_column` 又是为设置页 +/// 合法保留的,所以「哪个页面用它」必须单独守。 +/// +/// 逐文件断言(而不是只数一条调用):**同一个文件里退一个调用点**也会让 +/// `frame::single_column(` 出现,立刻红。 +/// +/// **抓得住**:页面退回居中的老写法(视觉复审 ① 原样); +/// **抓不住**:新增的页面文件(它不在这个清单里——新页要自己登记)。 +#[test] +fn single_column_pages_call_the_body_box_not_the_centred_one() { + for (name, source) in [ + ("download.rs", include_str!("download.rs")), + ("logs.rs", include_str!("logs.rs")), + ("instances.rs", include_str!("instances.rs")), + ("instances/detail.rs", include_str!("instances/detail.rs")), + ] { + assert!( + source.contains("frame::body("), + "{name} 没有调用 frame::body —— 正文块不再从块宽/块偏移建盒" + ); + assert!( + !source.contains("frame::single_column("), + "{name} 退回了 frame::single_column(= 424 居中,视觉复审 ① 的原样)" + ); + } + + // 实例页窄窗那一屏的唯一入口也得走 body + let narrow = + layout::source::function_body(include_str!("instances/detail.rs"), "fn narrow_detail"); + assert!( + narrow.contains("frame::body("), + "narrow_detail 没走 frame::body:\n{narrow}" + ); +} + /// **M84 的行宽红线**:任何页、任何窗口宽度、任何版面,正文列都不许超过 424px。 /// /// **抓得住**:`frame_metrics` 的 `column`(页面唯一的列宽来源)不会越界; From 4b26a0a9dcdfaf2db91fe16e72c30490290d6bd3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 19:18:20 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(app-iced):=20M102=20r5=20=E8=A1=8C?= =?UTF-8?q?=E8=B7=9D=E6=8A=A4=E6=A0=8F=E8=BF=BD=E7=BB=91=E5=AE=9A=EF=BC=88?= =?UTF-8?q?M4=20=E5=81=87=E7=BB=BF=E5=81=9A=E6=AD=BB=EF=BC=89+=20=E7=AD=89?= =?UTF-8?q?=E4=BB=B7=E9=87=8D=E6=9E=84=E4=B8=8D=E5=86=8D=E5=81=87=E7=BA=A2?= =?UTF-8?q?=EF=BC=88M3/M6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r4 复审的结论是「产品代码没问题,两条 p1 都在护栏的『抓得住』与实测不符」: - **M4(真正的假绿,做死)**:`rows = rows.spacing(ui.space(Space::X1));` 只要另起一条语句 就能绕过「只锁构建链」的旧护栏。现在 `list_column_spaces_rows_only_from_the_shared_constant` 用 `function_region` 取整段函数,再用 `source::call_arguments` 拿每一处 `rows.spacing(…)` 的实参,并用新增的 `source::derived_from` 顺 `let` / `Some(x) =` 绑定往回追(最多三跳): 实参必须追回 `ROW_SPACING`。于是 · 直接 `ui.space(spacing)`(来自 `if let Some(spacing) = ROW_SPACING`)→ 绿; · `let spacing = ROW_SPACING;` 再中转(H3)→ 绿; · 先把令牌挪进 `let extra = Space::X1;` 再 `rows.spacing(ui.space(extra))`(M4)→ 红。 - **M3(假红)**:日志接线护栏改用 `function_region`,于是 `wrap::breakable` 外面套一层同义 包装(`fn soft(…) { mono_owned(ui, wrap::breakable(…)) }`)不再假红;整条链路都没碰 `wrap::breakable` 照样红。 - **M6(假红)**:新增 `source::region_across`(多文件转调跟踪,`frame.rs` + `layout/block.rs`), 把盒子原样搬进 `block::body_box` 再转调不再假红;`align_x` 改判**实参**(`Alignment::Start` 与 `iced::Alignment::Start` 同等放行),于是搬进别的模块后路径写全也不假红。 - **M1/M2 按塔的裁定不加断言**:与 `frame::body` 逐像素等价的另写就是等价重构;docstring 写明 「抓得住:语义变化;抓不住:逐像素等价的另写」。 最终变异矩阵(本轮自跑、每条跑完 `git checkout --` 回滚、白名单已核): 基线 429 passed / 0 failed;M4 红(只红 list_column_spaces_rows_only_from_the_shared_constant); M3 绿;M6 绿;G 红(只红 single_column_pages_call_the_body_box_not_the_centred_one); I 红(只红 the_page_wires_the_soft_breaks_into_both_rendering_paths); 旧 p1 复跑 A 红、D 红,等价重构 J 绿。 --- crates/app-iced/src/pages/instances/tests.rs | 77 +++++++-- crates/app-iced/src/pages/layout/source.rs | 155 ++++++++++++++++-- .../app-iced/src/pages/layout/source/tests.rs | 68 ++++++++ crates/app-iced/src/pages/logs/tests.rs | 8 +- crates/app-iced/src/pages/tests.rs | 30 +++- 5 files changed, 299 insertions(+), 39 deletions(-) diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 2053713..88629a9 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -57,36 +57,81 @@ fn list_viewport_fits_whole_rows_and_still_overflow() { ); } -/// **源码结构**:列表**行容器**的构建链上不许叠页级间距,行距只能来自 [`ROW_SPACING`]。 +/// **源码结构**:`list_column()` 里对行容器的间距**只能**来自 [`ROW_SPACING`]。 /// -/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 直接加回 `list_column()` -/// 的行容器上,上面那条几何断言照样绿——它算的是常量,看不见页面又叠了一层。iced 的 Element -/// 又没有内省接口,所以只能断言这段代码的形状。 +/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 加回行容器后, +/// 几何断言照样绿——它算的是常量,看不见页面又叠了一层。iced 的 Element 又没有内省接口, +/// 所以只能断言这段代码的形状。 /// -/// **禁词范围只到「`let mut rows = column(…);` 这一条语句链」**(r2 复审 P2-2 的订正): -/// 早先扫整个 `list_column()` 并禁一切 `.spacing(ui.space(Space::`,那样将来在 -/// `column![head, scroller]` 上加一条合法间距会**假红**。现在只钉行容器那条链。 +/// ## 两层判据(r4 复审的 M4 订正) /// -/// **抓得住**:行容器上又写字面量 `.spacing(…)`(视觉复审 ② 的页级附加量,含「先存局部变量 -/// 再 `.spacing(gap)`」的写法);`ROW_SPACING` / `LIST_BOTTOM_INSET` 两个真源被摘掉; -/// **抓不住**:行距被算进别处再传进 `list_row()`(那要改组件几何,代码评审能看出来)。 +/// | 层 | 判据 | 抓的是 | +/// |---|---|---| +/// | ① 构建链 | `let mut rows = column(…);` 这条语句链上不许有 `.spacing(` | 直接写在链上的间距(变异 D) | +/// | ② 语句序列 | `list_column()` 里**每一处** `rows.spacing(…)` 的实参都要**顺绑定追回 `ROW_SPACING`** | **另起一条语句**再叠间距(变异 M4,含「先把令牌挪进一条 `let`」的写法) | +/// +/// 为什么②要追绑定而不是比字面:合法写法有几种形状——`rows = rows.spacing(ui.space(spacing))` +/// 直接取自 `if let Some(spacing) = ROW_SPACING`,或先 `let spacing = ROW_SPACING;` 再中转 +/// (变异 H3)。`layout/source.rs::derived_from` 顺 `let` / `Some(…) =` 两种绑定往回追最多三跳, +/// 于是「真源」放行、「中转了一层 `Space::X1`」判否。 +/// +/// **抓得住**:构建链上的字面量间距(D)、另起语句的行距(M4)、`ROW_SPACING` / `LIST_BOTTOM_INSET` +/// 两个真源被摘掉;`column![head, scroller]` 上的合法间距不受影响(变异 J,绿); +/// **抓不住**:把间距算进别处再以非令牌形式传进 `list_row()`(那要改组件几何,代码评审能看出来)、 +/// 以及绕过 `rows.spacing(…)` 这个调用形状的写法。 #[test] fn list_column_spaces_rows_only_from_the_shared_constant() { - let body = source::function_body(include_str!("../instances.rs"), "fn list_column"); - let rows_chain = source::statement(include_str!("../instances.rs"), "let mut rows = column("); + let file = include_str!("../instances.rs"); + let body = source::function_region(file, "fn list_column"); + let rows_chain = source::statement(file, "let mut rows = column("); assert!( body.contains("ROW_SPACING"), "列表行距必须从 ROW_SPACING 取,不许另写:\n{body}" ); - assert!( - !rows_chain.contains(".spacing("), - "行容器的构建链上又出现了间距(M102 ② 的页级附加量):\n{rows_chain}" - ); assert!( body.contains("LIST_BOTTOM_INSET"), "底部内缩必须从 LIST_BOTTOM_INSET 取:\n{body}" ); + + // ① 行容器的构建链上不许叠间距(`column![head, scroller]` 上的合法间距不在这条链里) + assert!( + !rows_chain.contains(".spacing("), + "行容器的构建链上又出现了间距(M102 ② 的页级附加量):\n{rows_chain}" + ); + + // ② 语句序列:每一处 `rows.spacing(…)` 的实参都必须**顺绑定追回 `ROW_SPACING`** + // (直接用它、或 `let spacing = ROW_SPACING` 再中转都算;先 `let extra = Space::X1;` + // 再 `rows.spacing(ui.space(extra))` 则红——r4 复审的变异 M4) + let applied = source::call_arguments(&body, "rows.spacing("); + assert!( + !applied.is_empty(), + "`list_column()` 里没有把 ROW_SPACING 应用到行容器的调用:\n{body}" + ); + for argument in &applied { + let ident = argument + .split_once("ui.space(") + .map(|(_, rest)| rest) + .unwrap_or(argument); + let ident: String = ident + .chars() + .take_while(|ch| ch.is_alphanumeric() || *ch == '_') + .collect(); + + assert!( + argument.contains("ui.space("), + "行容器的间距实参必须走令牌栅格(`ui.space(…)`),收到 {argument:?}" + ); + assert!( + !argument.contains("Space::"), + "行容器的间距实参里直接写了令牌档位:{argument:?}\n{body}" + ); + assert!( + source::derived_from(&body, &ident, "ROW_SPACING"), + "行容器的间距不是从 ROW_SPACING 来的(`{ident}` 追不到 ROW_SPACING)——\ + M102 ② 的页级附加量不许另起一条语句塞回来:{argument:?}\n{body}" + ); + } } /// **散文接线**:详情页的正文段必须进 `frame::prose`(424),不许直接塞进 `frame::body`(1044)。 diff --git a/crates/app-iced/src/pages/layout/source.rs b/crates/app-iced/src/pages/layout/source.rs index 0a592e8..28e74f7 100644 --- a/crates/app-iced/src/pages/layout/source.rs +++ b/crates/app-iced/src/pages/layout/source.rs @@ -54,19 +54,47 @@ pub(crate) fn statement<'a>(source: &'a str, start: &str) -> &'a str { &rest[..end] } -/// `signature` 的函数体 **+ 它内部转调的同文件函数体**(最多两层)。 +/// `signature` 的函数体 **+ 它内部转调的函数体**(同文件,最多两层)。 /// /// 为什么不只切一个函数:**等价重构不该假红**。把 `frame::body` 拆成 /// `body` → `body_impl` 两层再转调,盒子一个像素都没变,`function_body` 却会拿到三行转调桩、 -/// needle 全缺(r2 复审的变异 H1 就是这样假红的)。这里把「本文件内被它调用的函数」也算进契约, +/// needle 全缺(r2 复审的变异 H1 就是这样假红的)。这里把「被它调用的函数」也算进契约, /// 于是「拆一层再转调」放行,「换成居中盒子」照样红。 /// -/// 边界:只跟**同一文件内**的转调(跨文件转调、先赋局部变量再建盒不在覆盖内,见调用点 docstring)。 +/// 跨文件转调用 [`region_across`]。两者都不跟「先赋局部变量再建盒」的写法。 pub(crate) fn function_region(source: &str, signature: &str) -> String { - let mut region = function_body(source, signature).to_owned(); + region_across(&[source], signature) +} + +/// 多文件版的 [`function_region`]:[`expand`] 会在给定的**所有**文件里按名字找转调目标。 +/// +/// 存在的理由(r4 复审的变异 M6):把盒子原样搬进 `layout/block.rs::body_box` 再由 +/// `frame::body` 转调,是**等价重构**,单文件版会把 needle 全丢掉并假红。 +/// 现在把 `frame.rs` 与 `layout/block.rs` 一起交进来,搬运不再被惩罚。 +/// +/// # Panics +/// `signature` 在任何一个文件里都找不到时 panic(护栏前置条件坏了要立刻红)。 +pub(crate) fn region_across(sources: &[&str], signature: &str) -> String { + let mut region = sources + .iter() + .find(|source| source.contains(signature)) + .map(|source| function_body(source, signature).to_owned()) + .unwrap_or_else(|| panic!("这几个文件里都找不到签名 {signature:?}")); for _ in 0..2 { - let mut grew = false; + if !expand(&mut region, sources) { + break; + } + } + + region +} + +/// 把 `region` 里出现、且定义在 `sources` 某处的函数体补进来。返回是否有增长。 +fn expand(region: &mut String, sources: &[&str]) -> bool { + let mut grew = false; + + for source in sources { let mut cursor = 0; while let Some(found) = source[cursor..].find("fn ") { @@ -81,23 +109,126 @@ pub(crate) fn function_region(source: &str, signature: &str) -> String { continue; } - let called = format!("{name}("); + // `defined` 不带 `(`:签名可能带泛型(`fn body_impl<'a>(`), + // 带括号的 needle 会找不到定义、直接把护栏自己搞 panic。 let defined = format!("fn {name}"); - if region.contains(&called) && !region.contains(&defined) && source.contains(&defined) { + if region.contains(&format!("{name}(")) + && !region.contains(&defined) + && source.contains(&defined) + { region.push('\n'); - // `defined` 不带 `(`:签名可能带泛型(`fn body_impl<'a>(`), - // 带括号的 needle 会找不到定义、直接把护栏自己搞 panic。 region.push_str(function_body(source, &defined)); grew = true; } } + } - if !grew { - break; + grew +} + +/// `haystack` 里每一处 `callee(…)` 调用的**实参文本**(不含括号本身)。 +/// +/// 括号按深度配对,所以 `ui.space(Space::X1)` 这种嵌套实参会完整取到 `ui.space(Space::X1)`; +/// 找不到闭合括号时取到文本末尾(宁可让断言看见多余文本,也不静默返回空)。 +pub(crate) fn call_arguments<'a>(haystack: &'a str, callee: &str) -> Vec<&'a str> { + let mut found = Vec::new(); + let mut from = 0; + + while let Some(index) = haystack[from..].find(callee) { + let start = from + index + callee.len(); + let mut depth = 1usize; + let mut end = haystack.len(); + + for (offset, ch) in haystack[start..].char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = start + offset; + break; + } + } + _ => {} + } + } + + found.push(&haystack[start..end]); + from = end.max(start); + } + + found +} + +/// 顺绑定链往回追:`ident` 是否(直接或经至多 3 跳中转)来自 `root`。 +/// +/// 认两种绑定形状(本仓就这两种): +/// +/// - `let = ;` +/// - `if let Some() = {`(`while let` 同理,形状一样) +/// +/// 为什么需要它(r4 复审的变异 M4):把写死的令牌先挪到一条 `let extra = Space::X1;`, +/// 再 `rows = rows.spacing(ui.space(extra));` —— 只看实参文本「有没有 `Space::`」会漏掉它。 +/// 顺着绑定追回源头就能区分「真的来自 `ROW_SPACING`(含 `let spacing = ROW_SPACING` 再中转, +/// 变异 H3)」与「中转了一层写死令牌」。 +pub(crate) fn derived_from(body: &str, ident: &str, root: &str) -> bool { + let mut frontier = vec![ident.to_owned()]; + + for _ in 0..3 { + let mut next = Vec::new(); + + for name in &frontier { + for rhs in binding_rhs(body, name) { + if rhs.contains(root) { + return true; + } + + next.extend( + rhs.split(|ch: char| !(ch.is_alphanumeric() || ch == '_')) + .filter(|token| { + token + .chars() + .next() + .is_some_and(|first| first.is_alphabetic()) + }) + .map(str::to_owned), + ); + } + } + + if next.is_empty() { + return false; + } + frontier = next; + } + + false +} + +/// `ident` 在 `body` 里的绑定表达式(`let` 与 `Some(ident) =` 两种形状)。 +fn binding_rhs<'a>(body: &'a str, ident: &str) -> Vec<&'a str> { + let mut rhs = Vec::new(); + + for pattern in [format!("let {ident} = "), format!("let mut {ident} = ")] { + let mut from = 0; + while let Some(index) = body[from..].find(&pattern) { + let start = from + index + pattern.len(); + let end = body[start..].find(';').map_or(body.len(), |at| start + at); + rhs.push(&body[start..end]); + from = end; } } - region + let pattern = format!("Some({ident}) = "); + let mut from = 0; + while let Some(index) = body[from..].find(&pattern) { + let start = from + index + pattern.len(); + let end = body[start..].find('{').map_or(body.len(), |at| start + at); + rhs.push(&body[start..end]); + from = end; + } + + rhs } #[cfg(test)] diff --git a/crates/app-iced/src/pages/layout/source/tests.rs b/crates/app-iced/src/pages/layout/source/tests.rs index 85c5855..007d71b 100644 --- a/crates/app-iced/src/pages/layout/source/tests.rs +++ b/crates/app-iced/src/pages/layout/source/tests.rs @@ -103,6 +103,74 @@ fn a_missing_signature_fails_loudly() { let _ = function_body(SAMPLE, "pub fn nope"); } +/// 跨文件转调也要跟进去(`frame::body` → `layout::block::body_box` 就是这种形状)。 +/// +/// 变异 M6(把盒子原样搬进 `layout/block.rs`)在单文件版下假红;多文件版应当放行。 +#[test] +fn the_region_follows_delegation_across_files() { + let frame = "\ +pub fn body(a: u8) -> u8 { + block::body_box(a) + 1 +} +"; + let block = "\ +pub fn body_box(a: u8) -> u8 { + a * 7 +} +"; + let region = region_across(&[frame, block], "pub fn body("); + assert!(region.contains("body_box(a) + 1"), "{region}"); + assert!(region.contains("a * 7"), "跨文件转调没跟进去:{region}"); +} + +/// `call_arguments` 取到的是**实参原文**(嵌套括号完整),这是 M4 那条护栏的判据基础。 +#[test] +fn call_arguments_returns_the_nested_argument_text() { + let source = "\ +rows = rows.spacing(ui.space(Space::X1)); +rows = rows.spacing(ui.space(step)); +column![head, scroller].spacing(ui.space(Space::X2)); +"; + let args = call_arguments(source, "rows.spacing("); + assert_eq!(args, vec!["ui.space(Space::X1)", "ui.space(step)"]); + assert!( + args[0].contains("Space::") && !args[1].contains("Space::"), + "实参文本要能区分「写死令牌」与「经变量中转」:{args:?}" + ); +} + +/// `derived_from` 顺绑定链追源头:真源放行、中转一层写死令牌判否(变异 M4 的判据)。 +#[test] +fn binding_tracing_separates_the_truth_source_from_a_smuggled_token() { + let honest = "\ + if let Some(spacing) = ROW_SPACING { + rows = rows.spacing(ui.space(spacing)); + } +"; + assert!(derived_from(honest, "spacing", "ROW_SPACING")); + + // 经局部变量中转一次(变异 H3 的等价重构形状):仍要追到 ROW_SPACING + let via_local = "\ + let spacing = ROW_SPACING; + if let Some(step) = spacing { + rows = rows.spacing(ui.space(step)); + } +"; + assert!(derived_from(via_local, "step", "ROW_SPACING")); + + // 把写死令牌挪到另一条 let 再传(变异 M4):追不到 ROW_SPACING + let smuggled = "\ + if let Some(spacing) = ROW_SPACING { + rows = rows.spacing(ui.space(spacing)); + } + + let extra = Space::X1; + rows = rows.spacing(ui.space(extra)); +"; + assert!(!derived_from(smuggled, "extra", "ROW_SPACING")); + assert!(derived_from(smuggled, "spacing", "ROW_SPACING")); +} + #[test] #[should_panic(expected = "找不到语句起点")] fn a_missing_statement_start_fails_loudly() { diff --git a/crates/app-iced/src/pages/logs/tests.rs b/crates/app-iced/src/pages/logs/tests.rs index 64a4f60..7cf4793 100644 --- a/crates/app-iced/src/pages/logs/tests.rs +++ b/crates/app-iced/src/pages/logs/tests.rs @@ -92,15 +92,19 @@ fn log_text_carries_soft_breaks_but_keeps_the_original() { /// /// **抓得住**:任一处接线被摘掉(日志行 / 一行摘要); /// **抓不住**:接线写得对但断点表被掏空(那是上一条的事)。 +/// +/// 用 [`source::function_region`] 而不是 `function_body`(r4 复审的变异 M3):把 +/// `wrap::breakable` 外面套一层同义包装(`fn soft(…) { wrap::breakable(…) }`)再调用,是 +/// **等价重构**,不该假红;而「整条链路都没碰 `wrap::breakable`」照样红。 #[test] fn the_page_wires_the_soft_breaks_into_both_rendering_paths() { - let entry = source::function_body(include_str!("lines.rs"), "fn entry"); + let entry = source::function_region(include_str!("lines.rs"), "fn entry"); assert!( entry.contains("wrap::breakable("), "日志行没接软断点(`graphics.gl_operation_failure` 会被按字形劈开):\n{entry}" ); - let summary = source::function_body(include_str!("diagnostic.rs"), "fn summary_block"); + let summary = source::function_region(include_str!("diagnostic.rs"), "fn summary_block"); assert!( summary.contains("wrap::breakable("), "一行摘要没接软断点:\n{summary}" diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index 6d7ca0f..39e40fe 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -141,14 +141,19 @@ fn body_block_starts_at_the_header_title_and_fills_the_page() { /// `body_offset(metrics)`、`align_x` 只许是 `Start`(不写 = container 默认 Start,等价), /// 且不出现居中/散文列宽。 /// -/// 扫的是 [`layout::source::function_region`](函数体 + 本文件内被它转调的函数), -/// 所以「拆成 `body` → `body_impl` 两层再转调」这种**等价重构不假红**(r2 复审的变异 H1)。 +/// 扫的是 [`layout::source::region_across`]:`frame.rs` **加上** `layout/block.rs`, +/// 于是「拆成 `body` → `body_impl`」(变异 H1)与「把盒子原样搬进 `block::body_box`」(变异 M6) +/// 这两种**等价重构都不假红**,而「换成居中 424 盒子」照样红(变异 A)。 /// -/// **抓得住**:`body()` 被换回居中(变异 A)、宽度改回 `metrics.column`、`align_x` 改成 `Center`; -/// **抓不住**:跨文件转调、或先赋局部变量再建盒的写法(见 `layout/source.rs` 的边界说明)。 +/// **抓得住**:盒子的**语义**变了(居中 / 用散文列宽 / 用 `centered`); +/// **抓不住**:`frame::centered(metrics.column, …)` 这种**与 `frame::body` 逐像素等价**的另写 +/// (r4 复审的 M1/M2 —— 按塔的裁定那是等价重构,护栏不该为此红),也不抓「先赋局部变量再建盒」。 #[test] fn frame_body_is_built_from_the_block_quantities() { - let body = layout::source::function_region(include_str!("frame.rs"), "pub fn body<'a>("); + let body = layout::source::region_across( + &[include_str!("frame.rs"), include_str!("layout/block.rs")], + "pub fn body<'a>(", + ); for needle in ["body_width(metrics)", "body_offset(metrics)"] { assert!( @@ -159,10 +164,12 @@ fn frame_body_is_built_from_the_block_quantities() { // `align_x` 出现就必须是 Start:`.align_y(Alignment::Start)` 单独存在不算数(r2 复审 P2-3), // 而「干脆不写 align_x」(container 默认 Start)视为等价重构,放行。 - if let Some(at) = body.find(".align_x(") { + // 判的是**实参**而不是整段文本,所以 `Alignment::Start` 与 `iced::Alignment::Start` 同等放行 + // (把盒子搬进别的模块后路径要写全,那是等价重构,不该假红 —— r4 复审的变异 M6)。 + for argument in layout::source::call_arguments(&body, ".align_x(") { assert!( - body[at..].starts_with(".align_x(Alignment::Start)"), - "`frame::body` 的 align_x 只许是 Start:\n{body}" + argument.trim().ends_with("Alignment::Start"), + "`frame::body` 的 align_x 只许是 Start,收到 {argument:?}:\n{body}" ); } @@ -182,9 +189,14 @@ fn frame_body_is_built_from_the_block_quantities() { /// **源码结构**:`frame::prose` 的宽度必须来自 `prose_width`(= `metrics.column`),不来自块宽。 /// /// M102 把两个量拆开后,「把散文塞进 1044 的块」不会红任何数值断言;这条盯接线。 +/// 与 `frame::body` 那条一样用 [`layout::source::region_across`](`frame.rs` + `layout/block.rs`), +/// 所以把盒子搬进 `layout/block.rs` 的等价重构不假红;抓的是**语义**(用块宽/可用宽度就红)。 #[test] fn frame_prose_is_built_from_the_prose_width() { - let prose = layout::source::function_body(include_str!("frame.rs"), "pub fn prose<'a>("); + let prose = layout::source::region_across( + &[include_str!("frame.rs"), include_str!("layout/block.rs")], + "pub fn prose<'a>(", + ); assert!( prose.contains("prose_width(metrics)"),