两位 reviewer 都量到:`CHAR_ADVANCE_EM = 0.5` 是估的,14 × 0.5 × 72 = 504px 实际能放 83 字符, 「72 字符」只活在名字里。改成实测: - 口径:`BodyMedium` 14px / Segoe UI Regular / 无 letter-spacing(iced 0.14 不支持字距), 样本 = 实例页详情那段正文(361 字符,大小写/标点/空格/`→` 混排),FreeType getlength 实测 **2185.00px / 361 = 6.0526 px/字符 = 0.43233em**(旧 0.5 高估 15.7%); 交叉验证:同段首行 78 字符渲染墨迹 484px,FreeType 对同一串 479px,差 1%(抗锯齿外扩)。 - `CHAR_ADVANCE_EM` 0.5 → **0.43233**;`measure_cap_px()` 加 **floor** → **435** (435.79 四舍五入成 436 就会让最后一两个字符挤不进 72 字符的承诺);下限 315 → 272。 - 版面连带重算:`META_CARD_MIN_WIDTH` 160 → **136**(3×136 + 2×12 = 432 ≤ 435,否则 1180 宽下 元信息网格会从 3 列掉到 2 列)。**816 / 720 两个两列阈值不受影响**(它们由 LIST/DETAIL/FORM 宽度算, 与行宽上限无关),单测里的绝对值断言原样成立。 - 单测:`line_length_is_capped_at_72_characters` 里新增 tower 要求的断言 —— 用**实测步进** (样本字符数 + 实测宽度,独立于常量)反算「上限列宽能放多少字符」≤ 72(实际 71.87), 并加一条回归护栏:同一实测步进下旧口径 504px = 83.3 字符,改回 0.5em 立刻红。 - 文档:`layout.rs` §3 补「怎么重新量」(字体、样本、工具、交叉验证、CJK 不适用)与全部数字; `mod.rs` / 四页文档里的 ≤504 同步改 435。
146 lines
4.8 KiB
Rust
146 lines
4.8 KiB
Rust
//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤435)。
|
||
//!
|
||
//! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态),
|
||
//! 高度走 `LayoutMetrics::page_header_tall_height`(84),这是 12b §16.1 那栏在骨架期的落地。
|
||
//!
|
||
//! 内容区给「日志源」一张卡(文件行)+ 空态位(当前没有输出)。真正的等宽日志视图属于
|
||
//! 后续「日志页接事件流」的 mission,本页只负责版式。
|
||
|
||
use iced::widget::{column, row};
|
||
use iced::{Alignment, Element, Length, Padding};
|
||
|
||
use unlucky_design::color::Role;
|
||
use unlucky_design::component_map::Component;
|
||
use unlucky_design::icons::IconKind;
|
||
use unlucky_design::spacing::Space;
|
||
use unlucky_design::typography::TypeRole;
|
||
|
||
use crate::theme::Ui;
|
||
|
||
use super::frame::{self, Header};
|
||
use super::layout::FrameMetrics;
|
||
use super::{Action, Message};
|
||
|
||
/// 演示日志源(文件名 + 大小/时间)。
|
||
const SOURCES: [(&str, &str); 3] = [
|
||
("launcher.log", "128 KiB · live"),
|
||
("launch-2026-11-01T09-14-02.log", "412 KiB · 09:14"),
|
||
("diagnose-2026-10-31.yaml", "18 KiB · yesterday"),
|
||
];
|
||
|
||
/// 整页。
|
||
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
|
||
let header = Header::new("Logs")
|
||
.subtitle("Launcher and game output · nothing is running right now")
|
||
.action(frame::text_action(ui, "Open folder", Action::OpenLogs))
|
||
.action(frame::text_action(
|
||
ui,
|
||
"Copy diagnostics",
|
||
Action::CopyDiagnostics,
|
||
))
|
||
.action(frame::text_action(ui, "Clear", Action::ClearLogs));
|
||
|
||
let body = frame::single_column(
|
||
metrics,
|
||
column![
|
||
source_card(ui),
|
||
frame::empty_state(
|
||
ui,
|
||
IconKind::Logs,
|
||
"No output yet",
|
||
"Logs stream here while the launcher or the game is running. Start an instance to see \
|
||
the first lines appear.",
|
||
vec![frame::text_action(ui, "Start an instance", Action::Play)],
|
||
),
|
||
]
|
||
.spacing(ui.space(Space::X4))
|
||
.width(Length::Fill)
|
||
.height(Length::Fill),
|
||
);
|
||
|
||
frame::page(ui, metrics, header, body)
|
||
}
|
||
|
||
/// 日志源列表(12b 的 `ListItem` 档:行文 `BodyLarge`、副文案 `BodySmall`)。
|
||
fn source_card<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||
let spec = Component::ListItem.spec();
|
||
let mut rows = column![].spacing(0.0).width(Length::Fill);
|
||
|
||
for (index, (name, meta)) in SOURCES.iter().enumerate() {
|
||
if index > 0 {
|
||
rows = rows.push(frame::divider(ui));
|
||
}
|
||
|
||
rows = rows.push(
|
||
row![
|
||
frame::glyph_box(
|
||
IconKind::Logs,
|
||
ui.color(Role::OnSurfaceVariant),
|
||
frame::ICON_PX
|
||
),
|
||
column![
|
||
frame::text_role(ui, spec.text, false, name, ui.color(Role::OnSurface)),
|
||
frame::text_role(
|
||
ui,
|
||
spec.support.unwrap_or(TypeRole::BodySmall),
|
||
false,
|
||
meta,
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
]
|
||
.spacing(0.0)
|
||
.width(Length::Fill),
|
||
]
|
||
.spacing(ui.space(Space::X3))
|
||
.width(Length::Fill)
|
||
.align_y(Alignment::Center)
|
||
.padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)])),
|
||
);
|
||
}
|
||
|
||
frame::card(ui, rows)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit};
|
||
use super::*;
|
||
use unlucky_design::color::SchemeFlavor;
|
||
use unlucky_design::typography::Platform;
|
||
|
||
use crate::theme::Families;
|
||
|
||
#[test]
|
||
fn logs_page_is_single_column_and_capped() {
|
||
for width in [700.0, 1180.0, 1600.0] {
|
||
let metrics = frame_metrics(PaneKit::Single, width);
|
||
assert!(metrics.secondary.is_none());
|
||
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn header_is_two_lines_because_of_the_status_subtitle() {
|
||
let ui = Ui::with_platform(
|
||
SchemeFlavor::Dark,
|
||
Platform::Windows,
|
||
Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
},
|
||
);
|
||
assert_eq!(ui.metrics().header_height(true), 84.0);
|
||
assert!(ui.metrics().header_height(true) > ui.metrics().header_height(false));
|
||
}
|
||
|
||
#[test]
|
||
fn every_source_row_has_a_name_and_meta() {
|
||
assert_eq!(SOURCES.len(), 3);
|
||
for (name, meta) in SOURCES {
|
||
assert!(name.contains("log") || name.contains("yaml"));
|
||
assert!(!meta.is_empty());
|
||
}
|
||
}
|
||
}
|