feat(mods): 本地 Mod 列表与启停(M224 MODTOGGLE)
三层一次做完:core 读写目录 → presenter DTO/host → 实例详情页 MODS 区。 r1 复审后 rebase 到 main 71144df(M223 已合入),并修掉四条文档疵。 core(crates/core/src/addon/mods/) - suffix:`.disabled` / `.old` 的**唯一判据**(四态枚举 + 展示名 + 双向改名目标名), 收敛原先两份同构实现——`export/scan.rs::is_disabled_path` 与 `import/plan/paths.rs::suffix_variant` 改为委派/再导出,签名与行为不变。 - list:单根语义(入口是 derive_game_dir 派生出的运行目录)列 `<run>/mods/`,不按扩展名过滤, 符号链接/目录/怪名字逐条登记原因;`mods/` 本身是符号链接时拒(不跟随)。 - toggle:启停 = 在 `mods/` 里改名。五条守卫(裸文件名 / 目录可用 / 普通文件 / 不覆盖 / 不做 copy+delete 兜底)+ 幂等;`.disabled` 只插在 `.old` 之前。 presenter - dto/mods:ModsCommand / ModListView / ModFailureView(不拼文案,只给结构 + OS 原文)。 - host/mods:后台线程列目录与启停,运行目录走与启动同一份 D20 派生、不写任何设置; 游戏在跑时 host 侧直接挡。 - state/mods:快照整体替换、迟到结果丢弃、在飞标记;`AppState::mods`。 app-iced(实例详情页) - MODS 区(`pages/instances/mods.rs`):名字 + 大小 + `.old` 徽标 + Switch;运行中/在飞只读。 - 进入详情页即后台列一次(不走 3 s 观察窗,避免打红 M192 的「空闲不烧帧」守卫)。 - MODS 卡排在启动参数**之前**(详情列是滚动区,排在后面 1180×1000 下永远看不见)。 - i18n:19 条中英文案(`catalog/instances.rs` + 金表)。 rebase 冲突解法(三个文件,全是"两侧同段新增",都保留): - `i18n/catalog/instances.rs` + `i18n/catalog/golden.rs`:M223 的 Adopt* 段在前、M224 的 InstancesMods* 段在后(= `Key::ALL` 顺序 = 金表顺序)。 - `presenter/src/state.rs`:`adopt` 与 `mods` 字段/初始化/apply_core 分支都留;`tiles()` 归 `state/tiles.rs`(本分支搬的),`apply_agent`/`agent_rail_visible` 归 `state/agent.rs` (M223 搬的)——两处搬移不重叠,state.rs 里都不再出现。 r1 四条文档疵: 1. `pages/instances/mods.rs` 模块文档:挂载位置写反("启动参数卡之后"→实际之前),并补上为什么; 2. `state/mods.rs`:引用不存在的 `CoreEvent::ModsChanged/ModsFailed` → 改成 `CoreEvent::Mods` + 文字说明 Ok 落快照 / Err 落具名失败; 3. `app/state/mods_shot.rs`:文档说"目录串取真实派生结果"但代码留空 → 改成"故意留空"; 4. `app/state/refresh.rs::note_mods_failure`:文档说"连点两次看见两次红字"与去重机制不符 → 改成按 `(实例, 文件, 大类)` 三元组去重(防刷屏),换文件/换实例/换大类仍会再弹。 顺带(r1 已确认的 main 红):`pages/download/options/view.rs:88-89` 两条 `clippy::doc_lazy_continuation` 只缩进修掉,文字未动。
This commit is contained in:
1 parent
71144dfca8
commit
a0f61d8bb5
40 files changed
+2762
-91
No files matched your search
@@ -67,7 +67,9 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs
|
||||
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
mod boot_probe;
|
||||
// M224: 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE`;产品路径零影响)
|
||||
mod construct;
|
||||
mod mods_shot;
|
||||
mod refresh;
|
||||
mod theme;
|
||||
|
||||
@@ -180,6 +182,11 @@ pub struct App {
|
||||
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
|
||||
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
|
||||
pub(super) launch_ends_seen: u64,
|
||||
/// 已经提示过的启停失败(M224):`(实例, 文件, 大类)`。同一条失败重复到达不重弹。
|
||||
///
|
||||
/// 为什么不用计数器(与上面两条不同):失败是**并发可交错**的(A 的文件失败、B 的列表成功、
|
||||
/// A 又失败),计数差说明不了"哪一条没提示过",三元组才是幂等键。
|
||||
pub(super) mods_failed_seen: Option<(String, String, unlucky_presenter::dto::ModFailureKind)>,
|
||||
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
|
||||
///
|
||||
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
|
||||
|
||||
@@ -139,6 +139,7 @@ impl App {
|
||||
adopt_form: super::super::adopt::Form::default(),
|
||||
install_cancelled_seen: 0,
|
||||
launch_ends_seen: 0,
|
||||
mods_failed_seen: None,
|
||||
dialog_card: None,
|
||||
dialog_model: None,
|
||||
content: pages::content::PageState::default(),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE=1`)。
|
||||
//!
|
||||
//! # 它解决什么
|
||||
//!
|
||||
//! D30 的 MODS 区截图需要一屏**有内容的列表**。产品路径的列表来自后台线程(列 `mods/` → 快照),
|
||||
//! 而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及完成,截出来会是
|
||||
//! 「正在读取 mods/…」。M157 的启动参数区、M222 的下载页、M223 的导入都是同一个问题,
|
||||
//! 处理方式一致:**状态注入**,且注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
|
||||
//!
|
||||
//! # 边界(不要把它读成产品能力)
|
||||
//!
|
||||
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
|
||||
//! - 它注入的是**快照**([`ModListView`]),不是页面模型——`Mods::from_state` 的转换、
|
||||
//! 计数、`.old` 徽标、在飞置灰走的都是产品代码;
|
||||
//! - 真实链路由两条端到端用例钉住(`host::mods::tests` 在真盘上列目录 + 启停)。
|
||||
|
||||
/// 夹具开关是否打开(`ULL_MODS_FIXTURE` 非空且不为 `0`)。
|
||||
#[must_use]
|
||||
pub fn enabled() -> bool {
|
||||
std::env::var("ULL_MODS_FIXTURE")
|
||||
.map(|value| {
|
||||
let value = value.trim();
|
||||
!value.is_empty() && value != "0"
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 一份固定快照(实例 id 用调用方给的那个)。
|
||||
///
|
||||
/// `dir` 故意留空:夹具不碰路径(这一版页面也不画它),写一个假路径反而会被当成"真的派生结果"。
|
||||
#[must_use]
|
||||
pub fn view(instance: &str) -> unlucky_presenter::dto::ModListView {
|
||||
use unlucky_presenter::dto::{ModEntryView, ModListView};
|
||||
|
||||
let entry = |file: &str, name: &str, enabled: bool, bytes: u64, old: bool| ModEntryView {
|
||||
file: file.to_string(),
|
||||
name: name.to_string(),
|
||||
enabled,
|
||||
bytes,
|
||||
old,
|
||||
};
|
||||
// 夹具不碰路径:`dir` 留空(页面这一版不画它,它只是 DTO 的一部分)。
|
||||
let dir = String::new();
|
||||
|
||||
ModListView {
|
||||
instance: instance.to_string(),
|
||||
dir,
|
||||
exists: true,
|
||||
entries: vec![
|
||||
entry(
|
||||
"sodium-fabric-0.5.11.jar",
|
||||
"sodium-fabric-0.5.11.jar",
|
||||
true,
|
||||
1_048_576,
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
"iris-fabric-1.8.5.jar",
|
||||
"iris-fabric-1.8.5.jar",
|
||||
true,
|
||||
2_621_440,
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
"jei-1.20.4-forge-17.3.0.49.jar.disabled",
|
||||
"jei-1.20.4-forge-17.3.0.49.jar",
|
||||
false,
|
||||
1_363_148,
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
"aether-1.20.1-1.0.0.jar.old",
|
||||
"aether-1.20.1-1.0.0.jar",
|
||||
true,
|
||||
7_340_032,
|
||||
true,
|
||||
),
|
||||
entry(
|
||||
"create-1.20.1-0.5.1.f.jar.disabled",
|
||||
"create-1.20.1-0.5.1.f.jar",
|
||||
false,
|
||||
4_194_304,
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
"fabric-api-0.92.2+1.20.4.jar",
|
||||
"fabric-api-0.92.2+1.20.4.jar",
|
||||
true,
|
||||
2_097_152,
|
||||
false,
|
||||
),
|
||||
],
|
||||
skipped: Vec::new(),
|
||||
running: false,
|
||||
known: true,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
|
||||
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
|
||||
|
||||
use unlucky_presenter::dto::{AppCommand, ModsCommand};
|
||||
|
||||
use super::mods_shot;
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::view;
|
||||
@@ -57,6 +60,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// M224:详情页 ⇄ MODS 区的绑定与取数(换实例就重新列一次 mods/)。
|
||||
// **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态
|
||||
// (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。
|
||||
self.sync_mods();
|
||||
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
|
||||
self.note_mods_failure();
|
||||
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
@@ -110,4 +120,58 @@ impl App {
|
||||
None => self.launch_form.bound = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情页 ⇄ MODS 区的绑定与取数。
|
||||
///
|
||||
/// 判据只有一条:**绑定变了**(进详情页 / 换实例 / 退出详情页)。
|
||||
/// - 绑定函数自己会清掉旧快照(A 的列表不许画在 B 的详情页上,见 presenter 的 `ModsState::bind`);
|
||||
/// - 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Mods(List)` 去后台列目录。
|
||||
///
|
||||
/// 为什么放在 `refresh_view` 里:它是**唯一**的「路由变了」漏斗(点行、返回、首屏自动选中都走它)。
|
||||
/// 派发会再进一次 `refresh_view`(`dispatch` → `pump` → 重建),但那时 `bound` 已经相等,
|
||||
/// 这一支直接返回,不会重复发请求、也不会无限递归。
|
||||
fn sync_mods(&mut self) {
|
||||
let wanted = match self.state.route() {
|
||||
Route::Detail(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if self.state.mods.bound == wanted {
|
||||
return;
|
||||
}
|
||||
self.state.bind_mods(wanted.as_deref());
|
||||
let Some(id) = wanted else {
|
||||
return;
|
||||
};
|
||||
// 出图取证(`ULL_MODS_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
|
||||
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `mods_shot` 的模块文档)。
|
||||
if mods_shot::enabled() {
|
||||
self.state.mods.changed(mods_shot::view(&id));
|
||||
return;
|
||||
}
|
||||
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒(`watch_until`),
|
||||
// 而这里只是"进详情页顺手后台列一次目录"——结果在下一拍心跳(1 Hz)落地就够了。
|
||||
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红(`subscription` 的三条用例立刻红:实测过)。
|
||||
self.backend
|
||||
.dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id))));
|
||||
}
|
||||
|
||||
/// 启停失败的提示语:每条**新**失败弹一次 Toast。
|
||||
///
|
||||
/// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时,
|
||||
/// **只弹一次**——第二次是同一件事的重复,再弹就是刷屏(用户已经看见了那句红字)。
|
||||
/// 换了文件、换了实例、或失败大类变了(例如"不覆盖冲突"变成"被占用"),三元组就变了,
|
||||
/// 于是照样会再弹一次:提示语跟的是"新事实",不是"点击次数"。
|
||||
fn note_mods_failure(&mut self) {
|
||||
let Some(failure) = self.state.mods.failed.as_ref() else {
|
||||
self.mods_failed_seen = None;
|
||||
return;
|
||||
};
|
||||
let key = (failure.instance.clone(), failure.file.clone(), failure.kind);
|
||||
if self.mods_failed_seen.as_ref() == Some(&key) {
|
||||
return;
|
||||
}
|
||||
let text = pages::mods_failure_line(failure, self.lang().catalog());
|
||||
self.state.toasts.push(ToastKind::Error, text);
|
||||
self.mods_failed_seen = Some(key);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand};
|
||||
|
||||
use crate::pages::InstanceMessage;
|
||||
|
||||
@@ -19,17 +19,29 @@ use super::super::message::Message;
|
||||
use super::super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 启动参数区的一条消息。
|
||||
/// 实例页详情列的一条消息(启动参数表单 + MODS 区)。
|
||||
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
|
||||
let Some(instance) = self.selected_instance_id() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self.launch_form.bound_to(&instance) {
|
||||
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match message {
|
||||
// M224:启停一个 mod。**不做乐观翻面**——先把那个开关标成「请求在飞」(置灰),
|
||||
// 真状态等 host 回一份新快照(与 Save 分支同一条纪律)。
|
||||
//
|
||||
// 这一支在 `bound_to` 守卫**之前**:MODS 区的启停只依赖「当前选中实例」,
|
||||
// 与启动参数表单播没播种无关(守卫是给表单草稿用的,见下一条)。
|
||||
InstanceMessage::SetModEnabled { file, enabled } => {
|
||||
self.state.mods.mark_pending(&file);
|
||||
self.dispatch(AppCommand::Mods(Box::new(ModsCommand::SetEnabled {
|
||||
id: instance,
|
||||
file,
|
||||
enabled,
|
||||
})));
|
||||
return Task::none();
|
||||
}
|
||||
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
||||
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
|
||||
InstanceMessage::Jvm(text) => {
|
||||
self.launch_form.jvm = text;
|
||||
self.launch_form.dirty = true;
|
||||
@@ -137,6 +149,34 @@ mod tests {
|
||||
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘");
|
||||
}
|
||||
|
||||
/// **MODS 区的启停在表单守卫之前**(M224):表单还没播种也照样能启停。
|
||||
///
|
||||
/// 抓得住:把 `SetModEnabled` 放回 `bound_to` 守卫之后——那时首帧的详情页点了开关
|
||||
/// 什么都不会发生(用户看到开关纹丝不动,也没有任何提示)。
|
||||
#[test]
|
||||
fn mod_toggles_work_before_the_launch_form_is_bound() {
|
||||
let mut app = app_with_form();
|
||||
// 页面已经打开(真路径上 `refresh_view` 的 `sync_mods` 会做这一步),但表单还没播种。
|
||||
app.state.bind_mods(Some(INSTANCE));
|
||||
app.launch_form = LaunchForm::from_view(
|
||||
Some("other"),
|
||||
&LaunchArgsView {
|
||||
instance_id: "other".to_string(),
|
||||
..LaunchArgsView::default()
|
||||
},
|
||||
);
|
||||
|
||||
let _ = app.on_instance_message(InstanceMessage::SetModEnabled {
|
||||
file: "sodium.jar".to_string(),
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
assert!(
|
||||
app.state.mods.is_pending("sodium.jar"),
|
||||
"启停被表单守卫挡住了(开关点了没反应)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
|
||||
#[test]
|
||||
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
|
||||
|
||||
@@ -255,6 +255,8 @@ pub(super) fn model(
|
||||
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
|
||||
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
|
||||
logs: logs_model(state).with_status(lang),
|
||||
// M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段)
|
||||
mods: instances::mods_of(state, catalog),
|
||||
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
|
||||
update: update_of(&state.update),
|
||||
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
|
||||
|
||||
@@ -23,8 +23,9 @@ use unlucky_presenter::dto::{
|
||||
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
|
||||
LaunchStage, LoaderKind, LogOutcome,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::pages::{Detail, LaunchFacts, Meta};
|
||||
use crate::pages::{Detail, LaunchFacts, Meta, Mods};
|
||||
|
||||
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
|
||||
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||||
@@ -264,3 +265,11 @@ pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
|
||||
_ => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地 Mod 的展示模型(M224):presenter 的视图态 → 页面要画的东西。
|
||||
///
|
||||
/// 转换本体在 [`crate::pages::instances::mods`] 的 `from_state`(文案要用 `Texts`,那是页面层的
|
||||
/// 取词入口)——这里只把"当前语言"给过去,与 [`detail_of`] 同一条接法。
|
||||
pub(super) fn mods_of(state: &AppState, catalog: Catalog) -> Mods {
|
||||
crate::pages::mods_from_state(&state.mods, catalog)
|
||||
}
|
||||
@@ -85,8 +85,8 @@ pub(super) fn version_menu_height(items: usize) -> f32 {
|
||||
/// head + chips,错误行不可达;
|
||||
/// - chips 档(可用宽 < 600,`of` 对 `Options`/`Install` 无条件 Shared):`problem` 可以是
|
||||
/// `Some`,错误行**会进滚动区**、可能被滚走;`show_versions()` 为假时钉死区只剩实例名。
|
||||
/// D30 八审实测:滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + 间距 4)≈76px ⇒
|
||||
/// 1180×760 下 head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。
|
||||
/// D30 八审实测:滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + 间距 4)≈76px ⇒
|
||||
/// 1180×760 下 head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。
|
||||
pub(crate) fn section<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
|
||||
@@ -49,6 +49,8 @@ mod data;
|
||||
mod demo;
|
||||
mod detail;
|
||||
mod launch_args;
|
||||
// M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关)
|
||||
mod mods;
|
||||
mod state;
|
||||
mod text;
|
||||
|
||||
@@ -58,6 +60,15 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) use data::{DEMO, NOTE, SELECTED};
|
||||
pub(crate) use demo::{demo, demo_form};
|
||||
pub use mods::{from_state as mods_from_state, Mods};
|
||||
|
||||
/// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。
|
||||
pub fn mods_failure_line(
|
||||
failure: &unlucky_presenter::dto::ModFailureView,
|
||||
catalog: unlucky_i18n::Catalog,
|
||||
) -> String {
|
||||
mods::failure_line(failure, Texts::of(catalog))
|
||||
}
|
||||
pub use state::{LaunchForm, Message as InstanceMessage};
|
||||
pub(super) use text::Texts;
|
||||
|
||||
@@ -131,12 +142,16 @@ pub fn view<'a>(
|
||||
metrics,
|
||||
list_column(ui, block, forced, texts),
|
||||
match &block.detail {
|
||||
Some(detail) => detail::detail_column(ui, metrics, detail, form, forced, texts),
|
||||
Some(detail) => {
|
||||
detail::detail_column(ui, metrics, detail, form, &model.mods, forced, texts)
|
||||
}
|
||||
None => detail::detail_empty(ui, texts),
|
||||
},
|
||||
),
|
||||
_ => match &block.detail {
|
||||
Some(detail) => detail::narrow_detail(ui, metrics, detail, form, forced, texts),
|
||||
Some(detail) => {
|
||||
detail::narrow_detail(ui, metrics, detail, form, &model.mods, forced, texts)
|
||||
}
|
||||
None => frame::body(metrics, detail::detail_empty(ui, texts)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ pub(super) fn detail_column<'a>(
|
||||
metrics: FrameMetrics,
|
||||
detail: &'a Detail,
|
||||
form: &'a LaunchForm,
|
||||
mods: &'a super::Mods,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -45,6 +46,10 @@ pub(super) fn detail_column<'a>(
|
||||
.width(Length::Fill);
|
||||
|
||||
body = push_text(ui, metrics, body, detail);
|
||||
// M224:MODS 区排在启动参数**之前**——详情列是一个滚动区,而启动参数卡本身就有一屏高,
|
||||
// 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。
|
||||
// 顺序因此是:身份(页头 + 元信息)→ 内容(MODS)→ 选项(启动参数)→ 动作。
|
||||
body = body.push(super::mods::section(ui, mods, forced, texts));
|
||||
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
|
||||
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
|
||||
|
||||
@@ -62,6 +67,7 @@ pub(super) fn narrow_detail<'a>(
|
||||
metrics: FrameMetrics,
|
||||
detail: &'a Detail,
|
||||
form: &'a LaunchForm,
|
||||
mods: &'a super::Mods,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -84,6 +90,8 @@ pub(super) fn narrow_detail<'a>(
|
||||
.width(Length::Fill);
|
||||
|
||||
body = push_text(ui, metrics, body, detail);
|
||||
// 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。
|
||||
body = body.push(super::mods::section(ui, mods, forced, texts));
|
||||
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
|
||||
|
||||
body = body.push(frame::text_role(
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! 实例详情页的 **MODS 区**(M224 · MODTOGGLE):本地 mod 列表 + 启停开关。
|
||||
//!
|
||||
//! # 位置与形状
|
||||
//!
|
||||
//! 挂在详情列里、元信息网格之后、启动参数卡**之前**(`pages/instances/detail.rs` 的 `detail_column`),
|
||||
//! 动作行仍在最后。与启动参数区同一套卡片口径:[`frame::card`] + 一个 `LabelMedium` 小标 + 内容。
|
||||
//!
|
||||
//! **为什么在启动参数之前**:详情列整列是一个滚动区,而启动参数卡本身就有近一屏高。MODS 排在它
|
||||
//! 后面时,1180×1000 下 MODS 区整块落在折叠线以下、根本看不见(M224 出图实测踩到过)。
|
||||
//!
|
||||
//! # 为什么不自己再套一层滚动区
|
||||
//!
|
||||
//! 详情列**整列**已经是滚动区(M157 r2 修的「Max memory 以下够不着」)。这里再套一个滚动条
|
||||
//! 会出现「滚动条里的滚动条」——1180×1000 下两个条都认得出来,用户不知道该拖哪一个。
|
||||
//! 列表多长就排多长,交给外层那一条。
|
||||
//!
|
||||
//! # 开关三档
|
||||
//!
|
||||
//! | 状态 | 开关 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 正常 | 可点(发 [`PageMessage::SetModEnabled`]) | — |
|
||||
//! | 游戏在跑 | 禁用(不带消息) | Windows 上进程正持有 jar,rename 必然失败(host 侧也挡了一层) |
|
||||
//! | 请求在飞 | 禁用 | 同一文件连点两次会发出两条 rename,第二条必然报「文件不在」 |
|
||||
//!
|
||||
//! **不做乐观回显**:开关画的是快照里的 `enabled`,点完等 host 回一份新快照才翻面。
|
||||
//! 与启动/停止同一条纪律(`app/update/instances.rs` 的 Save 分支)。
|
||||
|
||||
use iced::widget::{column, 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, ModFailureView};
|
||||
use unlucky_presenter::state::ModsState;
|
||||
|
||||
use crate::app::components::interaction::Interaction;
|
||||
use crate::app::components::selection::{switch, Switch};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::frame;
|
||||
use super::super::Message;
|
||||
use super::state::Message as PageMessage;
|
||||
use super::Texts;
|
||||
|
||||
/// 一条 mod(纯展示字段,来源是 `presenter::dto::ModEntryView`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Item {
|
||||
/// 磁盘文件名(开关消息原样带回去)。
|
||||
pub file: String,
|
||||
/// 展示名(去掉 `.disabled` / `.old`)。
|
||||
pub name: String,
|
||||
/// 是否启用。
|
||||
pub enabled: bool,
|
||||
/// 右侧副文案(大小 + 「旧版本残留」标记,已由 app 层拼好)。
|
||||
pub detail: String,
|
||||
/// 这个文件的启停请求在飞(开关先禁用,不做乐观翻面)。
|
||||
pub pending: bool,
|
||||
}
|
||||
|
||||
/// MODS 区的展示模型(`Model::mods`)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Mods {
|
||||
/// 这份数据属于哪个实例(`None` = 不在详情页 / 还没请求过)。
|
||||
pub bound: Option<String>,
|
||||
/// 正在读盘(详情页首帧:请求已发出、快照还没回来)。
|
||||
pub loading: bool,
|
||||
/// 游戏在该实例上运行(开关只读)。
|
||||
pub running: bool,
|
||||
/// 实例有 `mods/` 目录吗(`false` 且没有错误 ⇒ 还没装过模组)。
|
||||
pub exists: bool,
|
||||
/// 读目录失败的人读原因(app 层按 i18n 拼好)。
|
||||
pub error: Option<String>,
|
||||
/// 列表。
|
||||
pub items: Vec<Item>,
|
||||
/// 跳过条目数(符号链接 / 目录 / 读不出)。
|
||||
pub skipped: usize,
|
||||
/// 计数行(`12 启用 · 3 禁用`):**在这一层算好**,页面只借 `&str`。
|
||||
///
|
||||
/// 为什么不留到页面里 `format!`:iced 的 `Element` 只借这一帧的数据,页面函数返回的是
|
||||
/// **借用局部变量的树**,局部 `String` 一出栈就悬空(`E0515`)。所以"要显示的那句话"
|
||||
/// 一律在视图模型里备好(与 `Detail::status` / `Row::supporting` 同一条做法)。
|
||||
pub count: String,
|
||||
/// 跳过提示(`跳过 3 项`)。
|
||||
pub skipped_note: String,
|
||||
}
|
||||
|
||||
impl Mods {
|
||||
/// 启用中的条数。
|
||||
#[must_use]
|
||||
pub fn enabled_count(&self) -> usize {
|
||||
self.items.iter().filter(|item| item.enabled).count()
|
||||
}
|
||||
|
||||
/// 禁用中的条数。
|
||||
#[must_use]
|
||||
pub fn disabled_count(&self) -> usize {
|
||||
self.items.len() - self.enabled_count()
|
||||
}
|
||||
|
||||
/// 有没有拿到一份快照(成功或失败都算;`loading` 中不算)。
|
||||
#[must_use]
|
||||
pub fn loaded(&self) -> bool {
|
||||
self.bound.is_some() && !self.loading
|
||||
}
|
||||
|
||||
/// 把某个文件的开关标成「请求在飞」(不做乐观翻面:`enabled` 不动)。
|
||||
pub fn mark_pending(&mut self, file: &str) {
|
||||
if let Some(item) = self.items.iter_mut().find(|item| item.file == file) {
|
||||
item.pending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
|
||||
///
|
||||
/// 两条口径:
|
||||
///
|
||||
/// 1. **失败不清列表**:list 失败时保留上一份 `items`(用户还能看见列表,只是多一条红字),
|
||||
/// 只有"从来没有过快照"时才是一条纯错误态;
|
||||
/// 2. **不做乐观翻面**:`enabled` 只来自快照,`pending` 只用来把开关置灰。
|
||||
#[must_use]
|
||||
pub fn from_state(state: &ModsState, catalog: Catalog) -> Mods {
|
||||
let texts = Texts::of(catalog);
|
||||
let mut out = Mods {
|
||||
bound: state.bound.clone(),
|
||||
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
|
||||
..Mods::default()
|
||||
};
|
||||
if let Some(view) = &state.view {
|
||||
out.running = view.running;
|
||||
out.exists = view.exists;
|
||||
out.skipped = view.skipped.len();
|
||||
// **先装列表再算计数**:`enabled_count()` 读的是 `out.items`(顺序反了会画出 `0 启用 · 0 禁用`
|
||||
// ——M224 出图实测抓到过一次)。
|
||||
out.items = view
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| Item {
|
||||
file: entry.file.clone(),
|
||||
name: entry.name.clone(),
|
||||
enabled: entry.enabled,
|
||||
detail: item_detail(entry.bytes, entry.old, texts),
|
||||
pending: state.is_pending(&entry.file),
|
||||
})
|
||||
.collect();
|
||||
out.count = texts.mods_count(out.enabled_count(), out.disabled_count());
|
||||
out.skipped_note = texts.mods_skipped(out.skipped);
|
||||
}
|
||||
if let Some(failure) = &state.failed {
|
||||
out.loading = false;
|
||||
out.error = Some(failure_line(failure, texts));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 启停失败/读目录失败的一句话(**唯一产地**:Toast 与页面内红字共用)。
|
||||
#[must_use]
|
||||
pub fn failure_line(failure: &ModFailureView, texts: Texts) -> String {
|
||||
let reason = texts.mods_reason(failure.kind);
|
||||
let sentence = if failure.file.is_empty() {
|
||||
texts.mods_failed(reason)
|
||||
} else {
|
||||
texts.mods_toggle_failed(&failure.file, reason)
|
||||
};
|
||||
if failure.detail.is_empty() {
|
||||
return sentence;
|
||||
}
|
||||
format!("{sentence}({})", failure.detail)
|
||||
}
|
||||
|
||||
/// 右侧副文案:`1.2 MiB` 或 `1.2 MiB · 旧版本残留`。
|
||||
fn item_detail(bytes: u64, old: bool, texts: Texts) -> String {
|
||||
let size = human_bytes(bytes);
|
||||
if old {
|
||||
format!("{size} · {}", texts.mods_old_badge())
|
||||
} else {
|
||||
size
|
||||
}
|
||||
}
|
||||
|
||||
/// 画 MODS 区(实例页唯一入口,`detail.rs` 调它)。
|
||||
pub(super) fn section<'a>(
|
||||
ui: &'a Ui,
|
||||
mods: &'a Mods,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
let mut body = column![head(ui, mods, texts)]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill);
|
||||
|
||||
if mods.loading {
|
||||
body = body.push(note(ui, texts.mods_loading()));
|
||||
} else if let Some(error) = mods.error.as_deref() {
|
||||
body = body.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
error,
|
||||
ui.color(Role::Error),
|
||||
));
|
||||
} else if !mods.exists {
|
||||
body = body.push(note(ui, texts.mods_missing_dir()));
|
||||
} else if mods.items.is_empty() {
|
||||
body = body.push(note(ui, texts.mods_empty()));
|
||||
} else {
|
||||
// 只读原因放在列表**上面**:用户先看到"为什么点不动",再去点。
|
||||
if mods.running {
|
||||
body = body.push(note(ui, texts.mods_running()));
|
||||
}
|
||||
for item in &mods.items {
|
||||
body = body.push(entry_row(ui, item, mods.running, forced));
|
||||
}
|
||||
if mods.skipped > 0 {
|
||||
body = body.push(note(ui, &mods.skipped_note));
|
||||
}
|
||||
}
|
||||
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
/// 小标行:`MODS` + 计数(`12 启用 · 3 禁用`)。
|
||||
fn head<'a>(ui: &'a Ui, mods: &'a Mods, texts: Texts) -> Element<'a, Message> {
|
||||
let mut line = row![frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
texts.mods_title(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill);
|
||||
|
||||
// 计数只在"真有一份可用快照"时画:读盘中 / 读失败 / 空目录画 0·0 会像"这里什么都没有"。
|
||||
let show_count = mods.loaded() && mods.error.is_none() && !mods.items.is_empty();
|
||||
if show_count {
|
||||
line = line.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
mods.count.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
line.into()
|
||||
}
|
||||
|
||||
/// 一条 mod 的行:名字 + 副文案 + 开关。
|
||||
fn entry_row<'a>(
|
||||
ui: &'a Ui,
|
||||
item: &'a Item,
|
||||
running: bool,
|
||||
forced: Option<Interaction>,
|
||||
) -> Element<'a, Message> {
|
||||
let label = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
item.name.as_str(),
|
||||
ui.color(if item.enabled {
|
||||
Role::OnSurface
|
||||
} else {
|
||||
Role::OnSurfaceVariant
|
||||
}),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
item.detail.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
let toggle = if running || item.pending {
|
||||
Switch::disabled("", item.enabled)
|
||||
} else {
|
||||
Switch::new(
|
||||
"",
|
||||
item.enabled,
|
||||
Message::Instances(PageMessage::SetModEnabled {
|
||||
file: item.file.clone(),
|
||||
enabled: !item.enabled,
|
||||
}),
|
||||
)
|
||||
};
|
||||
// 出图取证(`--force-state`)把开关也钉到同一档:否则截图里只有按钮是 hover、
|
||||
// 开关还是静息态,视觉复审没法判"悬停时的开关长什么样"。
|
||||
let toggle = match forced {
|
||||
Some(state) => toggle.state(state),
|
||||
None => toggle,
|
||||
};
|
||||
row![label, switch(ui, toggle)]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.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),
|
||||
)
|
||||
}
|
||||
@@ -154,6 +154,15 @@ pub enum Message {
|
||||
Save,
|
||||
/// 恢复跟随全局(清表单,仍需保存)。
|
||||
Reset,
|
||||
/// 启停一个 mod(M224):`file` 是**磁盘文件名**(含后缀),`enabled` 是期望状态。
|
||||
///
|
||||
/// 页面只发意图,落盘由 host 在后台线程做;回程是一份新快照(不做乐观回显)。
|
||||
SetModEnabled {
|
||||
/// 磁盘文件名
|
||||
file: String,
|
||||
/// 期望的启用状态
|
||||
enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -28,10 +28,15 @@ use super::super::Texts;
|
||||
///
|
||||
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
|
||||
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
|
||||
const ALLOWED: &[&str] = &["Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB"];
|
||||
const ALLOWED: &[&str] = &[
|
||||
"Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB",
|
||||
// M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个
|
||||
// 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。
|
||||
"mods",
|
||||
];
|
||||
|
||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
const PAGE_SOURCES: [(&str, &str); 6] = [
|
||||
const PAGE_SOURCES: [(&str, &str); 7] = [
|
||||
("instances.rs", include_str!("../../instances.rs")),
|
||||
("instances/detail.rs", include_str!("../detail.rs")),
|
||||
("instances/demo.rs", include_str!("../demo.rs")),
|
||||
@@ -44,6 +49,8 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
|
||||
"app/view/instances.rs",
|
||||
include_str!("../../../app/view/instances.rs"),
|
||||
),
|
||||
// M224:MODS 区(本地 mod 列表 + 启停开关)
|
||||
("instances/mods.rs", include_str!("../mods.rs")),
|
||||
];
|
||||
|
||||
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
|
||||
@@ -61,7 +68,7 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 24] = [
|
||||
const ALLOWED_LITERALS: [&str; 28] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
@@ -97,6 +104,12 @@ const ALLOWED_LITERALS: [&str; 24] = [
|
||||
"ULL_LAUNCH_ARGS_FIXTURE",
|
||||
// M161:示例「被门槛挡下」的取值(夹具)。
|
||||
"gated",
|
||||
// M224:MODS 区四个 key 的**占位符名**(`{enabled}` / `{disabled}` / `{count}` / `{file}`),
|
||||
// 不是文案——`Catalog::render` 按名字取值,换成别的词就渲染不出来了。
|
||||
"enabled",
|
||||
"disabled",
|
||||
"count",
|
||||
"file",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::ModFailureKind;
|
||||
|
||||
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -151,6 +152,66 @@ impl Texts {
|
||||
self.text(Key::InstancesLaunchReset)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- MODS 区(M224)
|
||||
pub(super) fn mods_title(self) -> &'static str {
|
||||
self.text(Key::InstancesModsTitle)
|
||||
}
|
||||
/// 「12 启用 · 3 禁用」。
|
||||
pub(super) fn mods_count(self, enabled: usize, disabled: usize) -> String {
|
||||
self.0.render(
|
||||
Key::InstancesModsCount,
|
||||
&[
|
||||
("enabled", &enabled.to_string()),
|
||||
("disabled", &disabled.to_string()),
|
||||
],
|
||||
)
|
||||
}
|
||||
pub(super) fn mods_loading(self) -> &'static str {
|
||||
self.text(Key::InstancesModsLoading)
|
||||
}
|
||||
pub(super) fn mods_empty(self) -> &'static str {
|
||||
self.text(Key::InstancesModsEmpty)
|
||||
}
|
||||
pub(super) fn mods_missing_dir(self) -> &'static str {
|
||||
self.text(Key::InstancesModsMissingDir)
|
||||
}
|
||||
pub(super) fn mods_running(self) -> &'static str {
|
||||
self.text(Key::InstancesModsRunning)
|
||||
}
|
||||
pub(super) fn mods_old_badge(self) -> &'static str {
|
||||
self.text(Key::InstancesModsOldBadge)
|
||||
}
|
||||
/// 「跳过 3 项」。
|
||||
pub(super) fn mods_skipped(self, count: usize) -> String {
|
||||
self.0
|
||||
.render(Key::InstancesModsSkipped, &[("count", &count.to_string())])
|
||||
}
|
||||
/// 读目录失败的主句:`读取 mods/ 失败:没有可用的 mods/ 目录`。
|
||||
pub(super) fn mods_failed(self, reason: &str) -> String {
|
||||
self.0
|
||||
.render(Key::InstancesModsFailed, &[("reason", reason)])
|
||||
}
|
||||
/// 启停失败的主句:`启停 x.jar 失败:同名文件已存在`。
|
||||
pub(super) fn mods_toggle_failed(self, file: &str, reason: &str) -> String {
|
||||
self.0.render(
|
||||
Key::InstancesModsToggleFailed,
|
||||
&[("file", file), ("reason", reason)],
|
||||
)
|
||||
}
|
||||
/// 启停失败大类的短句(`ModFailureKind` → 一句话;未知取值不猜,原样上屏)。
|
||||
pub(super) fn mods_reason(self, kind: ModFailureKind) -> &'static str {
|
||||
self.text(match kind {
|
||||
ModFailureKind::Running => Key::InstancesModsReasonRunning,
|
||||
ModFailureKind::InstanceUnavailable => Key::InstancesModsReasonInstance,
|
||||
ModFailureKind::UnsafeName => Key::InstancesModsReasonName,
|
||||
ModFailureKind::ModsDirUnusable => Key::InstancesModsReasonDirectory,
|
||||
ModFailureKind::NotFound => Key::InstancesModsReasonMissing,
|
||||
ModFailureKind::NotARegularFile => Key::InstancesModsReasonNotFile,
|
||||
ModFailureKind::Collision => Key::InstancesModsReasonCollision,
|
||||
ModFailureKind::Rename => Key::InstancesModsReasonRename,
|
||||
})
|
||||
}
|
||||
|
||||
fn text(self, key: Key) -> &'static str {
|
||||
self.0.text(key)
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ pub(crate) mod settings;
|
||||
mod tests;
|
||||
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
|
||||
pub(crate) use instances::demo_form as demo_launch_form;
|
||||
pub use instances::{InstanceMessage, LaunchForm};
|
||||
pub use instances::{mods_failure_line, mods_from_state, InstanceMessage, LaunchForm, Mods};
|
||||
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
||||
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
|
||||
pub use message::{Action, Message, UpdateAction};
|
||||
|
||||
@@ -140,6 +140,11 @@ pub struct 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,
|
||||
/// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
|
||||
///
|
||||
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
|
||||
@@ -169,6 +174,7 @@ impl Model {
|
||||
// 空快照 = 这一帧没有设置数据(harness / `pages_demo`;`demo_model` 也不伪造一份,
|
||||
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
|
||||
settings: Vec::new(),
|
||||
mods: super::instances::Mods::default(),
|
||||
lang: Lang::En,
|
||||
forced: None,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user