Merge branch 'feat/m102-pagelayout-fix-column-alignment-row'
This commit is contained in:
commit
78bcc3038b
19 files changed
+1354
-62
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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::<Vec<_>>(),
|
||||
)
|
||||
.spacing(ui.space(ROW_GAP))
|
||||
.padding(Padding::from([0.0, ui.space(Space::X2)]))
|
||||
.width(Length::Fill);
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,26 @@
|
||||
//!
|
||||
//! ## 边界
|
||||
//!
|
||||
//! 这里只放「页面级」的东西:页头、页边距、内容列、两列骨架、空态位的摆放。
|
||||
//! 这里只放「页面级」的东西:页头、页边距、内容块、两列骨架、空态位的摆放。
|
||||
//! 具体控件(卡片 / 分隔线 / 文本按钮 / 图标)在 [`pieces`],它们是**占位实现**
|
||||
//! (同一份令牌,没有完整状态机),等 M75 的 `components/` 家族合入后由另一条 mission 换掉。
|
||||
//!
|
||||
//! ## 对齐口径(M102):页头标题左缘 = 正文块左缘
|
||||
//!
|
||||
//! 页头带与内容区共用同一个 `gutter`(`Padding::from([0.0, metrics.gutter])` / `padding(gutter)`),
|
||||
//! 所以页头标题的左缘 = `rail 88 + gutter 24 = 112`。正文块必须从**同一条线**起:
|
||||
//!
|
||||
//! - 单列页用 [`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`)
|
||||
//!
|
||||
//! | 部件 | 参考 | 落点 |
|
||||
@@ -38,7 +54,7 @@ use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::layout::{self, FrameMetrics};
|
||||
use super::layout::{self, block, FrameMetrics};
|
||||
use super::Message;
|
||||
|
||||
/// 页头操作区之间的间隔(`Space::X2`)。
|
||||
@@ -139,6 +155,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<Element<'a, Message>>,
|
||||
@@ -146,6 +166,44 @@ pub fn single_column<'a>(
|
||||
centered(metrics.column, content)
|
||||
}
|
||||
|
||||
/// **页面块宽度**:pane 宽(单列 = 可用宽度 / 两列 = 主列 pane)封顶 1440。
|
||||
///
|
||||
/// 真源在 [`super::layout::block::body_width`](纯量 + 单测);这里只是转发,**不另算一份**。
|
||||
/// 与 [`FrameMetrics::column`](一句话多宽,≤424)是两个量。
|
||||
pub use super::layout::block::body_width;
|
||||
|
||||
/// **正文块**:从页边距起(= 页头标题那条左缘)、宽 [`body_width`]、左内边距 [`body_offset`]。
|
||||
///
|
||||
/// 盒子**从纯量建**:宽度取 [`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<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
container(content)
|
||||
.width(Length::Fixed(body_width(metrics)))
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Start)
|
||||
.align_y(Alignment::Start)
|
||||
.padding(Padding::default().left(block::body_offset(metrics)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// **散文块**:宽度锁在 [`super::layout::block::prose_width`](= `metrics.column`,≤424 ⇒ 一行 ≤72 字符)。
|
||||
///
|
||||
/// 只包「成段读的文字」(实例详情正文那样的 prose);卡片、列表、Tabs 吃的是 [`body_width`],
|
||||
/// 它们的宽度不承担行宽承诺(列宽只影响每行放几个字段,不影响读一句话的舒适度)。
|
||||
pub fn prose<'a>(
|
||||
metrics: FrameMetrics,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
container(content)
|
||||
.width(Length::Fixed(block::prose_width(metrics)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 两列版面:左列 `secondary`(独立滚动区由调用方自己加 `scrollable`)+ 右列 `main`。
|
||||
///
|
||||
/// 两列都**显式**取布局系统给的宽度,不吃 Fill:列表列必须固定 336,
|
||||
|
||||
@@ -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,23 @@
|
||||
//! 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**(真源 [`ROW_SPACING`] = `None`) | 行节奏回到组件的 60 |
|
||||
//! | 滚动区底部内缩 | 0 → **`Space::X2` 8**(真源 [`LIST_BOTTOM_INSET`]) | 切点落在卡片的内边距带里,不压描边 |
|
||||
//!
|
||||
//! 于是可视区 = 580 − 列头 32 − 内缩 8 = **540 = 9 × 60**,正好 9 整行:末行完整可见,
|
||||
//! 第 10 行整体落在裁切线之外。两个数都**只有一个真源**,页面与护栏都从它取
|
||||
//! (`instances/tests.rs` 引 [`LIST_BOTTOM_INSET`].`dp()` 与 [`ROW_SPACING`] 算 pitch,
|
||||
//! 另有一条源码结构断言盯着 `list_column()` 不许再叠字面量间距)。
|
||||
//! 列表内容 12×60 = 720 仍高过可视区,独立滚动条照样可见。
|
||||
|
||||
mod data;
|
||||
mod demo;
|
||||
@@ -60,8 +77,19 @@ 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;
|
||||
/// 列表行距:**`None` = 这一层不加间距**(页面与护栏**共用这一个真源**)。
|
||||
///
|
||||
/// M102 的 ② 就是页级附加量:`ListItem` 自己的 60 高已含上下 8px 内边距,页面再叠一道
|
||||
/// `Space::X1`(4) 就把行节奏变成 64、与可视高度除不尽(1180×760 下末行被切半行)。
|
||||
/// 所以行距不写成字面量、也不散落在 `list_column` 里,而是这个常量——
|
||||
/// 页面 `match` 它,`instances/tests.rs` 也从它算 pitch(改这里,测试立刻红)。
|
||||
pub(super) const ROW_SPACING: Option<Space> = 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> {
|
||||
@@ -95,7 +123,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)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -139,17 +167,22 @@ fn list_column<'a>(
|
||||
left: ui.space(Space::X3),
|
||||
});
|
||||
|
||||
let rows = column(
|
||||
let mut rows = column(
|
||||
block
|
||||
.rows
|
||||
.iter()
|
||||
.map(|entry| list_row(ui, entry, is_selected(block, entry), forced))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(ui.space(ROW_GAP))
|
||||
.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(
|
||||
@@ -161,9 +194,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,17 +1,154 @@
|
||||
//! `instances` 的断言:演示数据与窄窗退化。
|
||||
//! `instances` 的断言:演示数据、窄窗退化,以及**页面摆法**(源码结构断言)。
|
||||
use super::*;
|
||||
|
||||
use super::super::layout::source;
|
||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||||
|
||||
/// **M102 几何判据**:列表可视区正好放下整数行——末行不会被卡片底边切半行。
|
||||
///
|
||||
/// 算式里的三个页面真源**直接引用**(不写字面量),所以改页面常量会让这条立刻红:
|
||||
/// [`LIST_BOTTOM_INSET`](滚动区底部内缩)、[`ROW_SPACING`](页级行距,`None` = 0)。
|
||||
///
|
||||
/// ```text
|
||||
/// 卡片高 = 760 − 标题栏 48 − 两行页头 84 − 2×gutter 24 = 580
|
||||
/// 列头 = 上 12 + LabelMedium 16 + 下 4 = 32
|
||||
/// 内缩 = LIST_BOTTOM_INSET = Space::X2 = 8
|
||||
/// 可视区 = 580 − 32 − 8 = 540
|
||||
/// 行高 = ListItem 两行档 44 + 16 = 60
|
||||
/// pitch = 行高 + ROW_SPACING(None ⇒ 0) = 60
|
||||
/// 整行数 = 540 / 60 = 9 行整
|
||||
/// ```
|
||||
///
|
||||
/// **抓得住**:页面把内缩换档(`LIST_BOTTOM_INSET` → `X1` 立刻除不尽)、行距真源被改、
|
||||
/// 列头算错、演示数据被缩到不够滚(独立滚动条消失);
|
||||
/// **抓不住**:`list_column()` 里被另写一层 `.spacing(…)`(那一条由
|
||||
/// `list_column_spaces_rows_only_from_the_shared_constant` 扫源码守)。
|
||||
#[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 - LIST_BOTTOM_INSET.dp();
|
||||
assert_eq!(viewport, 540.0, "列表滚动区高度必须以页面的内缩真源算");
|
||||
|
||||
// 页级行距的**唯一真源**:None = 这一层不加间距
|
||||
let gap = ROW_SPACING.map_or(0.0, Space::dp);
|
||||
let row = lm.row_height + Space::X4.dp();
|
||||
let pitch = row + gap;
|
||||
assert_eq!(pitch, 60.0, "行节奏 = ListItem 两行档 60(不留页级行距)");
|
||||
assert_eq!(
|
||||
viewport % pitch,
|
||||
0.0,
|
||||
"可视区 {viewport} 不是 {pitch} 的整数倍 ⇒ 末行会被切半行(ROW_SPACING={ROW_SPACING:?})"
|
||||
);
|
||||
|
||||
// 同时必须还能滚:12 行内容高过可视区,截图里才看得到独立滚动条
|
||||
let content = DEMO.len() as f32 * pitch;
|
||||
assert!(
|
||||
content > visible,
|
||||
"列表内容 {content} 不比可视区 {visible} 高,截图里看不到独立滚动条"
|
||||
content > viewport,
|
||||
"列表内容 {content} 不比可视区 {viewport} 高,截图里看不到独立滚动条"
|
||||
);
|
||||
}
|
||||
|
||||
/// **源码结构**:`list_column()` 里对行容器的间距**只能**来自 [`ROW_SPACING`]。
|
||||
///
|
||||
/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 加回行容器后,
|
||||
/// 几何断言照样绿——它算的是常量,看不见页面又叠了一层。iced 的 Element 又没有内省接口,
|
||||
/// 所以只能断言这段代码的形状。
|
||||
///
|
||||
/// ## 两层判据(r4 复审的 M4 订正)
|
||||
///
|
||||
/// | 层 | 判据 | 抓的是 |
|
||||
/// |---|---|---|
|
||||
/// | ① 构建链 | `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 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!(
|
||||
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)。
|
||||
///
|
||||
/// 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}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 复审的教训)
|
||||
//!
|
||||
@@ -72,7 +69,11 @@
|
||||
//! **作用域**:这条承诺只覆盖 `BodyMedium` 14px 的正文。`BodySmall` 12px 的辅助文案
|
||||
//! (空态副文案、窄窗提示)在同一列宽下每行可到 ~82 字符——它们都是 1~2 行的短句,不做逐行承诺;
|
||||
//! 中文(全角 1em)另算,等 i18n mission 定。
|
||||
|
||||
//!
|
||||
//! ### M102 订正:`column` 是**正文(prose)列宽**,不是页面块宽
|
||||
//!
|
||||
//! 视觉复审抓到「正文列左缘 423 对标题 110」,根因是**把 424 当成整页块宽再居中**。
|
||||
//! 现在三个量分开,口径与常量全在 [`block`](本文件只留指针,免得文档把 file-size 门禁顶到红线)。
|
||||
//!
|
||||
//! ### 步进是怎么量的(口径 + 可复现)
|
||||
//!
|
||||
@@ -90,6 +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)。
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! 页面块几何:**块宽 / 块偏移 / 散文列宽**(纯函数,不碰 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`,封顶后**右侧留白**(不是居中)。
|
||||
//! 这是有意的:页头标题在左,正文块跟着左缘;把块居中又会回到视觉复审 ① 的「内容缩在中间」。
|
||||
//!
|
||||
//! ## 这一层唯一的行为变化,以及它从哪一版开始
|
||||
//!
|
||||
//! 封顶是 M102 r2 引入的**有意行为变化**:`≥ 1576` 窗宽(`112 + 1440 + 24`)下块宽不再跟随可用宽度。
|
||||
//! 在 1180 / 900 上它**不生效**——那两张验收图与 r1 逐字节相同(9/9 `cmp`)。所以对外口径是
|
||||
//! 「**≤1440 窗宽零像素变化;更宽的窗口按本上限有意改变**」,不是笼统的「零变化」。
|
||||
//! (`frame::body` 那行 `padding(left(body_offset))` 在同一轮加进来,值恒 0 ⇒ 像素中性。)
|
||||
//!
|
||||
//! ## 偏移为什么是个常量函数而不是 `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;
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! **源码结构断言**的小工具:把某个函数的实现文本切出来(只给测试用)。
|
||||
//!
|
||||
//! ## 为什么要读源码(而不是量 Element)
|
||||
//!
|
||||
//! iced 的 `Element` 没有几何内省接口(`size()` 只是 `Size<Length>`,取不到布局结果),
|
||||
//! 所以「页面把块摆在哪 / 有没有在行之间叠间距」在单测里**量不到**。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]
|
||||
}
|
||||
|
||||
/// 从 `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 就是这样假红的)。这里把「被它调用的函数」也算进契约,
|
||||
/// 于是「拆一层再转调」放行,「换成居中盒子」照样红。
|
||||
///
|
||||
/// 跨文件转调用 [`region_across`]。两者都不跟「先赋局部变量再建盒」的写法。
|
||||
pub(crate) fn function_region(source: &str, signature: &str) -> String {
|
||||
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 {
|
||||
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 ") {
|
||||
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;
|
||||
}
|
||||
|
||||
// `defined` 不带 `(`:签名可能带泛型(`fn body_impl<'a>(`),
|
||||
// 带括号的 needle 会找不到定义、直接把护栏自己搞 panic。
|
||||
let defined = format!("fn {name}");
|
||||
if region.contains(&format!("{name}("))
|
||||
&& !region.contains(&defined)
|
||||
&& source.contains(&defined)
|
||||
{
|
||||
region.push('\n');
|
||||
region.push_str(function_body(source, &defined));
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 <ident> = <expr>;`
|
||||
/// - `if let Some(<ident>) = <expr> {`(`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;
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,178 @@
|
||||
//! `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}");
|
||||
}
|
||||
|
||||
/// `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");
|
||||
}
|
||||
|
||||
/// 跨文件转调也要跟进去(`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() {
|
||||
let _ = statement(SAMPLE, "let nope = 1");
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! **断行工具**:给没有空格的长标识符补上「软断点」。
|
||||
//!
|
||||
//! ## 为什么在这里而不是日志页里
|
||||
//!
|
||||
//! 这是**排版度量**问题,不是日志页的私事:`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`] 插进去的软断点(还原成数据原文)。
|
||||
///
|
||||
/// 只给测试与调试用:页面渲染时不需要还原(原文从未被改写)。
|
||||
///
|
||||
/// **限界(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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,102 @@
|
||||
//! `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, "幂等:再跑一遍不许再插");
|
||||
}
|
||||
}
|
||||
|
||||
/// **已知限界**(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 修复点)"
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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, source, wrap, PaneKit};
|
||||
use super::model::{self, GROUPS, SUMMARY_HEADER};
|
||||
use super::{demo, lines, *};
|
||||
|
||||
@@ -44,6 +44,73 @@ fn page_is_single_column_and_never_wider_than_the_measure_cap() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **M102 ③**:`wrap::breakable` 这张表本身是好的,且**只改显示**(原文能还原)。
|
||||
///
|
||||
/// **抓得住**:断点表被掏空(`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();
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
/// **源码结构(调用点)**:日志页必须把 `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`] 守着了,
|
||||
/// 这里守的是**接线**:页面真把显示串过了一遍它。
|
||||
///
|
||||
/// **抓得住**:任一处接线被摘掉(日志行 / 一行摘要);
|
||||
/// **抓不住**:接线写得对但断点表被掏空(那是上一条的事)。
|
||||
///
|
||||
/// 用 [`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_region(include_str!("lines.rs"), "fn entry");
|
||||
assert!(
|
||||
entry.contains("wrap::breakable("),
|
||||
"日志行没接软断点(`graphics.gl_operation_failure` 会被按字形劈开):\n{entry}"
|
||||
);
|
||||
|
||||
let summary = source::function_region(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();
|
||||
|
||||
@@ -81,6 +81,174 @@ fn the_width_comparison_really_changes_the_layout() {
|
||||
assert!(narrow.secondary.is_none(), "700 不许再有列表列");
|
||||
}
|
||||
|
||||
/// **M102 的对齐判据**:正文块左缘 = 页头标题左缘(`body_offset == 0`),块宽 = pane 宽(封顶 1440)。
|
||||
///
|
||||
/// 页头标题的左缘 = `rail + gutter`(`frame::header_bar` 的 padding 与 `frame::page` 的内容
|
||||
/// padding 是同一个 `metrics.gutter`);正文块宽 = `frame::body_width`。
|
||||
///
|
||||
/// **抓得住**:块宽被改回 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] {
|
||||
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_eq!(
|
||||
layout::block::body_offset(metrics),
|
||||
0.0,
|
||||
"{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, "正文块宽度");
|
||||
|
||||
// 极宽窗:块宽封顶 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)`、`align_x` 只许是 `Start`(不写 = container 默认 Start,等价),
|
||||
/// 且不出现居中/散文列宽。
|
||||
///
|
||||
/// 扫的是 [`layout::source::region_across`]:`frame.rs` **加上** `layout/block.rs`,
|
||||
/// 于是「拆成 `body` → `body_impl`」(变异 H1)与「把盒子原样搬进 `block::body_box`」(变异 M6)
|
||||
/// 这两种**等价重构都不假红**,而「换成居中 424 盒子」照样红(变异 A)。
|
||||
///
|
||||
/// **抓得住**:盒子的**语义**变了(居中 / 用散文列宽 / 用 `centered`);
|
||||
/// **抓不住**:`frame::centered(metrics.column, …)` 这种**与 `frame::body` 逐像素等价**的另写
|
||||
/// (r4 复审的 M1/M2 —— 按塔的裁定那是等价重构,护栏不该为此红),也不抓「先赋局部变量再建盒」。
|
||||
#[test]
|
||||
fn frame_body_is_built_from_the_block_quantities() {
|
||||
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!(
|
||||
body.contains(needle),
|
||||
"`frame::body` 必须从 {needle} 建盒(贴左 + 块宽 + 零偏移):\n{body}"
|
||||
);
|
||||
}
|
||||
|
||||
// `align_x` 出现就必须是 Start:`.align_y(Alignment::Start)` 单独存在不算数(r2 复审 P2-3),
|
||||
// 而「干脆不写 align_x」(container 默认 Start)视为等价重构,放行。
|
||||
// 判的是**实参**而不是整段文本,所以 `Alignment::Start` 与 `iced::Alignment::Start` 同等放行
|
||||
// (把盒子搬进别的模块后路径要写全,那是等价重构,不该假红 —— r4 复审的变异 M6)。
|
||||
for argument in layout::source::call_arguments(&body, ".align_x(") {
|
||||
assert!(
|
||||
argument.trim().ends_with("Alignment::Start"),
|
||||
"`frame::body` 的 align_x 只许是 Start,收到 {argument:?}:\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 的块」不会红任何数值断言;这条盯接线。
|
||||
/// 与 `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::region_across(
|
||||
&[include_str!("frame.rs"), include_str!("layout/block.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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **源码结构(调用点)**:单列页必须调 `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`(页面唯一的列宽来源)不会越界;
|
||||
|
||||
Reference in new issue
Block a user