diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 36e0218..14f47bf 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -42,6 +42,7 @@ use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW}; use super::CAPTURE_AT_FRAME; mod download; +mod page_messages; impl App { /// 一条消息进来,改状态并返回后续任务。 @@ -96,10 +97,7 @@ impl App { } Task::none() } - Message::Page(message) => { - self.on_page(message); - Task::none() - } + Message::Page(message) => self.on_page(message), // 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转 Message::Select(nav) => { self.state.navigate(route_of(nav)); @@ -275,31 +273,6 @@ impl App { } } - /// 页面消息:导航走路由栈,动作翻成 `AppCommand`。 - fn on_page(&mut self, message: pages::Message) { - // 下载页的四支(含「刷新下载源」)在 `update/download.rs`:本文件 M93 合入后已贴 400 行 - if self.on_download(&message) { - return; - } - - match message { - pages::Message::Navigate(route) => { - self.state.navigate(route); - self.view_dirty = true; - self.refresh_view(); - } - pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), - pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), - pages::Message::Appearance(change) => self.on_appearance(change), - // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) - pages::Message::Action(action) => { - eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); - } - // 下载页四支 +「刷新下载源」已由上面的 `on_download` 消化,这里只是穷尽性兜底 - _ => {} - } - } - /// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。 pub(super) fn dispatch(&mut self, command: AppCommand) { self.backend.dispatch(command); diff --git a/crates/app-iced/src/app/update/download_wiring.rs b/crates/app-iced/src/app/update/download_wiring.rs index c5c06de..fbe6525 100644 --- a/crates/app-iced/src/app/update/download_wiring.rs +++ b/crates/app-iced/src/app/update/download_wiring.rs @@ -41,10 +41,12 @@ fn download_intents_dispatch_real_commands() { Box::new(Recorder(Rc::clone(&log))), SettingsBridge::memory_only(), ); - app.on_page(pages::Message::InstallVersion("1.21.9".to_string())); - app.on_page(pages::Message::CancelInstall); - app.on_page(pages::Message::SetCategory(pages::Category::Release)); - app.on_page(pages::Message::SelectVersion("1.21.8".to_string())); + // M95 起 `on_page` 返回 `Task`(剪贴板命令要走返回的 Task):这里只关心派发与视图模型, + // 任务本身没有需要断言的后效,显式丢弃。 + let _ = app.on_page(pages::Message::InstallVersion("1.21.9".to_string())); + let _ = app.on_page(pages::Message::CancelInstall); + let _ = app.on_page(pages::Message::SetCategory(pages::Category::Release)); + let _ = app.on_page(pages::Message::SelectVersion("1.21.8".to_string())); let expected = vec![ AppCommand::InstallVersion("1.21.9".to_string()), AppCommand::CancelInstall, diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs new file mode 100644 index 0000000..5183a65 --- /dev/null +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -0,0 +1,91 @@ +//! 页面消息的落点(`on_page`):解 M94 冲突时从 `update.rs` 拆出来。 +//! +//! 为什么拆:`main` 上 `update.rs` 已经 **397 行**(M94 自己也是因此把下载页四支拆到 +//! `update/download.rs`),日志页再往里塞「剪贴板 + 清日志」两支就顶破 400 硬线。 +//! 与 M94 同一种做法:`app/update/**` 属本 mission scope。 +//! +//! ## 穷尽性(M94 复审的 p3,本轮收掉) +//! +//! 这里是全 crate **唯一**对 [`pages::Message`] 的穷尽匹配点,所以**没有** `_ => {}` 兜底: +//! 谁往枚举里加变体却忘了接线,编译器立刻报 E0004。原来是 `_ => {}` + 注释「做穷尽性」—— +//! 方向正好写反了,它**取消**穷尽性,把「漏接线」变成「点了没反应」。 +//! +//! 代价:由前置分支(`on_download` / 本文件里的剪贴板分支)先消费的变体,在 match 里**仍要 +//! 显式列出**。那是保住穷尽性的必要代价,也是这条守卫抓得住东西的原因。 +//! +//! ## 边界(抓得住什么 / 抓不住什么) +//! +//! - 抓得住:**加一个 `pages::Message` 变体却没接线** → 编译失败(E0004),不会静默; +//! - 抓不住:加一个 `pages::Action` 变体 —— 它落进 `Action(action)` 的占位分支(打印一行 +//! `[page] 占位动作 …`),编译期不会报。这是占位期的既有设计,不是本文件能守的。 + +use super::*; + +impl App { + /// 页面消息:导航走路由栈,真业务命令派发,纯视图态改本地呈现。 + pub(super) fn on_page(&mut self, message: pages::Message) -> Task { + // 下载页的四支(含「刷新下载源」)在 `update/download.rs`:`update.rs` 早已贴 400 行 + if self.on_download(&message) { + return Task::none(); + } + + match message { + pages::Message::Navigate(route) => { + self.state.navigate(route); + self.view_dirty = true; + self.refresh_view(); + } + pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), + pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), + pages::Message::Appearance(change) => self.on_appearance(change), + // M95:摘要落剪贴板(文本由页面造,这里只搬) + pages::Message::CopySummary(text) => return iced::clipboard::write(text), + // 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档 + pages::Message::SelectVersion(_) + | pages::Message::InstallVersion(_) + | pages::Message::CancelInstall + | pages::Message::SetCategory(_) => debug_assert!(false, "应由 on_download 消费"), + // M95:清空日志是**呈现层**操作(`log_lines` 是 presenter 的公开快照字段, + // 不派业务命令、不写持久状态) + pages::Message::Action(pages::Action::ClearLogs) => { + self.state.log_lines.clear(); + self.view_dirty = true; + self.refresh_view(); + } + // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) + pages::Message::Action(action) => { + eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); + } + } + + Task::none() + } +} + +#[cfg(test)] +mod tests { + /// 抓得住:有人给 `on_page` 的 match 重新加 `_ => {}` 兜底 —— 那正是 M94 复审的 p3: + /// 兜底让「加了变体忘了接线」从**编译错误**退化成「点了没反应」,这条测试把它拽回来。 + /// 抓不住:别的兜底写法(把 match 拆成 if-else 链、给子枚举加通配之后再漏变体), + /// 也抓不住「加了变体但接错分支」——那两条只有编译器和人读得出来。 + #[test] + fn the_page_match_has_no_wildcard_fallback() { + let source = include_str!("page_messages.rs"); + let wildcards: Vec<(usize, &str)> = source + .lines() + .enumerate() + .filter(|(_, line)| { + let trimmed = line.trim(); + trimmed.starts_with("_ =>") || trimmed.starts_with("_=>") + }) + .map(|(index, line)| (index + 1, line)) + .collect(); + + assert!( + wildcards.is_empty(), + "on_page 里出现了 `_ =>` 兜底(行 {:?}):它会取消对 pages::Message 的穷尽性,\ + 新变体漏接线时编译器不再报 E0004", + wildcards + ); + } +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index b418390..b20f829 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -4,7 +4,10 @@ use iced::{Element, Theme}; -use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage}; +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{ + Diagnosis, InstanceRuntime, InstanceSummary, LaunchStage, LogLevel, LogOutcome, +}; use unlucky_presenter::router::Route; use unlucky_presenter::state::AppState; @@ -115,6 +118,9 @@ pub(super) fn model( 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, forced: forced.map(|state| match state { crate::cli::ForcedState::Hover => Interaction::Hovered, crate::cli::ForcedState::Pressed => Interaction::Pressed, @@ -122,6 +128,75 @@ pub(super) fn model( } } +/// 日志页视图模型(M95):presenter 快照 → 日志行 + 诊断结论。 +/// +/// **页面不读盘**:日志文本只从 `log_lines`(host 尾随 JSONL 后按批次发的事件)来;会话元信息 +/// 从 `log_sessions` 里按「运行中的那一场 → 诊断归属的那一场」找,找不到就不画。 +/// +/// 两处 DTO 侧的**信息丢失**(已报 finding,不在本 scope 修):`dto::Diagnosis` 没有 core 的 +/// `severity`(严重度只能按置信度折算,阈值是债)与 `cause.key`(`title`/`hint` 只能当数据画)。 +fn logs_model(state: &AppState) -> pages::Logs { + let report = state.diagnosis.as_ref(); + let current = state.selected_session.as_deref(); + let session = report + .and_then(|report| state.log_sessions.iter().find(|s| s.id == report.session)) + .or_else(|| { + state + .log_sessions + .iter() + .find(|s| Some(s.id.as_str()) == current) + }); + + pages::Logs { + instance: session.map(|s| s.instance.clone()), + when: session.map(|s| s.when.clone()), + outcome: session.map(|s| log_outcome(s.outcome)), + lines: state + .log_lines + .iter() + .map(|line| pages::Line { + text: line.text.clone(), + level: log_level(line.level), + }) + .collect(), + findings: report + .map(|report| diagnosis_of(&report.diagnosis)) + .into_iter() + .collect(), + } +} + +/// 日志等级:presenter 枚举 → 页面等级(**结构化出口**,不是认字符串)。 +fn log_level(level: LogLevel) -> pages::Level { + match level { + LogLevel::Debug => pages::Level::Debug, + LogLevel::Info => pages::Level::Info, + LogLevel::Warn => pages::Level::Warn, + LogLevel::Error => pages::Level::Error, + } +} + +/// 会话结局:presenter 枚举 → 页面结局(文案走 `LaunchOutcome*` key,页面不画 core 的中文串)。 +fn log_outcome(outcome: LogOutcome) -> pages::Outcome { + match outcome { + LogOutcome::Ok => pages::Outcome::Ok, + LogOutcome::Crashed => pages::Outcome::Crashed, + LogOutcome::SilentFail => pages::Outcome::SilentFail, + } +} + +/// 一条诊断结论 → 页面结论(严重度折算见 [`logs_model`] 的文档)。 +fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding { + pages::Finding { + rule: diagnosis.rule.clone(), + severity: pages::Severity::of_confidence(diagnosis.confidence), + title: diagnosis.title.clone(), + hint: diagnosis.hint.clone(), + confidence: diagnosis.confidence, + action: diagnosis.fix.clone(), + } +} + /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版」,见 `LoaderInfo::badge`)。 fn supporting_line(instance: &InstanceSummary) -> String { format!("{} · {}", instance.mc_version, instance.loader.badge()) diff --git a/crates/app-iced/src/pages/logs.rs b/crates/app-iced/src/pages/logs.rs index 32d2832..1601ba2 100644 --- a/crates/app-iced/src/pages/logs.rs +++ b/crates/app-iced/src/pages/logs.rs @@ -1,145 +1,149 @@ -//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤424)。 +//! 日志页(M95):**单列**版面的第二个用例,也是本项目卖点(崩溃日志诊断)的落地页。 //! -//! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态), -//! 高度走 `LayoutMetrics::page_header_tall_height`(84),这是 12b §16.1 那栏在骨架期的落地。 +//! ## 一屏由三块组成 //! -//! 内容区给「日志源」一张卡(文件行)+ 空态位(当前没有输出)。真正的等宽日志视图属于 -//! 后续「日志页接事件流」的 mission,本页只负责版式。 +//! ```text +//! ┌ 页头(两行 84:标题 + 运行状态;右侧 Open folder / Clear)──────────┐ +//! ├ 诊断卡片(只在有结论时出现,**按严重度分组**)──────────────────────┤ +//! │ DIAGNOSIS 3 CRITICAL 1 / WARNING 2 │ +//! │ integrity.unsatisfied_link(规则 id,等宽) │ +//! │ 结论(规则表原文) / 说明 / Suggested fix / Confidence 85% │ +//! │ ─ One-line summary ─ (等宽可复制的一行) [Copy summary] │ +//! ├ 日志行列表(等宽 + 折行 + **独立滚动**,吃满剩余高度)──────────────┤ +//! └──────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! 数据源只有两个(页面**不读盘**,路径从不进过页面): +//! +//! | 来源 | 谁造 | 用途 | +//! |---|---|---| +//! | presenter 快照(`log_lines` / `diagnosis` / `log_sessions`) | `app/view.rs::logs_model` | 产品运行时 | +//! | [`demo`] 内嵌的崩溃日志片段 | `pages::demo_model`(`ULL_LOGS_FIXTURE=empty\|live\|crash`) | 出图 | +//! +//! ## M95 的取舍(逐条) +//! +//! | # | 取舍 | 为什么 | +//! |---|---|---| +//! | 1 | **折行**而不是横向滚动 | 见 [`lines`] 模块文档:424px × 200 字符的现实下,横滚与外壳竖向滚动打架,且等宽族的 CJK 回退会毁掉「按列对齐」这个横滚唯一的好处 | +//! | 2 | 诊断卡片在日志列表**之上** | 崩溃时用户先要结论;把结论放在 14 行日志下面等于让人先翻页 | +//! | 3 | 规则原文(中文)当**数据**直接画 | 界面文案全走 i18n key;规则表的 `cause.summary` 是诊断结论本身,翻译要等 `rules.d` 带 `cause.key` 进 DTO(已报 finding) | +//! | 4 | 严重度按**置信度折算** | `dto::Diagnosis` 没有 `severity` 字段(core 有,DTO 丢了,已报 finding),阈值写在 `Severity::of_confidence` | +//! | 5 | 页头保持**两行 84** | M84 定的版式(运行状态在副标题上);计数留在两张卡片自己的标题行,避免为了拼计数把 i18n 字符串漏进页头生命期 | +//! +//! ## 指令(只有真业务命令才 dispatch) +//! +//! | 控件 | 消息 | 落到哪 | +//! |---|---|---| +//! | Copy summary | [`Message::CopySummary`] | `app/update.rs` → `iced::clipboard::write` | +//! | Open folder | `Action::OpenLogs` | **还没有 `AppCommand`**(报 finding,现在是占位分支) | +//! | Clear | `Action::ClearLogs` | `app/update.rs` → 清空 presenter 的 `log_lines` | +//! | 空态的 CTA | [`Message::Navigate`] | 压一层路由栈回实例页(真导航,不是占位动作) | +//! +//! 截图的强弱边界写在 `logs/tests.rs` 每条断言下面。 -use iced::widget::{column, row}; -use iced::{Alignment, Element, Length, Padding}; +mod demo; +mod diagnostic; +mod lines; +mod model; +mod panel; + +#[cfg(test)] +mod tests; + +/// 诊断卡在正文里的高度份额(见 [`panel`] 的模块文档:两块必须都是定高 + 内部滚动)。 +const DIAGNOSIS_SHARE: u16 = 3; +/// 日志卡的高度份额。 +const LOGS_SHARE: u16 = 2; + +pub use demo::demo; +pub use model::{Finding, Level, Line, Logs, Outcome, Severity}; + +use iced::widget::column; +use iced::{Element, Length}; -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 unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_i18n::Lang; +use unlucky_presenter::router::Route; +use crate::app::components::buttons::{self, Button}; +use crate::app::components::feedback::empty_state::{empty_state, EmptyState}; 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)); +pub fn view<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + logs: &'a Logs, + lang: Lang, +) -> Element<'a, Message> { + let catalog = Catalog::of(lang); - 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)], + let header = Header::new(catalog.text(Key::NavLogs)) + .subtitle(subtitle(&catalog, logs)) + .action(buttons::text( + ui, + Button::new( + catalog.text(Key::LogsOpenFolder), + Message::Action(Action::OpenLogs), ), + )) + .action(buttons::text( + ui, + Button::new( + catalog.text(Key::LogsClear), + Message::Action(Action::ClearLogs), + ), + )); + + let body = if logs.is_empty() { + frame::single_column(metrics, empty(ui, &catalog)) + } else if logs.findings.is_empty() { + // 没有结论:日志卡片独吞整页(这样「正常运行的一场」不会留一块空卡片)。 + frame::single_column(metrics, lines::card(ui, logs, lang, Length::Fill)) + } else { + // 有结论:两块**都定高**、各自独立滚动(诊断 3 : 日志 2)。 + // 定高不是偷懒:第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(截图见报告)。 + let stack = column![ + diagnostic::card(ui, logs, lang, Length::FillPortion(DIAGNOSIS_SHARE)), + lines::card(ui, logs, lang, Length::FillPortion(LOGS_SHARE)), ] - .spacing(ui.space(Space::X4)) .width(Length::Fill) - .height(Length::Fill), - ); + .height(Length::Fill) + .spacing(ui.space(Space::X4)); + + frame::single_column(metrics, stack) + }; 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()); - } +/// 页头副标题:**静态 key**(两行页头的第二行),计数留给卡片标题行。 +fn subtitle(catalog: &Catalog, logs: &Logs) -> &'static str { + if logs.is_empty() { + catalog.text(Key::LogsSubtitleIdle) + } else { + catalog.text(Key::LogsSubtitleActive) } } + +/// 空态(`feedback::EmptyState` 真组件:图标块 + 标题 + 说明 + 一个 CTA)。 +fn empty<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Message> { + empty_state( + ui, + EmptyState::new( + IconKind::Logs, + catalog.text(Key::LogsEmptyTitle), + catalog.text(Key::LogsEmptyBody), + ) + .action( + catalog.text(Key::LogsEmptyAction), + Message::Navigate(Route::Instances), + ), + ) +} diff --git a/crates/app-iced/src/pages/logs/demo.rs b/crates/app-iced/src/pages/logs/demo.rs new file mode 100644 index 0000000..d22c456 --- /dev/null +++ b/crates/app-iced/src/pages/logs/demo.rs @@ -0,0 +1,153 @@ +//! 日志页演示数据:**只给 `pages_demo` 出图与单测**,产品运行时的数据来自 presenter 快照 +//! (`app/view.rs::logs_model`)。页面任何路径都不读盘:日志文本要么来自快照,要么来自 +//! 本文件 `include_str!` 内嵌的 [`SAMPLE`](编译期进二进制,运行时零 I/O)。 +//! +//! ## 为什么用环境变量挑状态 +//! +//! 三种状态(空态 / 有日志 / 有诊断)要分别出图,而 `pages_demo` 的 `--screen` 只有一个 +//! `logs`。环境变量是这个 crate 已有的 harness 口径(`ULL_GALLERY_SHOT` / `ULL_FEEDBACK_PART` +//! 都是这么挑的,见 `components/gallery/mod.rs` 的说明):既能被测试直接调纯函数覆盖, +//! 又不用跟别人的 CLI 解析抢同一个文件。 +//! +//! ```text +//! ULL_LOGS_FIXTURE=empty pages_demo --screen=logs --screenshot=logs-empty-light.png +//! ``` +//! +//! 取值:`empty`(空态)/ `live`(有日志、无诊断)/ 其它或未设 = `crash`(日志 + 三条诊断)。 +//! 默认取 `crash`:一屏就能看见页面的全部结构,reviewer 不必先猜环境变量。 +//! +//! ## 样例内容的出处 +//! +//! [`SAMPLE`] 是按 `core/diagnose/rules.d/samples/graphics/graphics_driver.log` 与 +//! `integrity/unsatisfied_link.log` 的形状写的(含中文 + 堆栈 + 200 字符以上的长行), +//! 诊断结论的 `rule` / `title` / `hint` 抄自规则表 `rules.d/integrity.yaml` 与 `graphics.yaml`。 +//! **中文在这里是「规则表的输出(数据)」,不是界面文案**:界面文案(标题 / 按钮 / 分组名 / +//! 空态)全部走 `unlucky_i18n` 的 key,见 `logs.rs`。 + +use super::model::{Finding, Level, Line, Logs, Outcome, Severity}; + +/// 挑演示状态的环境变量。 +pub const STATE_ENV: &str = "ULL_LOGS_FIXTURE"; + +/// 内嵌的崩溃日志片段(见模块文档的出处)。 +const SAMPLE: &str = include_str!("sample_crash.log"); + +/// 演示状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + /// 空态:没有输出,也没有诊断。 + Empty, + /// 有日志、无诊断(正常跑着的一场)。 + Live, + /// 崩溃现场:日志 + 诊断结论。 + Crash, +} + +impl State { + /// 从环境变量取值解析(未设 / 不认识一律 `Crash`)。 + #[must_use] + pub fn of_env_value(raw: Option<&str>) -> Self { + match raw { + Some("empty") => Self::Empty, + Some("live") => Self::Live, + _ => Self::Crash, + } + } + + /// 造出这个状态的视图模型。 + #[must_use] + pub fn build(self) -> Logs { + match self { + Self::Empty => Logs::default(), + Self::Live => Logs { + outcome: Some(Outcome::Ok), + findings: Vec::new(), + ..crash() + }, + Self::Crash => crash(), + } + } +} + +/// harness 入口:按 [`STATE_ENV`] 挑状态。 +#[must_use] +pub fn demo() -> Logs { + State::of_env_value(std::env::var(STATE_ENV).ok().as_deref()).build() +} + +/// 崩溃现场(日志 + 三条诊断,覆盖 critical / warning 两个分组)。 +#[must_use] +pub fn crash() -> Logs { + Logs { + instance: Some("开发测试".to_owned()), + when: Some("11-02 19:40".to_owned()), + outcome: Some(Outcome::Crashed), + lines: parse(SAMPLE), + findings: findings(), + } +} + +/// 解析内嵌样例:`#` 注释与空行跳过,首字符是等级前缀。 +fn parse(text: &str) -> Vec { + text.lines() + .filter_map(|raw| { + let raw = raw.trim_end(); + if raw.is_empty() || raw.starts_with('#') { + return None; + } + + let (prefix, rest) = raw.split_at(1); + let level = match prefix { + "D" => Level::Debug, + "I" => Level::Info, + "W" => Level::Warn, + "E" => Level::Error, + _ => return None, + }; + + Some(Line { + text: rest.trim_start().to_owned(), + level, + }) + }) + .collect() +} + +/// 三条诊断(`rule` / `title` / `hint` 抄自规则表;`severity` 走 [`Severity::of_confidence`], +/// 也就是 DTO 缺 `severity` 时产品路径用的同一条折算)。 +fn findings() -> Vec { + vec![ + finding( + "integrity.unsatisfied_link", + 85, + "找不到原生库(natives):lwjgl_opengl.dll 没解压出来或被杀软删了", + "重装组件(重新解压 natives);Windows 上检查杀软隔离区", + Some("reinstall natives"), + ), + finding( + "graphics.gl_operation_failure", + 70, + "某个 mod/光影包做了非法的 OpenGL 操作(GL_INVALID_OPERATION)", + "换掉最近装的光影/资源包,或把资源包分辨率降下来", + Some("change_graphics.renderer = OpenGL 兼容模式"), + ), + finding( + "integrity.no_class_def_found", + 60, + "NoClassDefFoundError:缺少类文件(库没下全 / jar 被解压 / mod 不配套)", + "先检查 libraries 是否完整,再排查 mods 里的解压目录", + Some("reinstall libraries"), + ), + ] +} + +fn finding(rule: &str, confidence: u8, title: &str, hint: &str, action: Option<&str>) -> Finding { + Finding { + rule: rule.to_owned(), + severity: Severity::of_confidence(confidence), + title: title.to_owned(), + hint: hint.to_owned(), + confidence, + action: action.map(str::to_owned), + } +} diff --git a/crates/app-iced/src/pages/logs/diagnostic.rs b/crates/app-iced/src/pages/logs/diagnostic.rs new file mode 100644 index 0000000..cc1c9e1 --- /dev/null +++ b/crates/app-iced/src/pages/logs/diagnostic.rs @@ -0,0 +1,222 @@ +//! 诊断结果区:**按严重度分组**的结论卡片 + 可直接粘进 issue 的一行摘要。 +//! +//! ## 一条结论要能看出什么(tower 的三问) +//! +//! | 问题 | 落点 | 数据来源 | +//! |---|---|---| +//! | 哪条规则命中 | 规则 id(等宽、`LabelMedium`) | `Finding::rule` | +//! | 严重度 | 分组标题 + 分组顺序(`CRITICAL` → `WARNING` → `NOTICE`) | `Finding::severity` | +//! | 建议动作 | `Suggested fix` 一行(规则表的 `fix_action`,技术标识原样) | `Finding::action` | +//! +//! ## 用户真的要拿去用的两样东西 +//! +//! 1. **结论原文**(`title` / `hint`):规则表 `rules.d/*.yaml` 写的 `cause.summary` / `detail`。 +//! 它是**数据**(诊断结论),页面不翻译也不改写——改写会让「规则命中」与「用户读到的」 +//! 变成两套说法。界面文案(分组名 / 标签 / 按钮)仍然全部走 i18n key。 +//! 2. **一行摘要**([`Logs::summary`]):等宽显示 + 「Copy summary」按钮;按钮把字符串塞进 +//! 剪贴板(`Message::CopySummary` → `iced::app/update.rs` → `iced::clipboard::write`)。 +//! 崩溃日志换 issue 时粘这一行就够:实例 / 时间 / 结局 / 行数 / 命中的规则与置信度都在里面。 + +use iced::widget::text::{LineHeight, Wrapping}; +use iced::widget::{column, row, text}; +use iced::{Alignment, Color, Element, Length, Pixels}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_design::typography::{TypeRole, TypeStyle}; +use unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_i18n::Lang; + +use crate::app::components::buttons::{self, Button}; +use crate::theme::Ui; + +use super::super::frame; +use super::super::Message; +use super::lines::{mono, mono_owned}; +use super::model::{Finding, Logs, Severity, GROUPS}; +use super::panel; + +/// 诊断卡片(只在有结论时画)。高度由调用方定(见 [`panel`]),组与摘要在卡片内滚动。 +pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Element<'a, Message> { + let catalog = Catalog::of(lang); + + let mut group = column![].spacing(ui.space(Space::X2)).width(Length::Fill); + + for severity in GROUPS { + let findings = logs.group(severity); + if findings.is_empty() { + continue; + } + + group = group.push(frame::divider(ui)); + group = group.push(title_row( + ui, + catalog.text(key_of(severity)), + findings.len(), + )); + for finding in findings { + group = group.push(finding_block(ui, &catalog, finding)); + } + } + + 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()), + ), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center) + .spacing(ui.space(Space::X2)) + .into() +} + +/// 标题行:左边标签(大写字距靠令牌档),右边条数。 +fn title_row<'a>(ui: &'a Ui, label: &'a str, count: usize) -> Element<'a, Message> { + row![ + frame::text_role( + ui, + TypeRole::LabelMedium, + true, + label, + ui.color(Role::OnSurfaceVariant), + ), + iced::widget::Space::new().width(Length::Fill), + frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + count.to_string(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center) + .into() +} + +/// 一条结论:规则 id / 结论 / 说明 / 建议动作 / 置信度。 +fn finding_block<'a>(ui: &'a Ui, catalog: &Catalog, finding: &'a Finding) -> Element<'a, Message> { + let mut block = column![ + mono(ui, &finding.rule, ui.color(Role::OnSurfaceVariant)), + wrapped( + ui, + TypeRole::BodyMedium, + &finding.title, + ui.color(Role::OnSurface) + ), + wrapped( + ui, + TypeRole::BodySmall, + &finding.hint, + ui.color(Role::OnSurfaceVariant) + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + if let Some(action) = &finding.action { + block = block.push( + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::LogsSuggestedFix), + ui.color(Role::OnSurfaceVariant), + ), + mono(ui, action, ui.color(Role::OnSurface)), + ] + .spacing(0.0) + .width(Length::Fill), + ); + } + + block = block.push(frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + catalog.render( + Key::LogsConfidence, + &[("percent", &finding.confidence.to_string())], + ), + ui.color(Role::OnSurfaceVariant), + )); + + block.into() +} + +/// 一行摘要(等宽、可整段带走;复制按钮在卡片头上,见 [`head_row`])。 +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), + ), + mono_owned(ui, logs.summary(), ui.color(Role::OnSurface)) + .width(Length::Fill) + .wrapping(Wrapping::WordOrGlyph), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +/// 严重度 → 分组标题的 key。 +fn key_of(severity: Severity) -> Key { + match severity { + Severity::Critical => Key::LogsSeverityCritical, + Severity::Warning => Key::LogsSeverityWarning, + Severity::Info => Key::LogsSeverityInfo, + } +} + +/// 令牌排版档 + **按词或字形折行**(长堆栈里的点分标识符没有空格,只按词会溢出)。 +fn wrapped<'a>(ui: &'a Ui, role: TypeRole, content: &'a str, color: Color) -> Element<'a, Message> { + let style: TypeStyle = ui.style(role); + + text(content) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(ui.font_of(style)) + .color(color) + .width(Length::Fill) + .wrapping(Wrapping::WordOrGlyph) + .into() +} diff --git a/crates/app-iced/src/pages/logs/lines.rs b/crates/app-iced/src/pages/logs/lines.rs new file mode 100644 index 0000000..4b7666b --- /dev/null +++ b/crates/app-iced/src/pages/logs/lines.rs @@ -0,0 +1,159 @@ +//! 日志行列表:**等宽 + 折行 + 独立滚动**。 +//! +//! ## 为什么折行而不是横向滚动(tower 要求二选一,这里选折行) +//! +//! 正文列硬上限 424px,而崩溃堆栈动辄 200+ 字符(样例里最长的一行 214 字符)。两条路: +//! +//! | 方案 | 代价 | +//! |---|---| +//! | 横向滚动 | 424px 里 200 字符 ≈ 8 屏,读一条 `Caused by:` 要来回甩;外壳已经是竖向滚动,横竖两向嵌套后滚轮归属会打架 | +//! | **折行(本页选)** | 一条日志可能占 2~3 行,行号/等级栏必须能标明「这是同一条」 | +//! +//! 选折行的决定性理由:**折行让等宽族只承担「视觉密度」,不承担「列对齐」**。等宽栈在 +//! Windows 上是 `Cascadia Mono` / `Consolas`,它们没有 CJK 字形——中文日志要靠 cosmic-text +//! 逐字回退到系统 CJK 族(本机实测回退到 `Microsoft YaHei UI`),回退字形的 advance 不是 1ch。 +//! 横向滚动方案里「第 40 列对齐」是有意义的(堆栈缩进、`at a.b.C(File.java:12)` 的括号列), +//! 一旦 CJK 回退把列宽带偏,对齐就废了;折行版面不承诺任何列对齐,回退只影响同一行内的观感。 +//! 左栏那条 2px 等级条是跨行的(`Length::Fill`),所以「同一条日志折了 3 行」在视觉上仍是 +//! 一个整体。 +//! +//! ## 等宽族的实际落点 +//! +//! `ui.families().mono` 是启动时用系统字体库核对过的族名([`crate::fonts`] 求值,启动日志里 +//! 有一行 `mono=…`),本页只通过 [`mono_style`] 用它,不写死族名。字号/行高走 +//! `TypeRole::BodySmall`(12/16),与报告里的数值表对得上(见 `logs/tests.rs`)。 + +use iced::widget::text::LineHeight; +use iced::widget::{column, container, row, space}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels}; + +use unlucky_design::color::Role; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::{FontFamily, TypeRole, TypeStyle}; +use unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_i18n::Lang; + +use crate::theme::Ui; + +use super::super::frame; +use super::super::Message; +use super::model::{Level, Line, Logs}; +use super::panel; + +/// 等级条宽度(2px:够看见,又不抢日志正文的起始列)。 +const BAR_WIDTH: f32 = 2.0; + +/// 日志卡片:标题行(`OUTPUT` + 行数)+ 独立滚动的行列表。高度由调用方定(见 [`panel`])。 +pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Element<'a, Message> { + let catalog = Catalog::of(lang); + + let head = row![ + container(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::LogsOutputTitle), + ui.color(Role::OnSurfaceVariant), + )) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + logs.lines.len().to_string(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center) + .padding(Padding::default().bottom(ui.space(Space::X2))); + + let rows = column( + logs.lines + .iter() + .map(|line| entry(ui, line)) + .collect::>(), + ) + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + panel::card(ui, height, column![head, panel::scroll(ui, rows)]) +} + +/// 一行:等级条(跨行)+ 等宽正文(折行)。 +fn entry<'a>(ui: &'a Ui, line: &'a Line) -> Element<'a, Message> { + let color = color_of(ui, line.level); + + let bar = container(space::horizontal()) + .width(BAR_WIDTH) + .height(Length::Fill) + .style(move |_theme| container::Style { + background: Some(Background::Color(color)), + border: Border { + radius: ui.radius(ShapeScale::Full, BAR_WIDTH), + ..Border::default() + }, + ..container::Style::default() + }); + + row![ + bar, + mono(ui, &line.text, color) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Start) + .into() +} + +/// 等级 → 颜色(错误用 `Error`,警告用 `Tertiary`:与主题的 warning 槽位同源)。 +fn color_of(ui: &Ui, level: Level) -> Color { + match level { + Level::Error => ui.color(Role::Error), + Level::Warn => ui.color(Role::Tertiary), + Level::Info => ui.color(Role::OnSurface), + Level::Debug => ui.color(Role::OnSurfaceVariant), + } +} + +/// 等宽排版档:**同一个 `BodySmall` 档,只把字体族换成 mono 栈**。 +/// +/// 不新造字号(12/16 与辅助说明同档),所以「等宽」这件事只改字体族这一个维度, +/// 行高仍是令牌值——CJK 回退只换字形,行高不会跳(这也是折行方案的前提之一)。 +#[must_use] +pub fn mono_style(ui: &Ui) -> TypeStyle { + TypeStyle { + family: FontFamily::Mono, + ..ui.style(TypeRole::BodySmall) + } +} + +/// 等宽文本(字号 / 行高 / 字重 / 族全来自 [`mono_style`])。 +#[must_use] +pub fn mono<'a>(ui: &Ui, content: &'a str, color: Color) -> iced::widget::Text<'a> { + mono_styled(ui, content, color) +} + +/// 同 [`mono`],但文案是**拥有的**字符串(摘要行这类运行时拼出来的短串)。 +#[must_use] +pub fn mono_owned<'a>(ui: &Ui, content: String, color: Color) -> iced::widget::Text<'a> { + mono_styled(ui, content, color) +} + +fn mono_styled<'a>( + ui: &Ui, + content: impl iced::widget::text::IntoFragment<'a>, + color: Color, +) -> iced::widget::Text<'a> { + let style = mono_style(ui); + + iced::widget::text(content) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(ui.font_of(style)) + .color(color) +} diff --git a/crates/app-iced/src/pages/logs/model.rs b/crates/app-iced/src/pages/logs/model.rs new file mode 100644 index 0000000..94b99c5 --- /dev/null +++ b/crates/app-iced/src/pages/logs/model.rs @@ -0,0 +1,218 @@ +//! 日志页的视图模型:**纯展示字段**(沿用 `pages::model` 的分层——不含 iced 类型,也不认识 presenter)。 +//! +//! ## 为什么单独一层 +//! +//! 渲染函数只吃 [`Logs`],于是同一棵树能被三份数据喂: +//! +//! | 数据源 | 谁造 | 用途 | +//! |---|---|---| +//! | presenter 快照(`AppState`) | `app/view.rs::logs_model` | 产品运行时 | +//! | [`super::demo`] 的内嵌崩溃日志片段 | `pages::demo_model` | `pages_demo --screen=logs` 出图 | +//! | [`Logs::default`] | 空态 | 真的没有输出(未接线时也是这个兜底) | +//! +//! 页面**不读盘**:日志文本只有两条来路——presenter 快照里的 `LogLine`,或 `demo` 里 +//! `include_str!` 内嵌的样例。日志文件的路径从头到尾没进过页面。 +//! +//! ## 缺口(M95 登记,已报 finding) +//! +//! presenter 的 `dto::Diagnosis` 只有 `rule` / `title` / `hint` / `confidence` / `fix`: +//! +//! 1. **core 的 `Severity`(info / warning / critical)在半路丢了**——`core::diagnose::rules` +//! 里每条规则都带它,DTO 没有。页面要「按严重度分组」就只能用 +//! [`Severity::of_confidence`] 按置信度**折算**(阈值写死在函数里,是债,不是规格)。 +//! 2. `title` / `hint` 只有中文单语,而且规则表里其实已经有结构化出口 +//! (`rules.d/*.yaml` 的 `cause.key: diagnose.cause.…`)。DTO 没带 `key`,页面就把规则原文 +//! 当**数据**画出来,页面的界面文案(标题 / 按钮 / 分组名 / 空态)仍然全部走 i18n key。 + +/// 一行日志的等级(presenter `LogLevel` 的展示投影)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + /// 调试噪音。 + Debug, + /// 正常流水。 + Info, + /// 可疑。 + Warn, + /// 错误行(崩溃现场)。 + Error, +} + +impl Level { + /// 稳定 slug(摘要行与测试用;不是文案)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Debug => "debug", + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +/// 诊断结论的严重度(`core::diagnose::rules::Severity` 的展示投影)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + /// 提示:能跑但不最佳。 + Info, + /// 警告:可疑但结论不确定。 + Warning, + /// 严重:基本可以确定崩溃原因。 + Critical, +} + +/// 分区顺序(**主因在上**:崩溃日志换 issue 时第一段就是最该看的)。 +pub const GROUPS: [Severity; 3] = [Severity::Critical, Severity::Warning, Severity::Info]; + +impl Severity { + /// 稳定 slug(摘要行用;不是文案——分组标题走 i18n key)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warning => "warning", + Self::Critical => "critical", + } + } + + /// 按置信度折算严重度——**过渡口径**,见模块文档的缺口 #1。 + /// + /// 阈值:`≥80` 严重 / `≥50` 警告 / 其余提示。DTO 补上 `severity` 之后这个函数就该删掉。 + #[must_use] + pub const fn of_confidence(confidence: u8) -> Self { + if confidence >= 80 { + Self::Critical + } else if confidence >= 50 { + Self::Warning + } else { + Self::Info + } + } +} + +/// 一行日志。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Line { + /// 原始文本(**不截断**:折行是渲染的事,数据要留全,否则用户永远拿不回堆栈)。 + pub text: String, + /// 等级。 + pub level: Level, +} + +/// 一条诊断结论。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + /// 命中的规则 id(`graphics.opengl_not_supported`):技术标识,原样显示。 + pub rule: String, + /// 严重度(DTO 没有,见 [`Severity::of_confidence`])。 + pub severity: Severity, + /// 结论标题(规则表的 `cause.summary`,**数据不是文案**)。 + pub title: String, + /// 建议说明(规则表的 `cause.detail`)。 + pub hint: String, + /// 0~100 置信度。 + pub confidence: u8, + /// 建议动作(规则表的 `fix_action`,技术标识 / 设置项名)。 + pub action: Option, +} + +/// 一次运行的结局(`presenter::dto::LogOutcome` 的展示投影;文案走 `LaunchOutcome*` key)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// 正常退出。 + Ok, + /// 崩溃。 + Crashed, + /// exit 0 但游戏没起来。 + SilentFail, +} + +impl Outcome { + /// 稳定 slug(摘要行用)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Crashed => "crashed", + Self::SilentFail => "silent-fail", + } + } +} + +/// 日志页要画的东西。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Logs { + /// 会话所属实例名(`None` = 不知道,页头副标题退成通用文案)。 + pub instance: Option, + /// 会话时间(展示用短串 `10-29 18:40`,presenter 已经格式化好)。 + pub when: Option, + /// 结局。 + pub outcome: Option, + /// 日志行(顺序 = 文件顺序)。 + pub lines: Vec, + /// 诊断结论(顺序 = presenter 给的顺序,页面不重排)。 + pub findings: Vec, +} + +impl Logs { + /// 没有任何可显示的东西(空态判据:行与诊断都空)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.lines.is_empty() && self.findings.is_empty() + } + + /// 某一严重度下的结论(顺序不变;空组返回空 `Vec`)。 + #[must_use] + pub fn group(&self, severity: Severity) -> Vec<&Finding> { + self.findings + .iter() + .filter(|finding| finding.severity == severity) + .collect() + } + + /// **可复制的一行摘要**(崩溃日志换 issue 时直接粘)。 + /// + /// 格式是刻意的:ASCII 骨架 + 稳定字段序 + ` | ` 分隔,人读与机器读都行; + /// 不含任何界面文案(所以不随语言变),只带数据与控制面的技术标识。 + /// + /// ```text + /// ULL-LOGS v1 | instance=开发测试 | when=10-29 18:40 | outcome=crashed | lines=6 \ + /// | findings=graphics.opengl_not_supported:critical(85) + /// ``` + #[must_use] + pub fn summary(&self) -> String { + let mut parts = vec![SUMMARY_HEADER.to_owned()]; + + if let Some(instance) = &self.instance { + parts.push(format!("instance={instance}")); + } + if let Some(when) = &self.when { + parts.push(format!("when={when}")); + } + if let Some(outcome) = self.outcome { + parts.push(format!("outcome={}", outcome.slug())); + } + parts.push(format!("lines={}", self.lines.len())); + + if !self.findings.is_empty() { + let hits: Vec = self + .findings + .iter() + .map(|finding| { + format!( + "{}:{}({})", + finding.rule, + finding.severity.slug(), + finding.confidence + ) + }) + .collect(); + parts.push(format!("findings={}", hits.join(","))); + } + + parts.join(" | ") + } +} + +/// 摘要行的版本头(改了格式就换代,别让旧 issue 里的摘要含义漂移)。 +pub const SUMMARY_HEADER: &str = "ULL-LOGS v1"; diff --git a/crates/app-iced/src/pages/logs/panel.rs b/crates/app-iced/src/pages/logs/panel.rs new file mode 100644 index 0000000..4b6ec04 --- /dev/null +++ b/crates/app-iced/src/pages/logs/panel.rs @@ -0,0 +1,63 @@ +//! 两块的共用外壳:**定高的卡片 + 内部独立滚动**。 +//! +//! ## 为什么必须定高(M95 r1 的实拍 bug) +//! +//! 第一版把诊断卡按内容自然高度画、日志卡吃剩余高度。三条结论在 424×540 的正文里就要 +//! ~500px,结果:诊断卡被裁掉尾部(一行摘要整块消失)、日志卡拿到 **0** 高度(整块不见)。 +//! 截图见报告里的「r1 崩了」。 +//! +//! 所以两块都**由调用方给定高**(`FillPortion`),各自内部滚动: +//! +//! | 块 | 高度 | 为什么 | +//! |---|---|---| +//! | 诊断 | `FillPortion(3)` | 崩溃时先读结论;结论通常比日志短,给 3 份 | +//! | 日志 | `FillPortion(2)` | 日志是长流水,滚就是了 | +//! +//! 两者都是**兄弟**滚动区(不是嵌套):滚轮归属清楚,不存在「滚动条套滚动条」。 + +use iced::widget::{container, scrollable}; +use iced::{Background, Border, Element, Length}; + +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` 同源)。 +pub fn card<'a>( + ui: &'a Ui, + height: Length, + content: impl Into>, +) -> Element<'a, Message> { + 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() + }, + ..container::Style::default() + }) + .into() +} + +/// 卡片内的竖向滚动区(滚条样式与实例页列表列同源)。 +pub fn scroll<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { + scrollable(content) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new() + .width(frame::SCROLLBAR_WIDTH) + .scroller_width(frame::SCROLLBAR_WIDTH) + .margin(frame::SCROLLBAR_MARGIN), + )) + .height(Length::Fill) + .width(Length::Fill) + .style(frame::scroll_style(ui)) + .into() +} diff --git a/crates/app-iced/src/pages/logs/sample_crash.log b/crates/app-iced/src/pages/logs/sample_crash.log new file mode 100644 index 0000000..bc2671c --- /dev/null +++ b/crates/app-iced/src/pages/logs/sample_crash.log @@ -0,0 +1,23 @@ +# M95 演示数据:一段真实崩溃日志的片段(页面用 include_str! 编译期内嵌,运行时不读盘)。 +# +# 每行格式:`<等级> <原文>`,等级 = D(debug) / I(info) / W(warn) / E(error);`#` 开头是注释。 +# +# 这份样例是故意挑的(tower 要求的两条真风险都压在这里): +# 1. 中文 + 堆栈混排 —— mono 族(Windows 上是 Consolas / Cascadia Mono)没有 CJK 字形, +# 中文要靠 cosmic-text 逐字回退到系统 CJK 族,字宽与 Latin 1ch 不等;页面选的是折行, +# 所以「列对齐」不参与版面,回退只影响同一行内的视觉密度; +# 2. 超长行 —— 第 9/10/11 行都在 200 字符以上(真实 Java 堆栈的长度),正文列上限 424px, +# 折行策略见 `logs.rs` 模块文档。 +I [19:40:12] [main/INFO] 正在启动 1.20.4,实例「开发测试」,加载器 NeoForge 21.1.72 +I [19:40:12] [main/INFO] Java 21.0.5 (Microsoft build 21.0.5+11-LTS), 内存 4096 MiB, JVM 参数 -Xmx4G -XX:+UseG1GC -Dfile.encoding=UTF-8 -Djava.library.path=C:\Users\Player\AppData\Roaming\.unlucky\instances\dev-test\natives +W [19:40:13] [Render thread/WARN] GLFW 报告 OpenGL 2.1,低于所需的 3.2;显卡驱动可能是 Microsoft Basic Display Adapter +E [19:40:14] [Render thread/ERROR] 显卡驱动不支持 OpenGL 3.2,初始化窗口失败 +E java.lang.IllegalStateException: GLFW error before init: [0x10008] Failed to create window: WGL: The driver does not appear to support OpenGL +E at com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64) +E at net.minecraft.client.MainWindow.(MainWindow.java:261) +E at net.minecraft.client.Minecraft.(Minecraft.java:472) +E at net.neoforged.fml.loading.targets.CommonLaunchHandler.clientService(CommonLaunchHandler.java:98) +E Caused by: java.lang.UnsatisfiedLinkError: C:\Users\Player\AppData\Roaming\.unlucky\instances\dev-test\natives\lwjgl_opengl.dll: 找不到指定的模块。 (error code 126, 依赖的 d3dcompiler_47.dll 或 vcruntime140.dll 缺失时也会报同一条) +W [19:40:15] [main/WARN] 正在生成本地崩溃报告 crash-2026-11-02_19.40.15-client.txt,请勿关闭窗口 +I [19:40:16] [main/INFO] 已加载 148 个模组,其中 3 个标记为客户端专用:iris-1.7.4, sodium-0.5.11, indium-1.0.32 +E [19:40:16] [main/ERROR] 启动失败:exit code 1(进程存活 4 秒) diff --git a/crates/app-iced/src/pages/logs/tests.rs b/crates/app-iced/src/pages/logs/tests.rs new file mode 100644 index 0000000..02cddcb --- /dev/null +++ b/crates/app-iced/src/pages/logs/tests.rs @@ -0,0 +1,209 @@ +//! 日志页的守门测试。每条断言下面写清「抓得住什么 / 抓不住什么」(这个仓的硬要求)。 +//! +//! 这里**没有截图断言**:像素级验收靠 `pages_demo --screen=logs` 的四张图(浅/深 × 空态/崩溃), +//! 单测只钉住「图上看不出来的那部分」——数值令牌、分组顺序、摘要格式、长行不被截断。 + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::{FontFamily, Platform}; +use unlucky_i18n::catalog::{Catalog, Key}; +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::{demo, lines, *}; + +fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Dark, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) +} + +#[test] +fn page_is_single_column_and_never_wider_than_the_measure_cap() { + for width in [700.0, 1180.0, 1600.0] { + let metrics = frame_metrics(PaneKit::Single, width); + // 抓得住:谁把日志页改成两列(`secondary` 出现就会把正文列挤窄); + // 谁绕开 `layout` 自己算列宽(`column` 会超 424)。 + // 抓不住:列内文本是否真在这 424 里折行(那要量字形,单测量不到)。 + assert!(metrics.secondary.is_none(), "w={width}"); + assert!( + metrics.column <= measure_cap_px() + f32::EPSILON, + "w={width} 正文列 {} > 上限 {}", + metrics.column, + measure_cap_px() + ); + } +} + +#[test] +fn mono_lines_use_the_mono_stack_with_the_body_small_metrics() { + let ui = ui(); + let style = lines::mono_style(&ui); + + // 抓得住:把等宽行换成正文栈(`family` 会变 `Text`)——那是「等宽」失真的唯一入口; + // 顺手改字号/行高(数值表要写 12/16,漂了就红)。 + // 抓不住:CJK 字形到底有没有被回退到别的族(要查字体 cmap,本 crate 不做)。 + assert_eq!(style.family, FontFamily::Mono); + assert_eq!(style.size_px, 12.0); + assert_eq!(style.line_height_px, 16.0); + assert_eq!( + ui.font_of(style).family, + iced::font::Family::Name("Consolas") + ); +} + +#[test] +fn findings_are_grouped_critical_first_and_empty_groups_do_not_exist() { + let findings = demo::crash().findings; + let logs = Logs { + findings, + ..Logs::default() + }; + let counts: Vec = GROUPS + .iter() + .map(|severity| logs.group(*severity).len()) + .collect(); + + // 抓得住:分组顺序被改成 info 在前(主因会沉底);空组也被画出一个标题(截图上出现 0 条的分区)。 + // 抓不住:组内顺序(页面刻意不重排 presenter 给的顺序)。 + assert_eq!(GROUPS[0], Severity::Critical); + assert_eq!( + counts.iter().sum::(), + logs.findings.len(), + "每条结论必须落进恰好一个分组" + ); + assert!(counts[0] >= 1, "样例至少有主因"); + assert!(logs.group(Severity::Info).is_empty()); +} + +#[test] +fn summary_is_a_single_deterministic_line_carrying_rule_and_severity() { + let logs = demo::crash(); + let summary = logs.summary(); + + // 抓得住:摘要里丢了规则 id / 严重度 / 置信度(换 issue 时最关键的三个字段); + // 有人把换行塞进摘要("一行"就不成立了)。 + // 抓不住:粘贴到 issue 后的可读性(那是人判断的),也不守文案语言(摘要刻意不含界面文案)。 + assert!(!summary.contains('\n'), "{summary}"); + assert!(summary.starts_with(SUMMARY_HEADER), "{summary}"); + assert!( + summary.contains("integrity.unsatisfied_link:critical(85)"), + "{summary}" + ); + assert!(summary.contains("lines="), "{summary}"); + assert_eq!(summary, logs.summary(), "同一份模型两次摘要必须一致"); + + let empty = Logs::default(); + assert_eq!(empty.summary(), "ULL-LOGS v1 | lines=0"); +} + +#[test] +fn severity_is_derived_from_confidence_with_the_documented_thresholds() { + // 抓得住:阈值边界写成 `>` / `>=` 那种 off-by-one(80 与 50 是文档里写死的两个点)。 + // 抓不住:折算本身对不对——DTO 补上 `severity` 之前这只是**过渡口径**(已报 finding)。 + assert_eq!(Severity::of_confidence(100), Severity::Critical); + assert_eq!(Severity::of_confidence(80), Severity::Critical); + assert_eq!(Severity::of_confidence(79), Severity::Warning); + assert_eq!(Severity::of_confidence(50), Severity::Warning); + assert_eq!(Severity::of_confidence(49), Severity::Info); + assert_eq!(Severity::of_confidence(0), Severity::Info); +} + +#[test] +fn the_longest_stack_line_reaches_the_view_model_untruncated() { + let logs = demo::crash(); + let longest = logs + .lines + .iter() + .map(|line| line.text.chars().count()) + .max() + .unwrap_or(0); + + // 抓得住:为了排版把行截断 / 加省略号(用户就再也拿不回原始堆栈)。 + // 抓不住:折行后的视觉行数(渲染期的事)。 + assert!( + longest >= 200, + "样例里应有一条 ≥200 字符的堆栈行,实际 {longest}" + ); + assert!(!logs.lines.iter().any(|line| line.text.ends_with('…'))); +} + +#[test] +fn empty_state_is_driven_by_both_lists_being_empty() { + let mut logs = Logs::default(); + assert!(logs.is_empty()); + + logs.findings = demo::crash().findings; + // 抓得住:把「没有日志行」当成空态(有诊断时那样会藏掉最该看的东西)。 + assert!(!logs.is_empty(), "只有诊断也不算空态"); + + let live = demo::State::Live.build(); + assert!(!live.is_empty()); + assert!(live.findings.is_empty(), "live 状态没有诊断"); +} + +#[test] +fn demo_state_comes_from_the_fixture_variable() { + // 抓得住:环境变量取值拼错(截图会静默退成 crash,出图的人以为拍到了空态)。 + assert_eq!(demo::State::of_env_value(Some("empty")), demo::State::Empty); + assert_eq!(demo::State::of_env_value(Some("live")), demo::State::Live); + assert_eq!(demo::State::of_env_value(Some("crash")), demo::State::Crash); + assert_eq!(demo::State::of_env_value(None), demo::State::Crash); +} + +#[test] +fn every_key_the_page_uses_is_translated_in_both_languages() { + let keys = [ + Key::NavLogs, + Key::LogsSubtitleIdle, + Key::LogsSubtitleActive, + Key::LogsOpenFolder, + Key::LogsClear, + Key::LogsOutputTitle, + Key::LogsDiagnosisTitle, + Key::LogsSeverityCritical, + Key::LogsSeverityWarning, + Key::LogsSeverityInfo, + Key::LogsConfidence, + Key::LogsSuggestedFix, + Key::LogsSummaryLabel, + Key::LogsCopySummary, + Key::LogsEmptyTitle, + Key::LogsEmptyBody, + Key::LogsEmptyAction, + ]; + + // 抓得住:页面用到的 key 被改名/删掉,或只进了其中一套目录(缺译会返回 `⟪missing⟫`)。 + // 抓不住:译文的语气与长度(人读的事)。 + for lang in [Lang::En, Lang::ZhCn] { + let catalog = Catalog::of(lang); + for key in keys { + assert!( + catalog.get(key).is_some(), + "{} 缺 {}", + lang.setting_value(), + key.name() + ); + } + } + + // 占位符名写错时 `render` 会把 `{percent}` 原样留在文案里——这条钉住它。 + let rendered = Catalog::of(Lang::En).render(Key::LogsConfidence, &[("percent", "85")]); + assert_eq!(rendered, "Confidence 85%"); +} + +#[test] +fn header_stays_two_lines_because_of_the_status_subtitle() { + let ui = ui(); + // 抓得住:有人把副标题去掉,页头从 84 掉回 56(版式台账上会露出来)。 + assert_eq!(ui.metrics().header_height(true), 84.0); + assert!(ui.metrics().header_height(true) > ui.metrics().header_height(false)); +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index f916f5f..5ef1e45 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -108,6 +108,7 @@ mod tests; pub use download::{Category, Download}; pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; +pub use logs::{Finding, Level, Line, Logs, Outcome, Severity}; pub use model::{Detail, Instances, Meta, Model, Row}; use iced::widget::{column, container, row, space}; @@ -150,6 +151,10 @@ pub enum Message { CancelInstall, /// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。 SetCategory(Category), + /// 把一段现成的文本塞进剪贴板(**M95 日志页**:诊断卡片的一行摘要)。 + /// + /// 文本由页面造(它是唯一同时看得见规则 id / 分类 / 置信度的地方),app 层只负责落剪贴板。 + CopySummary(String), } /// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。 @@ -327,7 +332,7 @@ fn render<'a>( Screen::Instances => instances::view(ui, metrics, model), Screen::Download => download::view(ui, metrics, &model.download, unlucky_i18n::Lang::En), Screen::Settings => settings::view(ui, metrics, appearance), - Screen::Logs => logs::view(ui, metrics), + Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang), } } @@ -365,6 +370,9 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model { if screen == Screen::Download { model.download = download::demo(unlucky_i18n::Lang::En.catalog()); } + if screen == Screen::Logs { + model.logs = logs::demo(); + } model } diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index a0f7c7a..30be09e 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -14,10 +14,12 @@ //! //! 文本都是 `String`/`&'static str`,没有框架类型——这一层可以纯单测(见 `pages/tests.rs`)。 +use unlucky_i18n::Lang; use unlucky_presenter::router::NavSection; use crate::app::components::interaction::Interaction; +use super::logs::Logs; use super::{Download, Screen}; /// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。 @@ -85,6 +87,13 @@ pub struct Model { pub instances: Instances, /// 下载页的数据(M94:版本列表 / 选中 / 安装进度,全部来自 presenter 快照) pub download: Download, + /// 日志页的数据(M95:presenter 快照 → 日志行 + 诊断结论,见 [`super::logs`])。 + pub logs: Logs, + /// 界面语言(M95):页面文案走 i18n key,语言由 app 层决定后灌进来。 + /// + /// 这里只是**留口**:`theme.rs` 还没有「当前语言」的入口(那条线归 M93), + /// 产品路径先给 `Lang::En`;将来 `Ui` 拿到语言后,这个字段改由它填。 + pub lang: Lang, /// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。 /// /// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的; @@ -101,6 +110,8 @@ impl Model { active, instances: Instances::default(), download: Download::loading(), + logs: Logs::default(), + lang: Lang::En, forced: None, } } diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index 25b4e66..7cc3276 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -167,6 +167,28 @@ catalog! { ), DownloadOpenLogs => ("Open logs", "打开日志"), DownloadDismiss => ("Dismiss", "关闭"), + // --- logs page (M95) --- + // 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里: + // 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。 + LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"), + LogsSubtitleActive => ("Launcher and game output", "启动器与游戏输出"), + LogsOpenFolder => ("Open folder", "打开目录"), + LogsClear => ("Clear", "清空"), + LogsOutputTitle => ("OUTPUT", "输出"), + LogsDiagnosisTitle => ("DIAGNOSIS", "诊断"), + LogsSeverityCritical => ("CRITICAL", "严重"), + LogsSeverityWarning => ("WARNING", "警告"), + LogsSeverityInfo => ("NOTICE", "提示"), + LogsConfidence => ("Confidence {percent}%", "置信度 {percent}%"), + LogsSuggestedFix => ("Suggested fix", "建议动作"), + LogsSummaryLabel => ("One-line summary", "一行摘要"), + LogsCopySummary => ("Copy summary", "复制摘要"), + LogsEmptyTitle => ("No output yet", "还没有输出"), + LogsEmptyBody => ( + "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", + "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。" + ), + LogsEmptyAction => ("Choose an instance", "选择实例"), } /// 一套语言的目录(不可变;`Copy`,随手传值)。 diff --git a/crates/i18n/src/catalog/tests.rs b/crates/i18n/src/catalog/tests.rs index e42488a..630b0e4 100644 --- a/crates/i18n/src/catalog/tests.rs +++ b/crates/i18n/src/catalog/tests.rs @@ -1,7 +1,9 @@ -//! `Catalog` 的守门测试(M94 从 `catalog.rs` 原样搬出来:那边已经贴着 400 行硬上限, +//! `Catalog` 的守门测试(从 `catalog.rs` 原样搬出来:那边已经贴着 400 行硬上限, //! 而页面每加一批 key 都要在这个模块里占一行)。 //! //! **零改动搬运**:用例、注释、断言一字未动;`use super::*` 指向的仍是 `crate::catalog`。 +//! M94(下载页)与 M95(日志页)几乎同时撞到这堵墙、各自搬了一次:两条 branch 的**用例部分 +//! 逐字相同**(解冲突时按 M94 那份合并,测试数量 7 条不变)。 use super::*;