test(app-iced): M102 r2 护栏改成「抓得住页面换摆法」——块偏移/行距/散文接线全部有源码结构断言
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)且仍贴左。
This commit is contained in:
1 parent
89d98184c1
commit
824bb36f8e
11 files changed
+469
-67
No files matched your search
@@ -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<Element<'a, Message>>,
|
||||
@@ -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>>,
|
||||
) -> Element<'a, Message> {
|
||||
container(content)
|
||||
.width(Length::Fixed(metrics.column))
|
||||
.width(Length::Fixed(block::prose_width(metrics)))
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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> {
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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;
|
||||
@@ -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,39 @@
|
||||
//! **源码结构断言**的小工具:把某个函数的实现文本切出来(只给测试用)。
|
||||
//!
|
||||
//! ## 为什么要读源码(而不是量 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]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 修复点)"
|
||||
);
|
||||
}
|
||||
@@ -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`(页面唯一的列宽来源)不会越界;
|
||||
|
||||
Reference in new issue
Block a user