Files
Unlucky-Launcher/crates/app-iced/src/pages/logs.rs
T
AskaEth fae1b61d9e fix(app-iced): P2-1 「72 字符」改成实测口径(上限 504 → 435)
两位 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。
2026-11-02 00:13:20 +08:00

146 lines
4.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 日志页:**单列**版面的第二个用例(两行页头 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());
}
}
}