//! 实例详情页的 **SAVES 区**(M235 · STOREMVP):存档 / 截图的**只读**列表 + 打开文件夹。 //! //! # 位置与形状 //! //! 挂在详情列里、MODS 区之后、启动参数卡**之前**(`pages/instances/detail.rs` 的 `detail_column`)。 //! 与 MODS 区同一套卡片口径:[`frame::card`] + 一个 `LabelMedium` 小标 + 内容。 //! //! **为什么在启动参数之前**:与 MODS 区同一个理由(M224 出图实测)——详情列整列是一个滚动区, //! 而启动参数卡本身就有一屏高;排在它后面等于这整块落在折叠线以下、根本看不见。顺序因此是: //! 身份(页头 + 元信息)→ 内容(MODS → SAVES)→ 选项(启动参数)→ 动作。 //! //! # 为什么不自己再套一层滚动区 //! //! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条 //! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。 //! //! # 只读(U3 的边界,页面固定画出来) //! //! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」:没有删除、没有重命名、没有导入、**没有缩略图** //! (缩略图要引图片解码依赖,`AGENTS.md` §2 不允许)。所以卡片里第一行永远画 //! [`Texts::saves_read_only`]——用户点开就看见"这一版只读",不会以为漏了删除按钮。 //! //! # 失败与重试 //! //! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败不画重试**: //! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance //! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。 use iced::widget::{column, container, row}; use iced::{Alignment, Element, Length}; use unlucky_design::color::Role; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::Catalog; use unlucky_presenter::dto::{human_bytes, SavesFailureKind, SavesFailureView}; use unlucky_presenter::state::SavesState; use crate::app::components::buttons::{self, Button}; use crate::app::components::interaction::Interaction; use crate::theme::Ui; use super::super::frame; use super::super::Message; use super::forced_state; use super::state::Message as PageMessage; use super::Texts; /// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct Row { /// 世界目录名或截图文件名(磁盘原样)。 pub name: String, /// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。 pub detail: String, } /// SAVES 区的展示模型(`Model::saves`)。 #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Saves { /// 这份数据属于哪个实例(`None` = 不在详情页 / 还没请求过)。 pub bound: Option, /// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。 pub loading: bool, /// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。 pub error: Option, /// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。 pub retryable: bool, /// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。 pub has_view: bool, /// 两个目录都不存在(新实例的常态:还没进过游戏)。 pub missing: bool, /// 世界行。 pub worlds: Vec, /// 截图行(新的在前,顺序由 core 给)。 pub screenshots: Vec, /// 计数行(`3 个世界 · 12 张截图`):**在这一层算好**,页面只借 `&str`。 /// /// 为什么不留到页面里 `format!`:iced 的 `Element` 只借这一帧的数据,页面函数返回的是 /// **借用局部变量的树**,局部 `String` 一出栈就悬空(`E0515`)。与 [`super::mods::Mods`] /// 的 `count` 同一条做法。 pub count: String, /// 跳过条目数(符号链接 / 非图片 / 读不出)。 pub skipped: usize, /// 跳过提示(`跳过 3 项`)。 pub skipped_note: String, } impl Saves { /// 有没有拿到一份快照(成功或失败都算;`loading` 中不算)。 #[must_use] pub fn loaded(&self) -> bool { self.bound.is_some() && !self.loading } /// 两个目录都在、但里面什么都没有(**必须先有一份快照**:纯失败态不是"这里空的")。 #[must_use] pub fn nothing_yet(&self) -> bool { self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty() } } /// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。 /// /// 两条口径: /// /// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表, /// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态; /// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。 #[must_use] pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves { let texts = Texts::of(catalog); let mut out = Saves { bound: state.bound.clone(), loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(), ..Saves::default() }; if let Some(view) = &state.view { out.has_view = true; out.missing = !view.exists && !view.screenshots_exists; // 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍—— // M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。 out.worlds = view .worlds .iter() .map(|world| Row { name: world.dir.clone(), detail: world_detail(world.icon, world.modified_ms, texts), }) .collect(); out.screenshots = view .screenshots .iter() .map(|shot| Row { name: shot.file.clone(), detail: shot_detail(shot.bytes, shot.modified_ms, texts), }) .collect(); out.count = texts.saves_count(view.world_count(), view.screenshot_count()); out.skipped = view.skipped_count(); out.skipped_note = texts.saves_skipped(out.skipped); } if let Some(failure) = &state.failed { out.loading = false; out.error = Some(failure_line(failure, texts)); out.retryable = failure.kind != SavesFailureKind::OpenFailed; } out } /// 失败的一句话(**唯一产地**:页面内红字用它;本版没有 Toast——只读列表的失败就地说明更近)。 #[must_use] pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String { let reason = texts.saves_reason(failure.kind); let sentence = match failure.kind { SavesFailureKind::OpenFailed => texts.saves_open_failed(reason), SavesFailureKind::DirUnusable | SavesFailureKind::InstanceUnavailable => { texts.saves_failed(reason) } }; if failure.detail.is_empty() { return sentence; } format!("{sentence}({})", failure.detail) } /// 世界那一行的副文案:`2024-05-01 10:00 UTC`,有图标时再挂一个「有图标」。 fn world_detail(icon: bool, modified_ms: Option, texts: Texts) -> String { let stamp = stamp(modified_ms, texts); if icon { format!("{stamp} · {}", texts.saves_world_icon()) } else { stamp } } /// 截图那一行的副文案:`1.2 MiB · 2024-05-01 10:00 UTC`。 fn shot_detail(bytes: u64, modified_ms: Option, texts: Texts) -> String { format!("{} · {}", human_bytes(bytes), stamp(modified_ms, texts)) } /// mtime(UTC 毫秒)→ `2024-05-01 10:00 UTC`;取不到就说"时间未知"(不拿 0 或当前时间糊)。 /// /// 换算走 `core::launch::time` 的 RFC3339 格式化,再按 `app/view/instances.rs::last_played` /// 同一刀切开——**UTC 是 core 的口径,不做本地时区换算**(那属时间格式 mission)。 fn stamp(modified_ms: Option, texts: Texts) -> String { let Some(text) = modified_ms.and_then(stamp_utc) else { return texts.saves_time_unknown().to_string(); }; text } /// 毫秒 → `YYYY-MM-DD HH:MM UTC`(`None` = 早于 1970 / 溢出,不猜)。 fn stamp_utc(ms: u64) -> Option { let at = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms))?; let text = unlucky_core::launch::time::format_rfc3339_millis(at); // `2024-05-01T10:00:00.000Z` → `2024-05-01 10:00 UTC`(切两段,不解析、不重排)。 // 模板与 `app/view/instances.rs::last_played` **一字不差**:同一句话只能有一份写法, // 页面源码的字面量白名单里也只有这一条(`instances/tests/i18n.rs` 的 `ALLOWED_LITERALS`)。 let date = text.get(0..10)?; let time = text.get(11..16)?; Some(format!("{date} {time} UTC")) } /// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。 pub(super) fn section<'a>( ui: &'a Ui, saves: &'a Saves, forced: Option, texts: Texts, ) -> Element<'a, Message> { let mut body = column![head(ui, saves, forced, texts)] .spacing(ui.space(Space::X3)) .width(Length::Fill); // 只读边界固定画在第一行(见模块文档)。 body = body.push(note(ui, texts.saves_read_only())); if saves.loading { body = body.push(note(ui, texts.saves_loading())); return frame::card(ui, body); } if let Some(error) = saves.error.as_deref() { body = body.push(frame::text_role( ui, TypeRole::BodySmall, false, error, ui.color(Role::Error), )); if saves.retryable { body = body.push(buttons::text( ui, forced_state( Button::new( texts.saves_retry(), Message::Instances(PageMessage::ReloadSaves), ), forced, ), )); } // 旧列表还在时照旧画出来(失败不清列表),用户能看见上一次读到的东西; // 一份快照都没有时到此为止——**不许**在"读不了"下面再补一句"这里还没有世界"。 if !saves.has_view { return frame::card(ui, body); } } if saves.missing { body = body.push(note(ui, texts.saves_missing_dir())); return frame::card(ui, body); } if saves.nothing_yet() { body = body.push(note(ui, texts.saves_empty())); return frame::card(ui, body); } // 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。 body = body.push(sub_head(ui, texts.saves_worlds())); if saves.worlds.is_empty() { body = body.push(note(ui, texts.saves_no_worlds())); } else { for item in &saves.worlds { body = body.push(entry_row(ui, item)); } } body = body.push(sub_head(ui, texts.saves_screenshots())); if saves.screenshots.is_empty() { body = body.push(note(ui, texts.saves_no_screenshots())); } else { for item in &saves.screenshots { body = body.push(entry_row(ui, item)); } } if saves.skipped > 0 { body = body.push(note(ui, &saves.skipped_note)); } frame::card(ui, body) } /// 小标行:`SAVES` + 计数 + 右侧「打开存档文件夹」。 fn head<'a>( ui: &'a Ui, saves: &'a Saves, forced: Option, texts: Texts, ) -> Element<'a, Message> { // 左半是「存档 + 计数」,右半是「打开存档文件夹」:左半用一个吃满宽度的容器顶开, // 按钮才会落在卡片右边缘(与 `head` 之外那些卡片的动作行同一套摆法)。 let mut left = row![frame::text_role( ui, TypeRole::LabelMedium, false, texts.saves_title(), ui.color(Role::OnSurfaceVariant), )] .spacing(ui.space(Space::X2)) .align_y(Alignment::Center); // 计数只在"真有一份可用快照"时画:读盘中 / 读失败 / 空目录画 0·0 会像"这里什么都没有"。 let show_count = saves.has_view && saves.error.is_none() && !saves.missing && !(saves.worlds.is_empty() && saves.screenshots.is_empty()); if show_count { left = left.push(frame::text_role( ui, TypeRole::BodySmall, false, saves.count.as_str(), ui.color(Role::OnSurfaceVariant), )); } // 「打开文件夹」始终可点:目录还没有时 host 会退到运行目录,失败也有就地说明 // (见 `host::saves::open_target`)。 let open = buttons::text( ui, forced_state( Button::new( texts.saves_open_folder(), Message::Instances(PageMessage::OpenSavesFolder), ), forced, ), ); row![ container(left) .width(Length::Fill) .align_y(Alignment::Center), open ] .spacing(ui.space(Space::X2)) .align_y(Alignment::Center) .width(Length::Fill) .into() } fn sub_head<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> { frame::text_role( ui, TypeRole::LabelSmall, false, text, ui.color(Role::OnSurfaceVariant), ) } /// 一行:名字 + 副文案(只读,所以没有开关、没有按钮)。 fn entry_row<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> { column![ frame::text_role( ui, TypeRole::BodyMedium, false, item.name.as_str(), ui.color(Role::OnSurface), ), frame::text_role( ui, TypeRole::BodySmall, false, item.detail.as_str(), ui.color(Role::OnSurfaceVariant), ), ] .spacing(ui.space(Space::X1)) .width(Length::Fill) .into() } fn note<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> { frame::text_role( ui, TypeRole::BodySmall, false, text, ui.color(Role::OnSurfaceVariant), ) }