fix(app-iced): M95 r1 五条视觉复审(悬挂缩进 / 会话身份 / 摘要可见 / 贴底 / 描边)

- P2-1 **悬挂缩进真做了**(之前文档在骗人,代码里只有 `row![bar, text]`):
  每行拆「前缀 + 正文」两个文本控件(前缀 = 第一个空格及其之前,含空格;
  `split_prefix` 在第一个「词」>20 字符时不拆),正文带 Fill 宽度自己折行 ⇒
  续行落在前缀宽度之后。于是 `at com.mojang…(GLX.java:64)` 不再拆成「`at` 独占一行」。
  文档同步写清代价:续行缩进量 = 该行前缀宽度。
- P2-2 会话身份上页头:`Logs::with_status(lang)` 用 `LogsStatus` 模板拼
  `{instance} · {when} · {outcome}`,结局文案走 `LaunchOutcomeNormal/Crash/Silent`
  三 key(bridge 文档里那条「LogOutcome 三态 → 三 key」的结构化出口,此前 UI 侧零调用点)。
  中文模板差在全角竖线(守卫要求 zh≠en,也确实更符合中文排版)。
- P2-3 摘要钉成诊断卡**固定页脚**(不进滚动区):「One-line summary」+ Copy summary
  紧邻它要复制的那段等宽文本;卡片头不再放复制按钮(避免两处入口)。
- P2-4 日志卡改 `panel::scroll_tail`(`Anchor::End`):偏移按「距底多远」记,内容增长时
  跟随尾部;「用户上滚后是否冻结」的边界写在 `panel::scroll_tail` 文档里(结论:不冻结,
  冻结要 app 层回灌滚动状态,已登记)。
- P2-5 两张主卡补 1px 描边:`panel::card` 改用 `Component::OutlinedCard.spec().shape` +
  `ui.outlined(..)`,与 `frame::card` 同一套令牌。

测试:新增 `a_line_is_split_into_prefix_and_body_for_the_hanging_indent`(含 CJK 行与
「不拆」的两种边界)与 `status_line_names_the_session_with_the_launch_outcome_keys`;
key 清单补 `LogsStatus` 与三个 `LaunchOutcome*`;demo 的 state.build 改为收 `Lang`。

验证:`fmt --check` 通过;`clippy -p unlucky-app-iced --all-targets -D warnings` 干净;
`test -p unlucky-app-iced` 274+6 全绿、`-p unlucky-i18n` 23 全绿。
图:`.screens/logs-{empty,live,crash}-{light,dark}.png`(1180×760)、
`r2-live-tall-light.png`(1180×1080,栈帧可见,P2-1 证据)。
This commit is contained in:
AskaEth committed 2026-11-02 22:26:59 +08:00
1 parent 063a1dba39
commit 1bccf89e5c
10 files changed
+293 -93

No files matched your search

+60 -10
View File
@@ -14,8 +14,32 @@
//! 逐字回退到系统 CJK 族(本机实测回退到 `Microsoft YaHei UI`),回退字形的 advance 不是 1ch。
//! 横向滚动方案里「第 40 列对齐」是有意义的(堆栈缩进、`at a.b.C(File.java:12)` 的括号列),
//! 一旦 CJK 回退把列宽带偏,对齐就废了;折行版面不承诺任何列对齐,回退只影响同一行内的观感。
//! 左栏那条 2px 等级条是跨行的(`Length::Fill`),所以「同一条日志折了 3 行」在视觉上仍是
//! 一个整体。
//! ## 悬挂缩进(M95 r2 落地,视觉复审 P2-1)
//!
//! r1 的 `entry()` 只有 `row![等级条, 单个 Text]`:折行的续行回到文本自己的左边距,
//! 于是 `at com.mojang…(GLX.java:64)` 被拆成「`at` 独占一行 + 帧名一行」,和「下一条日志」
//! 视觉上无法区分(一屏白花 ~5 行)。**现在的落地方式**是把一行拆成**前缀 + 正文两个文本控件**:
//!
//! ```text
//! │ [19:40:14] [Render thread/ERROR] 显卡驱动不支持 OpenGL 3.2,初始化窗口失败
//! │ ↑ 前缀(第一个空格及其之前,含空格) ↑ 正文从它右边开始,带着 Fill 宽度自己折行
//! │ java.lang.IllegalStateException: GLFW error before init: [0x10008] Failed to create window…
//! │ ↑ 无前缀(第一个「词」超过 20 字符)⇒ 整行一个文本块
//! ```
//!
//! 为什么不是「NBSP 绑定前缀 + 单个 Text」:NBSP 只阻止断行,**改不了续行的起点**——续行依旧
//! 回到文本左边距,`at` 还是独占一行(除非整行按字形硬断,那会把时间戳也断掉)。两段布局是
//! iced 里唯一能同时做到「`at` 与帧名同行」+「续行比行首缩进」的写法。
//!
//! 代价与边界:续行缩进量 = **该行前缀宽度**(时间戳行 ≈ 10 字符、栈帧 `at` 行 ≈ 3 字符),
//! 所以不同行的续行缩进不一样——这是悬挂缩进的正常形态(按行首标签缩进),不是 bug。
//!
//! ## 贴底(M95 r2,视觉复审 P2-4)
//!
//! 崩溃排查看的是**最后几行**,所以行列表用 `panel::scroll_tail`(`Anchor::End`):偏移按
//! 「距底多远」记,内容增长时视图保持距底同一位置 ⇒ 天然跟随尾部。用户上滚后的行为与
//! 「是否冻结」的边界写在 [`panel::scroll_tail`] 的文档里(结论:**没有**冻结,需要 app 层
//! 回灌滚动状态才做得到)。
//!
//! ## 等宽族的实际落点
//!
@@ -41,6 +65,9 @@ use super::super::Message;
use super::model::{Level, Line, Logs};
use super::panel;
/// 前缀上限(字符):超过就不拆(见 [`split_prefix`])。
const PREFIX_MAX_CHARS: usize = 20;
/// 等级条宽度(2px:够看见,又不抢日志正文的起始列)。
const BAR_WIDTH: f32 = 2.0;
@@ -79,12 +106,13 @@ pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Eleme
.spacing(ui.space(Space::X1))
.width(Length::Fill);
panel::card(ui, height, column![head, panel::scroll(ui, rows)])
panel::card(ui, height, column![head, panel::scroll_tail(ui, rows)])
}
/// 一行:等级条(跨行)+ 等宽正文(折行)。
/// 一行:等级条(跨行)+ **前缀 / 正文两段**(正文折行后落在前缀右边 = 悬挂缩进)。
fn entry<'a>(ui: &'a Ui, line: &'a Line) -> Element<'a, Message> {
let color = color_of(ui, line.level);
let (prefix, body) = split_prefix(&line.text);
let bar = container(space::horizontal())
.width(BAR_WIDTH)
@@ -98,16 +126,38 @@ fn entry<'a>(ui: &'a Ui, line: &'a Line) -> Element<'a, Message> {
..container::Style::default()
});
row![
bar,
mono(ui, &line.text, color)
// 「前缀 + 正文」拆两个文本控件是悬挂缩进的**唯一**落地方式(iced 的单个 `Text` 折行后
// 续行一定回到自己的左边距,没有 `text-indent` 之类的排版钩子):
// 前缀占第一行开头,正文从它右边开始、带着 `Fill` 宽度自己折行 ⇒ 续行自然落在
// `前缀宽度` 之后,与下一条日志的起点(x = 等级条右边)分开。
let text = row![
mono(ui, prefix, color),
mono(ui, body, color)
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
]
.spacing(ui.space(Space::X2))
.spacing(0.0)
.width(Length::Fill)
.align_y(Alignment::Start)
.into()
.align_y(Alignment::Start);
row![bar, text]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Start)
.into()
}
/// 拆「前缀」与「正文」:前缀 = 第一个空格及其之前的部分(**含**那个空格,所以接缝处不用再加
/// 间距),正文 = 其余。
///
/// 拆的条件:空格在 1..=[`PREFIX_MAX_CHARS`] 个字符之内。超长(一整行没有空格、或第一个
/// 「词」本身就比 20 字符长,例如以 URL 开头的行)就**不拆**,整行当一个文本块折行——否则
/// 前缀会把正文挤成一条 1 字符宽的缝。
pub(super) fn split_prefix(text: &str) -> (&str, &str) {
match text.find(' ') {
Some(index) if index > 0 && index < PREFIX_MAX_CHARS => text.split_at(index + 1),
_ => ("", text),
}
}
/// 等级 → 颜色(错误用 `Error`,警告用 `Tertiary`:与主题的 warning 槽位同源)。