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:
tower-worker committed 2026-11-10 21:09:37 +08:00
1 parent 71144dfca8
commit a0f61d8bb5
40 files changed
+2762 -91

No files matched your search

@@ -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,
+17 -2
View File
@@ -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(
+314
View File
@@ -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)
}
+1 -1
View File
@@ -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};
+6
View File
@@ -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,
}