//! 日志页:**单列**版面的第二个用例(两行页头 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()); } } }