Files
Unlucky-Launcher/crates/app-iced/src/pages/model.rs
T
AskaEth ab573838f3 M201: 接通 iced 设置页的数据通路(下载组:真值 + 真控件 + 写回闭环)
M193 的用户投诉「设置页里改了不生效」的结构性根因是 iced 侧没有设置数据通路:
`CoreEvent::SettingsChanged` 在 `crates/app-iced/src` 零命中、`pages::Model` 没有设置快照、
`download::State` 是空 struct。presenter 那套设置组早就是真值来源(`host::settings::groups`
由 `refresh_all` 与 `dispatch` 的 `SetSetting` 分支发成 `SettingsChanged`),只是没有接到 iced。

这条线现在通了:

    host::settings::groups → SettingsChanged → AppState::settings_groups
      → pages::Model.settings → settings::States.settings → 下载组控件
      → AppCommand::SetSetting → 写盘 + 重新发 SettingsChanged(闭环)

主要改动
- presenter `host/settings/download.rs`:并发/重试两行改成画**引擎生效值**(`DownloadTuning`),
  文件原值与生效值不一致时写明「原值 X 已按出厂 N 生效」(P3-5,两个数都现读);
  gh-proxy 行补「当前生效:跟随内置默认/已关闭/覆盖为 X」。
- app-iced `pages/settings/download.rs`:`GROUP.body = Some(body)`,按 `SettingControl`
  画 choice(filter chip)/ text(输入框 + 应用)/ action(按钮)/ toggle / readonly;写回走
  `app/update/settings/download.rs` → `AppCommand::SetSetting`(不做乐观回显)。
- `pages::Model.settings` + `settings::States.settings`:快照进页面的唯一通道。
- i18n:新增 `SettingsDownloadsApply`(金表锚点追加,`split_keeps_every_translation` 绿)。
- 取证:`ULL_SCREENSHOT_REAL_HOST=1` 让 `--screenshot` 接真 host(截图通道默认强制 mock,
  而 mock 不发 `SettingsChanged`,真值屏拍不出来)。

测试(新增 11 条)
- presenter `download_tests.rs`:P3-5 的越界回落 / 0→1 / 无差异不出提示 + P3-3 的
  gh-proxy 文件→三态端到端链。
- app-iced `app/update/settings/download_tests.rs`:假后端闭环 ×4 + **真 `CoreHost` 端到端**
  (点 16 ⇒ 磁盘上 `launcher.json` 真的有 16)。
- `pages/settings/tests/download_section.rs`:`body.is_none()` 绊线翻成 `is_some()` +
  控件形状 / 空快照不编值。
2026-11-09 10:51:16 +08:00

177 lines
7.8 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,
/// 取证用的强制交互态(`--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(),
lang: Lang::En,
forced: None,
}
}
}