Files
Unlucky-Launcher/crates/app-iced/src/pages/model.rs
T
AskaEth f217d83680 feat(saves): 实例存档 / 截图的只读列表与打开文件夹(M235 STOREMVP)
用户诉求是 PCL 差距四项里的「存档截图 / 导入」,M230 审计确认三层全缺。塔裁决 U3
把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙(缩略图要引图片解码依赖,而
AGENTS §2 只允许 palette-extract 引 image 类依赖;截图/导入产物形态也没定型)。

core
- `game::saves`(新):只读列 `<run_dir>/saves`(世界目录)与 `<run_dir>/screenshots`
  (图片文件)。只调 symlink_metadata / read_dir / metadata,不建目录、不写文件、不读
  文件内容(`icon.png` 只判存在)。目录不存在 ⇒ 空列表不是错误;存在但不可用(符号
  链接 / 不是目录 / 读不了)⇒ 具名错误。条目级异常逐条登记原因,不静默丢。
- `platform::opener`(新):`open_dir` 是**目录**打开器的唯一收敛点(explorer / open /
  xdg-open),内部复用 `child::quiet`,cfg 分支只此一处。塔明确要求按 AGENTS §6 放
  platform 而不是业务模块,并要一条可执行判据防退化
  (`the_launcher_literals_live_here_only` 扫 `crates/core/src`:除本文件外不许出现
  `"explorer"` / `"xdg-open"` 字面量)。

presenter
- `dto::saves`:SavesCommand(List / OpenFolder)、SavesListView、SavesFailureView
  (结构 + OS 原文,不拼中文)、`SavesListView` 的计数助手。
- `host::saves`:走 `job::spawn`,UI 只收快照;运行目录仍是
  `isolation::derive_game_dir` 的同一个答案(与启动 / mod 列表同源,**不写盘**)。
  「打开文件夹」打开哪个目录由 host 定:`saves/` → `screenshots/` → 运行目录。
  失败分三类(InstanceUnavailable / DirUnusable / OpenFailed),可重试。
- `state::saves`:快照整体替换、迟到结果丢弃、失败保留绑定(重试才有意义)。
- 接线:CoreEvent::Saves / AppCommand::Saves / HostTask::Saves。

app-iced
- `pages::instances::saves`:详情页 SAVES 卡片(MODS 之后、启动参数之前;**不套第二层
  滚动条**,整列那一条是唯一入口)。世界行带 UTC 时间与「有图标」,截图行带大小与时间;
  第一行固定画「这一版只读」(U3 边界让用户看得见)。读盘失败画一条红字 + 「重试」;
  打开文件夹失败**不画**重试(那个动作的重试就是再点一次按钮)。
- 接线:Message::{ReloadSaves, OpenSavesFolder}、`sync_saves`(与 `sync_mods` 同一判据)、
  Model::saves、`saves_shot` 出图夹具(ULL_SAVES_FIXTURE=1)。

i18n
- `InstancesSaves*` 20 条中英成对,catalog 与金表在**同一条锚点**后追加、逐字节一致。

证据
- core 只读判据:`game::saves::tests` 在真盘建世界/截图,调用前后各拍一次
  (相对路径, 是否目录, 字节数, mtime)指纹并逐条比对;另有用例钉「目录缺失不建目录」。
- presenter:`host::saves::tests` 真盘端到端(custom 档位派生运行目录 → 快照)。
- GUI:`--software-renderer` 1180×760 浅/深各一张 + 1180×1280 两张「整块卡片可见」
  (1180×760 下 SAVES 卡片位于折叠线附近,只能看到卡头;第二对用 ULL_WINDOW_SIZE
  把窗口拉高,口径与 M98 的矩阵一致),落 `DevDocs/artifacts/M235/`。

没做的(U3 边界内,见 handoff):缩略图墙、导入、删除/重命名、`level.dat` 的
LevelName 解析(世界名暂时就是目录名)。
2026-11-11 06:00:27 +08:00

189 lines
8.6 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.
//! 视图模型:**presenter 快照 → 页面要画的东西**的唯一转换产物。
//!
//! ## 为什么页面不直接吃 presenter 的快照
//!
//! 页面层(`pages/*`)要能用**两套数据源**喂同一个渲染函数:
//!
//! | 数据源 | 谁转 | 用途 |
//! |---|---|---|
//! | presenter 的 `AppState` 快照 | `app/view.rs` 的自由函数 `model(&AppState, width, forced)` | 产品运行时的真页面 |
//! | `pages/instances/data.rs` 的演示条目 | [`crate::pages::demo`] | M84 骨架图的可复现基线、`pages_demo` example |
//!
//! 如果页面直接读 `InstanceSummary`,演示数据就得先伪造成 `InstanceSummary`(含 core 语义字段),
//! 基线图会跟着业务 DTO 的每次改动漂。所以中间加一层**纯展示字段**的模型:它只含「要画什么」。
//!
//! 文本都是 `String`/`&'static str`,没有框架类型——这一层可以纯单测(见 `pages/tests.rs`)。
use unlucky_i18n::Lang;
use unlucky_presenter::router::NavSection;
use crate::app::components::interaction::Interaction;
use super::content::Content;
use super::logs::Logs;
use super::update::Update;
use super::{Download, Screen};
/// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
/// 实例 id(点行 → `Route::Detail(id)`)
pub id: String,
/// 显示名
pub name: String,
/// 副文案(`1.20.4 · Fabric 0.16.5`)
pub supporting: String,
/// 运行状态(`None` = 空闲,不占 overline 槽位)
pub status: Option<String>,
}
/// 元信息卡:标签 + 值(`VERSION` / `Fabric 0.16.5`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Meta {
/// 卡片上排的标签(`LabelMedium`)
pub label: String,
/// 卡片下排的值(`BodyMedium`)
pub value: String,
}
/// 详情列要画的东西。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detail {
/// 实例 id(启动/停止命令要用它)
pub id: String,
/// 标题(`TitleLarge`)
pub name: String,
/// 标题下的一行(`BodySmall`)
pub subtitle: String,
/// 元信息网格(顺序 = 展示顺序;列数仍由 `layout::meta_columns` 决定)
pub meta: Vec<Meta>,
/// 正文档(演示数据有;真实快照没有对应字段时为 `None`)
pub note: Option<String>,
/// 运行状态行(`None` = 空闲,不占版面)
pub status: Option<String>,
/// 「Play」可用
pub can_launch: bool,
/// 「Stop」可用
pub can_stop: bool,
/// 启动参数区的只读事实(M157)
pub launch: LaunchFacts,
}
/// 启动参数区(M157)的**只读事实**:门槛、生效值、三态词表。
///
/// 可编辑的值不在这里——它们在页面状态 [`super::instances::LaunchForm`](由 `App` 持有)。
/// 这样「每来一条事件就重建视图模型」不会把用户正在输入的内容冲掉。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct LaunchFacts {
/// 这个版本能不能下发 `--graphicsBackend`(core 的版本门槛判定)。
pub allowed: bool,
/// 判定依据的游戏版本(提示文案里给出)。
pub mc_version: String,
/// 门槛的最低版本(`26.2-snapshot-2`;提示与测试共用一份来源)。
pub min_version: String,
/// 生效最大堆(三级回落之后)。
pub memory_effective_mb: u32,
/// 生效 Java(空 = 自动挑)。
pub java_effective: String,
/// 生效图形后端。
pub backend_effective: String,
/// 三态词表(`default` / `opengl` / `vulkan`)。
pub options: Vec<String>,
}
/// 实例页要画的东西。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Instances {
/// 列表列(顺序稳定 = 实例列表顺序)
pub rows: Vec<Row>,
/// 当前选中的实例详情(`None` = 没选中 → 空态)
pub detail: Option<Detail>,
}
/// 一屏的视图模型。
#[derive(Debug, Clone, PartialEq)]
pub struct Model {
/// 当前路由落在哪个页面上
pub screen: Screen,
/// 窗口宽度(**含外壳**:布局档位判据就是它)
pub window_width: f32,
/// 窗口高度(**含外壳**)。
///
/// 只有「矮窗口要换版面」这一类判据用它(M161:内容库在低高度下不能把列表压扁)——
/// 宽度档位仍只看 [`Self::window_width`]。
pub window_height: f32,
/// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页)
pub active: Option<NavSection>,
/// 实例页的数据(其它三页还是占位视觉,不读)
pub instances: Instances,
/// 下载页的数据(M94:版本列表 / 选中 / 安装进度,全部来自 presenter 快照)
pub download: Download,
/// 日志页的数据(M95:presenter 快照 → 日志行 + 诊断结论,见 [`super::logs`])。
pub logs: Logs,
/// 内容库页的数据(M109:检索条件 / 命中项 / 装入闸门,见 [`super::content`])。
pub content: Content,
/// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。
pub update: Update,
/// **设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流的那一份
/// (`AppState::settings_groups`,由 `presenter::host::settings::groups` 产出)。
///
/// 谁填:`app/view.rs` 的 `model()`(`state.settings_groups.clone()`)。用途只有一处——
/// 设置页各组的 `body` 按 schema 键从它里面 `dto::find_row` 取**当前值**,画成可改的控件;
/// 写回走 `AppCommand::SetSetting`,落盘后 host 重新发一次 `SettingsChanged` ⇒ 这里换新的一份。
///
/// 为什么放在 `Model` 而不是别处:它就是「这一帧要画的设置值」,与 `update` 同一条形状
/// (页面只认 `Model`,presenter 的快照不直接进页面层)。
pub settings: Vec<unlucky_presenter::dto::SettingGroupView>,
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
///
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
/// 即「用户档位 / 系统 locale → 生效语言」这条链的结论。**不是**基线值:M95 以前这里是
/// `Lang::En` 占位,M106 之后中文系统上灌进来的是 `Lang::ZhCn`。
pub lang: Lang,
/// 本地 Mod(M224):详情页 MODS 区的列表 / 启停(见 [`super::instances::Mods`])。
///
/// 谁填:`app/view/instances.rs` 的 `mods_of`(presenter 的 `AppState::mods` → 展示字段)。
/// 空态(不在详情页 / 还没请求)是 `Mods::default()`,页面不画这一区。
pub mods: super::instances::Mods,
/// 存档 / 截图(M235):详情页 SAVES 区的只读列表(见 [`super::instances::Saves`])。
///
/// 谁填:`app/view/instances.rs` 的 `saves_of`(presenter 的 `AppState::saves` → 展示字段)。
/// 空态(不在详情页 / 还没请求)是 `Saves::default()`,页面照旧画这一次请求前的样子。
pub saves: super::instances::Saves,
/// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
///
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
/// 现在由 `app/view.rs` 把 `cli::ForcedState` 翻成 `Interaction` 灌进模型。
pub forced: Option<Interaction>,
}
impl Model {
/// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。
pub fn new(
screen: Screen,
window_width: f32,
window_height: f32,
active: Option<NavSection>,
) -> Self {
Self {
screen,
window_width,
window_height,
active,
instances: Instances::default(),
download: Download::loading(),
logs: Logs::default(),
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
update: Update::default(),
// 空快照 = 这一帧没有设置数据(harness / `pages_demo`;`demo_model` 也不伪造一份,
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
settings: Vec::new(),
mods: super::instances::Mods::default(),
saves: super::instances::Saves::default(),
lang: Lang::En,
forced: None,
}
}
}