fix(app-iced): M102 页面几何 —— 正文块贴左吃满页宽、列表末行不再被裁半行、日志按词断行

① 正文列与页头不对齐(下载 / 日志):根因是 `frame::single_column` 把**行宽上限 424**
当成**整页块宽度**并水平居中(1180 下 1044 里只摆 424,左缘 422 对页头标题 112)。
现在两个量分开:`layout` 的 `column` 只管成段散文(≤424),页面块宽走新的
`frame::body_width`(= available / main_pane)。新增 `frame::body`(贴左吃满)与
`frame::prose`(散文锁 424)。1180×760 下下载 / 日志的块左缘 = 112 = 页头标题左缘。

② 实例列表末行被裁半行:页级行距 `Space::X1`(4) 让行节奏变成 64、与可视高度除不尽,
且滚动区直贴卡片 1px 圆角描边。行距归零(下载页同改)+ 滚动区底部内缩 `Space::X2`(8)
⇒ 可视区 580−32−8 = 540 = 9×60,Redstone Lab 及其副标题完整可见,第 10 行整体在裁切线外。

③ 日志断行把单词劈开(`graphics.gl_o` / `peration_failure`):新增
`layout::wrap::breakable`,在 `.` `_` `:` `/` `\` 后插零宽空格(U+200B 软断点),
日志行正文与一行摘要都过它;悬挂缩进不变(前缀仍是独立文本控件),
`Line::text` 与 `Logs::summary()`(剪贴板)仍是原文。工具函数放 `pages/layout/`:
断点是「列宽之后」的另一半排版判据,纯函数、可单测、不碰控件树。

护栏:`body_block_starts_at_the_header_title_and_fills_the_page`、
`list_viewport_fits_whole_rows_and_still_overflow`(把 580/32/8/540/60 五个数钉住)、
`wrap::tests`(断点位置 / 不重复插 / 可还原 / 幂等)、`log_text_carries_soft_breaks_but_keeps_the_original`。
`download/tests.rs` 的「宽窗留白」口径按新分工改成「宽度归块、不归散文列」。
This commit is contained in:
AskaEth committed 2026-11-04 19:53:38 +08:00
1 parent 8574d6ba2d
commit 89d98184c1
15 files changed
+466 -53

No files matched your search

+14 -8
View File
@@ -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)
}
+7 -4
View File
@@ -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);
+12 -4
View File
@@ -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"
);
}
+59 -2
View File
@@ -3,10 +3,22 @@
//!
//! ## 边界
//!
//! 这里只放「页面级」的东西:页头、页边距、内容列、两列骨架、空态位的摆放。
//! 这里只放「页面级」的东西:页头、页边距、内容块、两列骨架、空态位的摆放。
//! 具体控件(卡片 / 分隔线 / 文本按钮 / 图标)在 [`pieces`],它们是**占位实现**
//! (同一份令牌,没有完整状态机),等 M75 的 `components/` 家族合入后由另一条 mission 换掉。
//!
//! ## 对齐口径(M102):页头标题左缘 = 正文块左缘
//!
//! 页头带与内容区共用同一个 `gutter`(`Padding::from([0.0, metrics.gutter])` / `padding(gutter)`),
//! 所以页头标题的左缘 = `rail 88 + gutter 24 = 112`。正文块必须从**同一条线**起:
//!
//! - 单列页用 [`body`](贴左 + 吃满 `layout::available`);
//! - 成段散文再套一层 [`prose`](宽度 ≤424,保证一行 ≤72 字符);
//! - 两列页的正文在 [`panes`] 的主列里,左缘 = `112 + 列表列 336 + 间隙 24 = 472`(有据的分栏,不是错位)。
//!
//! 旧 [`single_column`] 把 424 的块**居中**摆进 1044,于是左缘 422、标题 112——视觉复审的 ① 就是这个。
//! 它保留给设置页(`pages/settings/**`,几何归设置页线)。
//!
//! ## 判据落点(`research/12b`)
//!
//! | 部件 | 参考 | 落点 |
@@ -38,7 +50,7 @@ use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::layout::{self, FrameMetrics};
use super::layout::{self, FrameMetrics, PageLayout};
use super::Message;
/// 页头操作区之间的间隔(`Space::X2`)。
@@ -139,6 +151,10 @@ fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Elem
}
/// 单列版面的正文列:宽 = 行宽上限内的实际列宽,**水平居中**。
///
/// > **M102 起产品页不再用它**:居中会把 424 摆到 1044 的正中(左缘 422 对页头标题 112),
/// > 视觉复审判为「内容缩在中间一条窄栏」。设置页(`pages/settings/**`)仍走这条,
/// > 它的几何归设置页线,本 mission 不动;下载 / 日志 / 窄窗实例改用 [`body`]。
pub fn single_column<'a>(
metrics: FrameMetrics,
content: impl Into<Element<'a, Message>>,
@@ -146,6 +162,47 @@ pub fn single_column<'a>(
centered(metrics.column, content)
}
/// **页面块宽度**:单列版面 = 整条可用宽度;两列版面 = 主列 pane 宽度。
///
/// 这是「表格 / 卡片 / 列表能有多宽」的答案,与 [`FrameMetrics::column`](一句话能有多宽,
/// ≤424)是**两个量**。M102 的根因就是把后者当成了前者:424 的块在 1044 里居中,
/// 于是正文左缘比页头标题右移了 310px(1180 窗宽下)。
pub fn body_width(metrics: FrameMetrics) -> f32 {
match metrics.layout {
PageLayout::SingleColumn => metrics.available,
PageLayout::SupportingPane | PageLayout::ListDetail => metrics.main_pane,
}
}
/// **正文块**:从页边距起(= 页头标题那条左缘)、铺满 [`body_width`]。
///
/// 与旧 `single_column` 的差别只有两点,但都是判据本身:**贴左**而不是居中,
/// 宽度取 pane 而不是行宽上限。散文仍要自己套 [`prose`],别把整页块拉回 424。
pub fn body<'a>(
metrics: FrameMetrics,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
container(content)
.width(Length::Fixed(body_width(metrics)))
.height(Length::Fill)
.align_x(Alignment::Start)
.align_y(Alignment::Start)
.into()
}
/// **散文块**:宽度锁在 [`FrameMetrics::column`](≤424 = 逐行 ≤72 字符)。
///
/// 只包「成段读的文字」(实例详情正文那样的 prose);卡片、列表、Tabs 吃的是 [`body_width`],
/// 它们的宽度不承担行宽承诺(列宽只影响每行放几个字段,不影响读一句话的舒适度)。
pub fn prose<'a>(
metrics: FrameMetrics,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
container(content)
.width(Length::Fixed(metrics.column))
.into()
}
/// 两列版面:左列 `secondary`(独立滚动区由调用方自己加 `scrollable`)+ 右列 `main`。
///
/// 两列都**显式**取布局系统给的宽度,不吃 Fill:列表列必须固定 336,
+25 -6
View File
@@ -4,8 +4,8 @@
//!
//! | 版面 | 结构 | 退化行为 |
//! |---|---|---|
//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,正文列 ≤424 | — |
//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(≤424,居中) | 顶部给「Back to instances」,列表退成上一屏 |
//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,散文 ≤424 | — |
//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(块贴左吃满页宽,散文 ≤424) | 顶部给「Back to instances」,列表退成上一屏 |
//!
//! 为什么窄窗不是「上下堆」:官方 compact 下 list-detail 的口径是一次只显示一个 pane(列表 → 详情,
//! 整页切换)。上下堆会把详情挤到折叠线以下,等于详情不存在。
@@ -27,6 +27,21 @@
//! M84 的行高是「`row_height` 44 + `Space::X3` 12 = 56」的手工值;真 `ListItem` 的两行档是
//! **60**(令牌 `row_height` 44 + 每多一行 16,A1 是 72)。差异来自组件自己的几何真源,
//! 页面不许覆盖——这正是「接真组件」的应有结果。列表仍高于可视区,独立滚动条照样可见。
//!
//! ## 行距归零 + 底部内缩(M102,视觉复审 ②)
//!
//! 视觉复审在 1180×760 的截图里抓到「末行 Redstone Lab 被卡片底边切掉半行(副标题消失)」。
//! 根因是**两道页级附加量**:`Space::X1` 的行距让行节奏变成 64,与可视高度除不尽;
//! 列表滚动区贴到卡片的 1px 圆角描边上,切点正好落在描边线上。修法两件:
//!
//! | 改什么 | 值 | 结果 |
//! |---|---|---|
//! | 页级行距 | 4 → **0**(见 `download/rows.rs` 同一条) | 行节奏回到组件的 60 |
//! | 滚动区底部内缩 | 0 → **`Space::X2` 8** | 切点落在卡片的内边距带里,不压描边 |
//!
//! 于是可视区 = 580 − 列头 32 − 内缩 8 = **540 = 9 × 60**,正好 9 整行:末行完整可见,
//! 第 10 行整体落在裁切线之外(`instances/tests.rs::list_viewport_fits_whole_rows_and_still_overflow`
//! 把这四个数钉住)。列表内容 12×60 = 720 仍高过可视区,独立滚动条照样可见。
mod data;
mod demo;
@@ -60,8 +75,11 @@ use super::layout::{FrameMetrics, PageLayout};
use super::model::{Instances, Model, Row};
use super::{Action, Message};
/// 列表行的间距(`Space::X1`;与 M84 同值,行高差异全部来自 `ListItem` 自己的几何)。
const ROW_GAP: Space = Space::X1;
/// 列表滚动区的**底部内缩**(`Space::X2`):让裁切线落在卡片的内边距带里,而不是压在 1px 描边上。
///
/// 行距归零(`list()` 不写 `.spacing(…)`)与这条内缩是一对,理由见模块文档「行距归零(M102)」
/// 与本页 `instances.rs` 的同名条目。
const LIST_BOTTOM_INSET: Space = Space::X2;
/// 整页(`block.detail = None` 时详情区显示空态)。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<'a, Message> {
@@ -95,7 +113,7 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<
),
_ => match &block.detail {
Some(detail) => detail::narrow_detail(ui, metrics, detail, forced),
None => frame::single_column(metrics, detail::detail_empty(ui)),
None => frame::body(metrics, detail::detail_empty(ui)),
},
};
@@ -146,7 +164,6 @@ fn list_column<'a>(
.map(|entry| list_row(ui, entry, is_selected(block, entry), forced))
.collect::<Vec<_>>(),
)
.spacing(ui.space(ROW_GAP))
.padding(Padding::from([0.0, ui.space(Space::X2)]))
.width(Length::Fill);
@@ -161,9 +178,11 @@ fn list_column<'a>(
.height(Length::Fill)
.style(frame::scroll_style(ui));
// 底部内缩:裁切线落在卡片的内边距带里,而不是压在圆角描边上(M102 / 视觉复审 ②)。
container(column![head, scroller].height(Length::Fill))
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding::default().bottom(ui.space(LIST_BOTTOM_INSET)))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: Border {
+16 -9
View File
@@ -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 {
+43 -7
View File
@@ -3,15 +3,51 @@ use super::*;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
/// **M102 几何判据**:列表可视区正好放下整数行——末行不会被卡片底边切半行。
///
/// 算式全部取真源(1180×760,harness 路径):
///
/// ```text
/// 卡片高 = 760 − 标题栏 48 − 两行页头 84 − 2×gutter 24 = 580
/// 列头 = 上 12 + LabelMedium 16 + 下 4 = 32
/// 内缩 = Space::X2 = 8
/// 可视区 = 580 − 32 − 8 = 540
/// 行高 = ListItem 两行档 44 + 16 = 60 (页级行距 0)
/// 整行数 = 540 / 60 = 9 行整
/// ```
///
/// **抓得住**:页级行距被加回来(64 的节奏立刻除不尽 ⇒ 末行只剩半行,视觉复审 ② 的原样)、
/// 列头/内缩算错、演示数据被缩到不够滚(独立滚动条消失);
/// **抓不住**:真实像素(报告里的前后对照图 + 量测表负责)。
#[test]
fn demo_rows_overflow_the_visible_list() {
// 列表列要能滚动:条目数必须让内容高过可视区(760 窗高 − 标题栏 48 − 页头 84 − 页边距)
let visible = 760.0 - 48.0 - 84.0 - 2.0 * WidthClass::Expanded.gutter();
let row_height = 44.0 + Space::X3.dp();
let content = DEMO.len() as f32 * row_height;
fn list_viewport_fits_whole_rows_and_still_overflow() {
use unlucky_design::spacing::LayoutMetrics;
use unlucky_design::typography::TypeRole;
let lm = LayoutMetrics::DESKTOP;
let gutter = WidthClass::Expanded.gutter();
let card = 760.0 - lm.titlebar_height - lm.header_height(true) - 2.0 * gutter;
assert_eq!(card, 580.0, "1180×760 下列表卡片的内部高度");
let head = Space::X3.dp() + TypeRole::LabelMedium.style().line_height_px + Space::X1.dp();
let viewport = card - head - Space::X2.dp();
assert_eq!(viewport, 540.0, "列表滚动区高度");
// 行距归零(页级不再附加):行节奏 = ListItem 两行档 60
let row = lm.row_height + Space::X4.dp();
assert_eq!(row, 60.0);
assert_eq!(
viewport % row,
0.0,
"可视区 {viewport} 不是 {row} 的整数倍 ⇒ 末行会被切半行"
);
// 同时必须还能滚:12 行内容高过可视区,截图里才看得到独立滚动条
let content = DEMO.len() as f32 * row;
assert!(
content > visible,
"列表内容 {content} 不比可视区 {visible} 高,截图里看不到独立滚动条"
content > viewport,
"列表内容 {content} 不比可视区 {viewport} 高,截图里看不到独立滚动条"
);
}
+18 -1
View File
@@ -72,7 +72,22 @@
//! **作用域**:这条承诺只覆盖 `BodyMedium` 14px 的正文。`BodySmall` 12px 的辅助文案
//! (空态副文案、窄窗提示)在同一列宽下每行可到 ~82 字符——它们都是 1~2 行的短句,不做逐行承诺;
//! 中文(全角 1em)另算,等 i18n mission 定。
//!
//! ### M102 订正:`column` 是**正文(prose)列宽**,不是页面块宽
//!
//! 视觉复审在 1180×760 的下载 / 日志页抓到「正文列从 x≈423 起、标题在 x≈110,左右各空 ~300px」。
//! 根因不在 [`frame_metrics`] 的宽度分配,而在**把 424 这个「一句话多宽」的上界当成了整页块的宽度**:
//! `pages::frame` 的旧 `single_column` 取 `metrics.column` 并**水平居中**,于是 1044 里只用的 424 被摆到正中。
//!
//! 现在两者分开,口径如下:
//!
//! | 量 | 谁给 | 用在哪 |
//! |---|---|---|
//! | [`FrameMetrics::column`] | 本模块(≤ 424) | **正文/散文**:`frame::prose` 包一层,保证一行 ≤72 字符 |
//! | `pages::frame::body_width` | 本模块的 `available` / `main_pane` | **页面块**:表格、卡片、列表、Tabs 吃满可用宽度 |
//!
//! 左缘一律是 `rail 88 + gutter 24 = 112`——与页头标题同一条线(判据:
//! `pages/tests.rs::body_block_starts_at_the_header_title_and_fills_the_page`)。
//!
//! ### 步进是怎么量的(口径 + 可复现)
//!
@@ -90,6 +105,8 @@
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
pub mod wrap;
/// 官方 M3 窗口档的 medium 断点(600dp)。
pub const BREAKPOINT_MEDIUM: f32 = 600.0;
/// 官方 M3 窗口档的 expanded 断点(840dp)。
+79
View File
@@ -0,0 +1,79 @@
//! **断行工具**:给没有空格的长标识符补上「软断点」。
//!
//! ## 为什么在这里而不是日志页里
//!
//! 这是**排版度量**问题,不是日志页的私事:`layout.rs` 已经是页面级几何的唯一真源
//! (档位 / 版面 / 列宽 / 行宽上限),「一行在哪儿能断」属于同一类判据的第二半——
//! 列宽决定**能放多宽**,断点决定**放不下时从哪儿折**。放在 `layout/` 下面:
//!
//! - 日志页(`pages/logs/**`)与以后任何要排长串的页都能用,不产生第二份实现;
//! - 它是纯函数(`&str → String`),不碰 iced 控件树,能在单测里逐字符钉死。
//!
//! ## 修的是什么(M102 视觉复审的 ③)
//!
//! 崩溃日志的一行摘要与堆栈里有 `graphics.gl_operation_failure:medium(70)` 这种
//! **完全没有空格**的长串:iced 的 `Wrapping::Word` 找不到断点,只能退化成按字形硬断,
//! 于是图里出现了 `graphics.gl_o` / `peration_failure`——单词被从中间劈开。
//!
//! 修法不是改字号也不是截断(截断会让用户拿不回原始堆栈),而是往
//! `.` `_` `:` `/` `\` 这些**标识符/路径分隔符后面**插一个**零宽空格**(U+200B):
//!
//! - U+200B 是 Unicode 的**软断点**(UAX #14 `ZW`):排版时可以在这里折行,但不占宽度、
//! 不产生字形,`Wrapping::WordOrGlyph` 会优先在它那里折;
//! - 它只进**显示**用的字符串,不进数据:`Logs::summary()`(剪贴板)与 `Line::text`
//! (视图模型)仍是原文,`strip` 一下就能还原(见单测)。
//!
//! ## 边界(没做什么)
//!
//! - 只在**分隔符之后**插,不在之前:「`a.b`」断成「`a.` / `b`」比「`a` / `.b`」好读;
//! - 分隔符后面紧跟空白、或结尾,就不插(`Wrapping` 本来就能在空白处折);
//! - 连续分隔符(`a..b`、`a/_b`)只在最后那个后面插一个,不制造一串空断点;
//! - `-` **不插**:时间戳 `2026-11-02 19:40` 断在日期中间比不断更难看,
//! 而 `-` 两侧几乎都有空白/标点兜底。
/// 零宽空格(U+200B):可断行、不占宽度的软断点。
pub const SOFT_BREAK: char = '\u{200b}';
/// 软断点允许落在哪些字符**之后**。
///
/// 取标识符 / 路径 / 规则 id 的分隔符:`integrity.unsatisfied_link`、
/// `graphics.gl_operation_failure:medium(70)`、`logs/2026-11-02/client.txt`、
/// `AppData\Roaming\.unlucky\instances\dev-test\natives`(Windows 路径,反斜杠也断)。
pub const BREAK_AFTER: [char; 5] = ['.', '_', ':', '/', '\\'];
/// 给 `text` 补上软断点(返回**显示用**的新串;数据侧原文不动)。
///
/// 幂等:对已经补过断点的串再跑一遍不会插更多(U+200B 不是分隔符,也不是空白以外的可断字符)。
pub fn breakable(text: &str) -> String {
let mut out = String::with_capacity(text.len() + text.len() / 8);
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
out.push(ch);
if !BREAK_AFTER.contains(&ch) {
continue;
}
// 只在「分隔符 + 真内容」之间插:结尾、空白、连续分隔符、已有软断点都不插。
let worth_breaking = chars.peek().is_some_and(|next| {
!next.is_whitespace() && *next != SOFT_BREAK && !BREAK_AFTER.contains(next)
});
if worth_breaking {
out.push(SOFT_BREAK);
}
}
out
}
/// 去掉 [`breakable`] 插进去的软断点(还原成数据原文)。
///
/// 只给测试与调试用:页面渲染时不需要还原(原文从未被改写)。
pub fn strip(text: &str) -> String {
text.chars().filter(|ch| *ch != SOFT_BREAK).collect()
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,83 @@
//! `wrap` 的断言:软断点插在哪、插完还能不能还原。
//!
//! 口径与 `layout/tests.rs` 一致:只钉**纯函数**(断点位置 / 原文可还原),
//! 「渲染器是不是真在那里折」由截图与报告里的前后对照图负责。
use super::*;
/// 视觉复审 ③ 的原话样本:这两个被劈开的词必须重新可断。
#[test]
fn identifiers_break_after_their_separators() {
let summary =
"findings=integrity.unsatisfied_link:high(85),graphics.gl_operation_failure:medium(70)";
let broken = breakable(summary);
// 抓得住:断点漏掉 `_` / `.` / `:`(那就退化成按字形硬断,`graphics.gl_o` + `peration_failure` 复现);
// 抓不住:渲染器最终在哪一列折行(那要量图,见报告)。
for seam in [
"integrity.\u{200b}",
"unsatisfied_\u{200b}",
"link:\u{200b}",
"graphics.\u{200b}",
"gl_\u{200b}",
"operation_\u{200b}",
"failure:\u{200b}",
] {
assert!(broken.contains(seam), "缺断点 {seam:?}:{broken}");
}
}
/// 路径样本:`logs/2026-11-02/client.txt` 在 `/` 与 `.` 后能断,日期里的 `-` 不许断。
#[test]
fn paths_break_after_slashes_but_not_inside_dates() {
let broken = breakable("logs/2026-11-02/client.txt");
assert!(broken.contains("logs/\u{200b}"), "{broken}");
assert!(broken.contains("client.\u{200b}txt"), "{broken}");
// 日期整体不拆:`-` 不在断点表里,视觉复审没要求、拆开反而难读。
assert!(broken.contains("2026-11-02"), "{broken}");
// Windows 路径:反斜杠同样是断点(日志里 `AppData\Roaming\...\dev-test\natives\...`)
let win =
breakable("C:\\Users\\Player\\AppData\\Roaming\\.unlucky\\instances\\dev-test\\natives");
assert!(win.contains("Player\\\u{200b}"), "{win}");
assert!(win.contains("natives"), "{win}");
}
/// 不该插的地方一个都不插:结尾、空白之后、连续分隔符。
#[test]
fn no_dangling_or_doubled_breaks() {
assert_eq!(breakable(""), "");
assert_eq!(breakable("plain text"), "plain text");
assert_eq!(breakable("trailing."), "trailing.", "结尾不插");
assert_eq!(
breakable("a. b"),
"a. b",
"分隔符后面是空白:Wrapping 本来就能在空白处折"
);
assert_eq!(
breakable("a..b"),
"a..\u{200b}b",
"连续分隔符只在最后一处断"
);
assert_eq!(breakable("a:/b"), "a:/\u{200b}b");
// 抓得住:有人在每个字符后面都插(那就等于按字形断,本 mission 的修法失效)。
assert!(
!breakable("abcdef").contains(SOFT_BREAK),
"没有任何分隔符的串不该出现断点"
);
}
/// 只改显示:`strip` 还原后与原文逐字符相同(剪贴板/日志原文不许被污染)。
#[test]
fn breaking_is_reversible_and_idempotent() {
for text in [
"graphics.gl_operation_failure:medium(70)",
"[19:40:12] [main/ERROR] 启动失败: exit code 1",
"at com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64)",
] {
let broken = breakable(text);
assert_eq!(strip(&broken), text, "还原失败:{broken}");
assert_eq!(breakable(&broken), broken, "幂等:再跑一遍不许再插");
}
}
+13 -5
View File
@@ -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)
+8 -3
View File
@@ -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)
+15 -3
View File
@@ -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),
]
+36 -1
View File
@@ -11,7 +11,7 @@ use unlucky_i18n::Lang;
use crate::theme::Families;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit};
use super::super::layout::{frame_metrics, measure_cap_px, wrap, PaneKit};
use super::model::{self, GROUPS, SUMMARY_HEADER};
use super::{demo, lines, *};
@@ -44,6 +44,41 @@ fn page_is_single_column_and_never_wider_than_the_measure_cap() {
}
}
/// **M102 ③**:日志行与一行摘要都带软断点——否则 iced 只能按字形硬断,`gl_o` / `peration` 复现。
///
/// **抓得住**:`entry()` / `summary_block()` 又把原文直接喂给 `mono_owned`(软断点不见了);
/// **抓不住**:渲染器最终断在哪一列(那要量图,报告里有前后对照)。
#[test]
fn log_text_carries_soft_breaks_but_keeps_the_original() {
let logs = demo::crash();
let summary = wrap::breakable(&logs.summary());
assert!(
!summary.contains("gl_operation_failure"),
"摘要里没有软断点,长标识符会被按字形劈开:{summary}"
);
assert_eq!(
wrap::strip(&summary),
logs.summary(),
"剪贴板拿到的仍是原文"
);
// 每条日志行的正文(拆前缀后的那一半)都要过同一件事:至少最长的那几行里有断点。
let broken = logs
.lines
.iter()
.filter(|line| {
!wrap::breakable(lines::split_prefix(&line.text).1).contains(wrap::SOFT_BREAK)
})
.count();
assert!(
broken * 2 < logs.lines.len(),
"{} / {} 行日志没有任何软断点(断点表可能被掏空了)",
broken,
logs.lines.len()
);
}
#[test]
fn mono_lines_use_the_mono_stack_with_the_body_small_metrics() {
let ui = ui();
+38
View File
@@ -81,6 +81,44 @@ fn the_width_comparison_really_changes_the_layout() {
assert!(narrow.secondary.is_none(), "700 不许再有列表列");
}
/// **M102 的对齐判据**:正文块左缘 = 页头标题左缘,块宽 = 可用宽度(不再二次居中)。
///
/// 页头标题的左缘 = `rail + gutter`(`frame::header_bar` 的 padding 与 `frame::page` 的
/// 内容 padding 是同一个 `metrics.gutter`);正文块宽 = `frame::body_width`。
///
/// **抓得住**:谁把单列页的块宽改回 424(视觉复审 ①:左缘 422 对标题 112),
/// 或把块重新居中(左缘立刻偏离 `rail + gutter`);
/// **抓不住**:真实像素(`pages_demo` 的图与报告里的量测表负责)。
#[test]
fn body_block_starts_at_the_header_title_and_fills_the_page() {
for width in [600.0, 840.0, 900.0, 1180.0] {
let metrics = frame_metrics(PaneKit::Single, width);
let title_x = layout::rail_width() + metrics.gutter;
let block = frame::body_width(metrics);
assert_eq!(
block, metrics.available,
"{width}: 单列页块宽必须是可用宽度"
);
assert!(
(title_x + block + metrics.gutter - width).abs() < f32::EPSILON,
"{width}: 块从 {title_x} 起、宽 {block},没铺到右页边距"
);
// 块变宽不等于一句话变宽:散文列仍 ≤424
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
}
// 验收尺寸 1180:标题左缘 112、块宽 1044(旧版居中时正文左缘是 422)
let wide = frame_metrics(PaneKit::Single, 1180.0);
assert_eq!(layout::rail_width() + wide.gutter, 112.0, "页头标题左缘");
assert_eq!(frame::body_width(wide), 1044.0, "正文块宽度");
// 两列版面的块宽 = 主列 pane(实例页正文在 panes 的主列里,不铺整页)
let two = frame_metrics(PaneKit::ListDetail, 1180.0);
assert_eq!(frame::body_width(two), two.main_pane);
assert_eq!(two.main_pane, 1180.0 - 88.0 - 48.0 - 336.0 - 24.0);
}
/// **M84 的行宽红线**:任何页、任何窗口宽度、任何版面,正文列都不许超过 424px。
///
/// **抓得住**:`frame_metrics` 的 `column`(页面唯一的列宽来源)不会越界;