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:
1 parent
063a1dba39
commit
1bccf89e5c
10 files changed
+293
-93
No files matched your search
@@ -112,15 +112,17 @@ pub(super) fn model(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// 语言入口还没接到 `Ui`(`theme.rs` 归 M93),产品路径先英文。
|
||||
let lang = Lang::En;
|
||||
|
||||
pages::Model {
|
||||
screen,
|
||||
window_width: width,
|
||||
active: route.nav(),
|
||||
instances: pages::Instances { rows, detail },
|
||||
download: pages::Download::from_snapshot(state, category, unlucky_i18n::Lang::En.catalog()),
|
||||
logs: logs_model(state),
|
||||
// 语言入口还没接到 `Ui`(`theme.rs` 归 M93),产品路径先英文。
|
||||
lang: Lang::En,
|
||||
logs: logs_model(state).with_status(lang),
|
||||
lang,
|
||||
forced: forced.map(|state| match state {
|
||||
crate::cli::ForcedState::Hover => Interaction::Hovered,
|
||||
crate::cli::ForcedState::Pressed => Interaction::Pressed,
|
||||
@@ -163,6 +165,7 @@ fn logs_model(state: &AppState) -> pages::Logs {
|
||||
.map(|report| diagnosis_of(&report.diagnosis))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..pages::Logs::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,13 +123,11 @@ pub fn view<'a>(
|
||||
frame::page(ui, metrics, header, body)
|
||||
}
|
||||
|
||||
/// 页头副标题:**静态 key**(两行页头的第二行),计数留给卡片标题行。
|
||||
fn subtitle(catalog: &Catalog, logs: &Logs) -> &'static str {
|
||||
if logs.is_empty() {
|
||||
catalog.text(Key::LogsSubtitleIdle)
|
||||
} else {
|
||||
catalog.text(Key::LogsSubtitleActive)
|
||||
}
|
||||
/// 页头副标题:有会话身份(实例 · 时间 · 结局)就显示它,否则退回静态文案。
|
||||
fn subtitle<'a>(catalog: &Catalog, logs: &'a Logs) -> &'a str {
|
||||
logs.status
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| catalog.text(Key::LogsSubtitleIdle))
|
||||
}
|
||||
|
||||
/// 空态(`feedback::EmptyState` 真组件:图标块 + 标题 + 说明 + 一个 CTA)。
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
//! **中文在这里是「规则表的输出(数据)」,不是界面文案**:界面文案(标题 / 按钮 / 分组名 /
|
||||
//! 空态)全部走 `unlucky_i18n` 的 key,见 `logs.rs`。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use super::model::{Finding, Level, Line, Logs, Outcome, Severity};
|
||||
|
||||
/// 挑演示状态的环境变量。
|
||||
@@ -54,28 +56,31 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
/// 造出这个状态的视图模型。
|
||||
/// 造出这个状态的视图模型(`lang` 用于页头副标题的 i18n)。
|
||||
#[must_use]
|
||||
pub fn build(self) -> Logs {
|
||||
pub fn build(self, lang: Lang) -> Logs {
|
||||
match self {
|
||||
Self::Empty => Logs::default(),
|
||||
Self::Empty => Logs::default().with_status(lang),
|
||||
Self::Live => Logs {
|
||||
outcome: Some(Outcome::Ok),
|
||||
findings: Vec::new(),
|
||||
..crash()
|
||||
},
|
||||
Self::Crash => crash(),
|
||||
}
|
||||
.with_status(lang),
|
||||
Self::Crash => crash().with_status(lang),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// harness 入口:按 [`STATE_ENV`] 挑状态。
|
||||
#[must_use]
|
||||
pub fn demo() -> Logs {
|
||||
State::of_env_value(std::env::var(STATE_ENV).ok().as_deref()).build()
|
||||
pub fn demo(lang: Lang) -> Logs {
|
||||
State::of_env_value(std::env::var(STATE_ENV).ok().as_deref()).build(lang)
|
||||
}
|
||||
|
||||
/// 崩溃现场(日志 + 三条诊断,覆盖 critical / warning 两个分组)。
|
||||
///
|
||||
/// 不带 `status`(副标题要 i18n,见 [`Logs::with_status`]):[`State::build`] 会补上。
|
||||
#[must_use]
|
||||
pub fn crash() -> Logs {
|
||||
Logs {
|
||||
@@ -84,6 +89,7 @@ pub fn crash() -> Logs {
|
||||
outcome: Some(Outcome::Crashed),
|
||||
lines: parse(SAMPLE),
|
||||
findings: findings(),
|
||||
..Logs::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,11 @@ use super::lines::{mono, mono_owned};
|
||||
use super::model::{Finding, Logs, Severity, GROUPS};
|
||||
use super::panel;
|
||||
|
||||
/// 诊断卡片(只在有结论时画)。高度由调用方定(见 [`panel`]),组与摘要在卡片内滚动。
|
||||
/// 诊断卡片(只在有结论时画)。高度由调用方定(见 [`panel`])。
|
||||
///
|
||||
/// 结构 = 头(`DIAGNOSIS` + 条数)/ **可滚动的分组区** / **固定的摘要页脚**。
|
||||
/// 摘要钉在页脚而不是跟着滚(视觉复审 P2-3):三条结论就足以把它顶到折叠线以下,
|
||||
/// 而「Copy」复制的是用户必须看得见的东西——看不见的字符串不该能被复制。
|
||||
pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Element<'a, Message> {
|
||||
let catalog = Catalog::of(lang);
|
||||
|
||||
@@ -59,49 +63,22 @@ pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Eleme
|
||||
}
|
||||
}
|
||||
|
||||
group = group.push(frame::divider(ui));
|
||||
group = group.push(summary_block(ui, &catalog, logs));
|
||||
|
||||
panel::card(
|
||||
ui,
|
||||
height,
|
||||
column![head_row(ui, &catalog, logs), panel::scroll(ui, group)].width(Length::Fill),
|
||||
)
|
||||
}
|
||||
|
||||
/// 卡片头部:`DIAGNOSIS` + 条数 + **始终可见的「Copy summary」**。
|
||||
///
|
||||
/// 复制按钮钉在头上而不是跟着摘要滚:三条结论就足以把摘要推到折叠线以下,
|
||||
/// 而「换 issue 时粘一行」正是这页的出口,不能要用户先滚一下才发现它。
|
||||
fn head_row<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, Message> {
|
||||
row![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
true,
|
||||
catalog.text(Key::LogsDiagnosisTitle),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
iced::widget::Space::new().width(Length::Fill),
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
logs.findings.len().to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::LogsCopySummary),
|
||||
Message::CopySummary(logs.summary()),
|
||||
column![
|
||||
title_row(
|
||||
ui,
|
||||
catalog.text(Key::LogsDiagnosisTitle),
|
||||
logs.findings.len(),
|
||||
),
|
||||
),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(ui.space(Space::X2))
|
||||
.into()
|
||||
panel::scroll(ui, group),
|
||||
frame::divider(ui),
|
||||
summary_block(ui, &catalog, logs),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
}
|
||||
|
||||
/// 标题行:左边标签(大写字距靠令牌档),右边条数。
|
||||
@@ -179,16 +156,30 @@ fn finding_block<'a>(ui: &'a Ui, catalog: &Catalog, finding: &'a Finding) -> Ele
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// 一行摘要(等宽、可整段带走;复制按钮在卡片头上,见 [`head_row`])。
|
||||
/// **固定的摘要页脚**:标签 +「Copy summary」+ 等宽摘要(可整段带走)。
|
||||
///
|
||||
/// 页脚不滚动(见 [`card`] 的文档):按钮紧邻它要复制的那段文字,用户复制的一定是看得见的串。
|
||||
fn summary_block<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, Message> {
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
catalog.text(Key::LogsSummaryLabel),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
row![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
catalog.text(Key::LogsSummaryLabel),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
iced::widget::Space::new().width(Length::Fill),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::LogsCopySummary),
|
||||
Message::CopySummary(logs.summary()),
|
||||
),
|
||||
),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
mono_owned(ui, logs.summary(), ui.color(Role::OnSurface))
|
||||
.width(Length::Fill)
|
||||
.wrapping(Wrapping::WordOrGlyph),
|
||||
|
||||
@@ -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 槽位同源)。
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
//! (`rules.d/*.yaml` 的 `cause.key: diagnose.cause.…`)。DTO 没带 `key`,页面就把规则原文
|
||||
//! 当**数据**画出来,页面的界面文案(标题 / 按钮 / 分组名 / 空态)仍然全部走 i18n key。
|
||||
|
||||
use unlucky_i18n::catalog::{Catalog, Key};
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
/// 一行日志的等级(presenter `LogLevel` 的展示投影)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Level {
|
||||
@@ -148,6 +151,9 @@ pub struct Logs {
|
||||
pub when: Option<String>,
|
||||
/// 结局。
|
||||
pub outcome: Option<Outcome>,
|
||||
/// 页头副标题(`实例 · 时间 · 结局`):**构造时**拼好(见 [`Logs::with_status`]),
|
||||
/// 页面只负责显示——i18n 的「一句话 = 一个 key」不许在渲染里拼句子。
|
||||
pub status: Option<String>,
|
||||
/// 日志行(顺序 = 文件顺序)。
|
||||
pub lines: Vec<Line>,
|
||||
/// 诊断结论(顺序 = presenter 给的顺序,页面不重排)。
|
||||
@@ -155,6 +161,21 @@ pub struct Logs {
|
||||
}
|
||||
|
||||
impl Logs {
|
||||
/// 补上页头副标题(M95 r2,视觉复审 P2-2)。
|
||||
///
|
||||
/// 为什么在这里做而不是在页面里:`Header::subtitle` 要 `&'a str`,而拼出来的是 `String`;
|
||||
/// 让模型持有它,页面就永远只借不拼。
|
||||
#[must_use]
|
||||
pub fn with_status(mut self, lang: Lang) -> Self {
|
||||
self.status = status_line(
|
||||
self.instance.as_deref(),
|
||||
self.when.as_deref(),
|
||||
self.outcome,
|
||||
lang,
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// 没有任何可显示的东西(空态判据:行与诊断都空)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
@@ -216,3 +237,37 @@ impl Logs {
|
||||
|
||||
/// 摘要行的版本头(改了格式就换代,别让旧 issue 里的摘要含义漂移)。
|
||||
pub const SUMMARY_HEADER: &str = "ULL-LOGS v1";
|
||||
|
||||
/// 页头副标题:`{instance} · {when} · {outcome}`。
|
||||
///
|
||||
/// 结局文案走 [`Key::LaunchOutcomeNormal`] / [`Key::LaunchOutcomeCrash`] /
|
||||
/// [`Key::LaunchOutcomeSilent`] —— 就是 `unlucky_i18n::bridge` 文档里写了很久的那条
|
||||
/// 「`LogOutcome` 三态 → 三 key 结构化出口」,M95 r2 之前它们在 UI 侧**零调用点**(视觉复审 P2-2)。
|
||||
///
|
||||
/// 没有实例名或结局时返回 `None`(不知道是哪一场就别编),页头退回静态副标题。
|
||||
#[must_use]
|
||||
pub fn status_line(
|
||||
instance: Option<&str>,
|
||||
when: Option<&str>,
|
||||
outcome: Option<Outcome>,
|
||||
lang: Lang,
|
||||
) -> Option<String> {
|
||||
let instance = instance?;
|
||||
let outcome = outcome?;
|
||||
let catalog = Catalog::of(lang);
|
||||
|
||||
let outcome_key = match outcome {
|
||||
Outcome::Ok => Key::LaunchOutcomeNormal,
|
||||
Outcome::Crashed => Key::LaunchOutcomeCrash,
|
||||
Outcome::SilentFail => Key::LaunchOutcomeSilent,
|
||||
};
|
||||
|
||||
Some(catalog.render(
|
||||
Key::LogsStatus,
|
||||
&[
|
||||
("instance", instance),
|
||||
("when", when.unwrap_or("—")),
|
||||
("outcome", catalog.text(outcome_key)),
|
||||
],
|
||||
))
|
||||
}
|
||||
@@ -16,40 +16,61 @@
|
||||
//! 两者都是**兄弟**滚动区(不是嵌套):滚轮归属清楚,不存在「滚动条套滚动条」。
|
||||
|
||||
use iced::widget::{container, scrollable};
|
||||
use iced::{Background, Border, Element, Length};
|
||||
use iced::{Background, Element, Length};
|
||||
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::frame;
|
||||
use super::super::Message;
|
||||
|
||||
/// 定高卡片(`Card` 材质层 + `OutlinedCard` 形状,令牌与 `frame::card` 同源)。
|
||||
/// 定高卡片(`Card` 材质层 + `OutlinedCard` 形状与 **1px 描边**,与 `frame::card` 同一套令牌:
|
||||
/// 实例页 / 下载页的卡都带这条描边,日志页的两张主卡不能例外)。
|
||||
pub fn card<'a>(
|
||||
ui: &'a Ui,
|
||||
height: Length,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let shape = Component::OutlinedCard.spec().shape;
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(height)
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||
border: Border {
|
||||
radius: ui.radius_dp(ShapeScale::Large),
|
||||
..Border::default()
|
||||
},
|
||||
border: ui.outlined(shape),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 卡片内的竖向滚动区(滚条样式与实例页列表列同源)。
|
||||
/// 卡片内的竖向滚动区(贴顶:诊断卡从头读)。
|
||||
pub fn scroll<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
scrollable(content)
|
||||
scroller(ui, content, false)
|
||||
}
|
||||
|
||||
/// 卡片内的竖向滚动区(**贴底**:日志卡看最后几行)。
|
||||
///
|
||||
/// `anchor_bottom` = `Anchor::End`:滚动偏移按「距底多远」记,所以**内容增长时视图保持在
|
||||
/// 距底同一位置 ⇒ 天然跟随尾部**。用户上滚后偏移 = 距底 N 像素,新内容到达时仍按「距底 N」
|
||||
/// 呈现(视图跟着内容下移),也就是说**这里没有「上滚即冻结」**——那需要页面自己持有滚动
|
||||
/// 状态 + `on_scroll` 消息 + app 层回灌,属于另一件事(已登记在报告里)。
|
||||
pub fn scroll_tail<'a>(
|
||||
ui: &'a Ui,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
scroller(ui, content, true)
|
||||
}
|
||||
|
||||
fn scroller<'a>(
|
||||
ui: &'a Ui,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
tail: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let view = scrollable(content)
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(frame::SCROLLBAR_WIDTH)
|
||||
@@ -58,6 +79,11 @@ pub fn scroll<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Eleme
|
||||
))
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill)
|
||||
.style(frame::scroll_style(ui))
|
||||
.into()
|
||||
.style(frame::scroll_style(ui));
|
||||
|
||||
if tail {
|
||||
view.anchor_bottom().into()
|
||||
} else {
|
||||
view.into()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 日志页的守门测试。每条断言下面写清「抓得住什么 / 抓不住什么」(这个仓的硬要求)。
|
||||
//!
|
||||
//! 这里**没有截图断言**:像素级验收靠 `pages_demo --screen=logs` 的四张图(浅/深 × 空态/崩溃),
|
||||
//! 单测只钉住「图上看不出来的那部分」——数值令牌、分组顺序、摘要格式、长行不被截断。
|
||||
//! 这里**没有截图断言**:像素级验收靠 `pages_demo --screen=logs` 的六张图(浅/深 × 空态/有日志/崩溃),
|
||||
//! 单测只钉住「图上看不出来的那部分」——数值令牌、分组顺序、摘要格式、悬挂缩进的拆分规则、
|
||||
//! 页头会话身份的拼法。
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::{FontFamily, Platform};
|
||||
@@ -11,7 +12,7 @@ use unlucky_i18n::Lang;
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit};
|
||||
use super::model::{GROUPS, SUMMARY_HEADER};
|
||||
use super::model::{self, GROUPS, SUMMARY_HEADER};
|
||||
use super::{demo, lines, *};
|
||||
|
||||
fn ui() -> Ui {
|
||||
@@ -136,6 +137,70 @@ fn the_longest_stack_line_reaches_the_view_model_untruncated() {
|
||||
assert!(!logs.lines.iter().any(|line| line.text.ends_with('…')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_is_split_into_prefix_and_body_for_the_hanging_indent() {
|
||||
// 抓得住:悬挂缩进被改回「整行一个文本块」(`at` 会重新独占一行、续行回到行首)。
|
||||
// 抓不住:缩进到底落在第几像素(那要量字形/截图,见报告的前后对比图)。
|
||||
assert_eq!(
|
||||
lines::split_prefix("[19:40:12] [main/INFO] hi"),
|
||||
("[19:40:12] ", "[main/INFO] hi")
|
||||
);
|
||||
assert_eq!(
|
||||
lines::split_prefix("at com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64)"),
|
||||
(
|
||||
"at ",
|
||||
"com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64)"
|
||||
)
|
||||
);
|
||||
|
||||
// 没有空格 / 第一个「词」过长 ⇒ 不拆(否则正文被挤成一条 1 字符宽的缝)。
|
||||
assert_eq!(
|
||||
lines::split_prefix("java.lang.RuntimeException"),
|
||||
("", "java.lang.RuntimeException")
|
||||
);
|
||||
assert_eq!(
|
||||
lines::split_prefix("https://example.invalid/aaaaaaaaaaaaaaaaaaaaaaaa va"),
|
||||
("", "https://example.invalid/aaaaaaaaaaaaaaaaaaaaaaaa va")
|
||||
);
|
||||
// CJK 行:第一个空格若落在 20 字符以内就正常拆(时间戳 + 中文正文)。
|
||||
assert_eq!(
|
||||
lines::split_prefix("[19:40:12] 正在启动 1.20.4"),
|
||||
("[19:40:12] ", "正在启动 1.20.4")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_line_names_the_session_with_the_launch_outcome_keys() {
|
||||
// 抓得住:页头副标题不再说「这是哪个实例、哪一场、什么结局」(视觉复审 P2-2),
|
||||
// 以及 `LaunchOutcome*` 三 key 又回到「定义了但零调用点」。
|
||||
// 抓不住:副标题在 1180 下会不会撞上右侧动作区(那要看截图)。
|
||||
let en = model::status_line(
|
||||
Some("开发测试"),
|
||||
Some("11-02 19:40"),
|
||||
Some(Outcome::Crashed),
|
||||
Lang::En,
|
||||
)
|
||||
.expect("有实例名与结局就该有副标题");
|
||||
assert_eq!(en, "开发测试 · 11-02 19:40 · The game crashed");
|
||||
|
||||
let zh = model::status_line(
|
||||
Some("开发测试"),
|
||||
Some("11-02 19:40"),
|
||||
Some(Outcome::SilentFail),
|
||||
Lang::ZhCn,
|
||||
)
|
||||
.expect("静默失败也有副标题");
|
||||
assert!(zh.contains("疑似静默失败"), "{zh}");
|
||||
|
||||
// 不知道是哪一场就别编:没有实例名 / 没有结局时退回静态副标题。
|
||||
assert!(model::status_line(None, Some("11-02 19:40"), Some(Outcome::Ok), Lang::En).is_none());
|
||||
assert!(model::status_line(Some("x"), None, None, Lang::En).is_none());
|
||||
|
||||
// demo 的三种状态里,有会话身份的那两种必须带副标题(页面才有东西可显示)。
|
||||
assert!(demo::State::Crash.build(Lang::En).status.is_some());
|
||||
assert!(demo::State::Live.build(Lang::En).status.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_state_is_driven_by_both_lists_being_empty() {
|
||||
let mut logs = Logs::default();
|
||||
@@ -145,7 +210,7 @@ fn empty_state_is_driven_by_both_lists_being_empty() {
|
||||
// 抓得住:把「没有日志行」当成空态(有诊断时那样会藏掉最该看的东西)。
|
||||
assert!(!logs.is_empty(), "只有诊断也不算空态");
|
||||
|
||||
let live = demo::State::Live.build();
|
||||
let live = demo::State::Live.build(Lang::En);
|
||||
assert!(!live.is_empty());
|
||||
assert!(live.findings.is_empty(), "live 状态没有诊断");
|
||||
}
|
||||
@@ -164,7 +229,7 @@ fn every_key_the_page_uses_is_translated_in_both_languages() {
|
||||
let keys = [
|
||||
Key::NavLogs,
|
||||
Key::LogsSubtitleIdle,
|
||||
Key::LogsSubtitleActive,
|
||||
Key::LogsStatus,
|
||||
Key::LogsOpenFolder,
|
||||
Key::LogsClear,
|
||||
Key::LogsOutputTitle,
|
||||
@@ -179,6 +244,10 @@ fn every_key_the_page_uses_is_translated_in_both_languages() {
|
||||
Key::LogsEmptyTitle,
|
||||
Key::LogsEmptyBody,
|
||||
Key::LogsEmptyAction,
|
||||
// 结局文案由页头副标题间接用到(见 `status_line`)
|
||||
Key::LaunchOutcomeNormal,
|
||||
Key::LaunchOutcomeCrash,
|
||||
Key::LaunchOutcomeSilent,
|
||||
];
|
||||
|
||||
// 抓得住:页面用到的 key 被改名/删掉,或只进了其中一套目录(缺译会返回 `⟪missing⟫`)。
|
||||
|
||||
@@ -371,7 +371,7 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model {
|
||||
model.download = download::demo(unlucky_i18n::Lang::En.catalog());
|
||||
}
|
||||
if screen == Screen::Logs {
|
||||
model.logs = logs::demo();
|
||||
model.logs = logs::demo(model.lang);
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
@@ -170,8 +170,10 @@ catalog! {
|
||||
// --- logs page (M95) ---
|
||||
// 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里:
|
||||
// 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。
|
||||
// `LogsStatus` 是**模板**:中英差别落在分隔符上(中文用全角竖线——`·` 是外来符号,
|
||||
// 中文正文里偏挤;这也让「中文目录必须与英文不同」这条守卫成立,不是为过测试硬造差异)。
|
||||
LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"),
|
||||
LogsSubtitleActive => ("Launcher and game output", "启动器与游戏输出"),
|
||||
LogsStatus => ("{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
|
||||
LogsOpenFolder => ("Open folder", "打开目录"),
|
||||
LogsClear => ("Clear", "清空"),
|
||||
LogsOutputTitle => ("OUTPUT", "输出"),
|
||||
|
||||
Reference in new issue
Block a user