用户诉求是 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 解析(世界名暂时就是目录名)。
189 lines
8.6 KiB
Rust
189 lines
8.6 KiB
Rust
//! 视图模型:**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,
|
||
}
|
||
}
|
||
}
|