feat(launch): M157 实例级启动参数(五项回落 + graphicsBackend 三态)

core: 新增 game/launch_overrides(LaunchOverrides 五项 All-Option + 三级回落 + 文本补丁校验)与 launch/overrides(拼装落位:全局 java.extra_args 进 user.jvm、实例 jvmArgs 进 override_jvm、gameArgs 进 user.game、memory 进 -Xmx、backend 进 --graphicsBackend 并复用既有版本门槛告警);实例 schema 登记 launch.* 五键。

presenter: dto/LaunchArgsView+LaunchArgsPatch,主机侧读/写与门槛判定(backend_allowed),AppCommand::SetInstanceLaunchArgs。

cli: 新命令 instances launch-args(读/写/--clear),launch 装配链消费回落值。

app-iced: 实例详情页「启动参数」区(JVM/游戏参数、内存、Java、三态后端 chips + 灰化提示 + 保存/恢复),表单状态归 App、i18n 中英文案同步。
This commit is contained in:
AskaEth committed 2026-11-07 10:36:35 +08:00
1 parent d68523b02a
commit e2021645d7
56 files changed
+3776 -607

No files matched your search

+6
View File
@@ -178,6 +178,12 @@ pub struct App {
pub(super) account_form: pages::account::Form,
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
pub(super) account: pages::account::Page,
/// 实例页「启动参数」区的表单(M157):五项 + 脏标记 + 绑定的实例 id。
///
/// 住在 `App` 的理由与 `download_draft` 完全一致:`refresh_view` 每次都整棵重建
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
pub(super) launch_form: pages::LaunchForm,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
@@ -124,6 +124,7 @@ impl App {
agent_host: super::presenter::AgentHost::from_env(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(),
launch_form: pages::LaunchForm::default(),
install_cancelled_seen: 0,
content: pages::content::PageState::default(),
account_form: pages::account::Form::new(),
+27
View File
@@ -8,6 +8,8 @@ use unlucky_presenter::toast::ToastKind;
use crate::pages;
use unlucky_presenter::router::Route;
use super::App;
impl App {
@@ -52,10 +54,35 @@ impl App {
self.lang(),
&self.content,
);
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
self.sync_launch_form();
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width);
self.view_dirty = false;
self.refreshes += 1;
}
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
///
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
/// 免得「A 的草稿被当成 B 的初值」。
fn sync_launch_form(&mut self) {
let Route::Detail(id) = self.state.route() else {
self.launch_form.bound = None;
return;
};
match self
.state
.instances
.iter()
.find(|instance| &instance.id == id)
{
Some(instance) => {
let view = instance.launch.clone();
self.launch_form.sync(&view);
}
None => self.launch_form.bound = None,
}
}
}
+2 -2
View File
@@ -202,13 +202,13 @@ fn the_settings_page_renders_the_real_appearance_group() {
assert_eq!(app.view.screen, Screen::Settings);
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
drop(_tree);
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
app.appearance.apply(Change::SelectGroup(0));
assert_eq!(app.appearance.selected_group, 0);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
}
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
+1
View File
@@ -48,6 +48,7 @@ mod boot_env;
mod clock;
mod content;
mod download;
mod instances;
mod page_messages;
mod settings;
+161
View File
@@ -0,0 +1,161 @@
//! 实例页「启动参数」区的消息落点(M157)。
//!
//! 三件事:
//! 1. **编辑**只改页面状态(`App::launch_form`)并置脏——不 dispatch,不打网络;
//! 2. **保存**派一条 [`AppCommand::SetInstanceLaunchArgs`](整份五项补丁),回程由
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
//!
//! 脏标记的清理点在保存之后:`refresh_view` 的 `LaunchForm::sync` 看到「同实例 + 脏」不会覆盖,
//! 所以保存后要显式清脏,让下一次快照(host 写完文件回来的那份)重播种。
use iced::Task;
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
use crate::pages::InstanceMessage;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 启动参数区的一条消息。
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 {
InstanceMessage::Jvm(text) => {
self.launch_form.jvm = text;
self.launch_form.dirty = true;
}
InstanceMessage::Game(text) => {
self.launch_form.game = text;
self.launch_form.dirty = true;
}
InstanceMessage::Memory(text) => {
self.launch_form.memory = text;
self.launch_form.dirty = true;
}
InstanceMessage::Java(text) => {
self.launch_form.java = text;
self.launch_form.dirty = true;
}
InstanceMessage::Backend(id) => {
self.launch_form.backend = id;
self.launch_form.dirty = true;
}
InstanceMessage::Reset => {
self.launch_form.reset();
}
InstanceMessage::Save => {
let patch: LaunchArgsPatch = self.launch_form.patch();
self.launch_form.dirty = false;
self.dispatch(AppCommand::set_launch_args(&instance, patch));
}
}
// 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在
// 消息处理完之后本来就会画一帧,所以这里什么都不排。
Task::none()
}
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
fn selected_instance_id(&self) -> Option<String> {
match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::LaunchArgsView;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::{InstanceMessage, LaunchForm};
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn app_with_form() -> App {
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::mock::MockBackend::new()),
SettingsBridge::memory_only(),
);
// 进详情页 + 直接放一份「已播种」的表单:本测试只验消息落点,
// 不验播种(那在 `LaunchForm::sync` 的单测里)。
app.state.navigate(unlucky_presenter::router::Route::Detail(
INSTANCE.to_string(),
));
app.launch_form = LaunchForm::from_view(
Some(INSTANCE),
&LaunchArgsView {
instance_id: INSTANCE.to_string(),
..LaunchArgsView::default()
},
);
app
}
/// 测试用的实例 id(mock 后端的实例快照里有它)。
const INSTANCE: &str = "survival";
/// 编辑 → 脏;恢复 → 五项清空仍需保存;保存前不发命令。
#[test]
fn editing_marks_dirty_and_reset_waits_for_save() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("6144".to_string()));
assert_eq!(app.launch_form.memory, "6144");
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Backend("vulkan".to_string()));
assert_eq!(app.launch_form.backend, "vulkan");
let _ = app.on_instance_message(InstanceMessage::Reset);
assert_eq!(app.launch_form.memory, "");
assert_eq!(app.launch_form.backend, "default");
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘");
}
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
#[test]
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
let mut app = app_with_form();
app.launch_form = LaunchForm::from_view(
Some("other"),
&LaunchArgsView {
instance_id: "other".to_string(),
..LaunchArgsView::default()
},
);
let _ = app.on_instance_message(InstanceMessage::Memory("9999".to_string()));
assert_eq!(app.launch_form.memory, "");
assert!(!app.launch_form.dirty);
}
/// 保存之后脏标记清掉(否则下一次快照刷新不敢重播种,界面会一直显示旧值)。
#[test]
fn save_clears_the_dirty_flag() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Java("D:/jdk21/bin/java.exe".to_string()));
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Save);
assert!(!app.launch_form.dirty);
}
}
@@ -88,6 +88,8 @@ impl App {
self.view_dirty = true;
self.refresh_view();
}
// M157:实例页「启动参数」区(编辑写进页面状态;保存才派发命令)
pages::Message::Instances(message) => return self.on_instance_message(message),
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
pages::Message::Account(message) => return self.on_account(message),
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
+2
View File
@@ -92,6 +92,8 @@ impl App {
},
// M106:设置页 General 组要画「当前档位 + 生效语言」。
self.language(),
// M157:实例页「启动参数」区的表单(编辑态归 `App`,页面只借一帧)。
&self.launch_form,
)
.map(Message::Page),
agent::docked(&self.ui, session, agent_catalog, self.width)
+12 -1
View File
@@ -21,7 +21,7 @@
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
use crate::pages::{Detail, Meta};
use crate::pages::{Detail, LaunchFacts, Meta};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
@@ -135,6 +135,17 @@ pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail
instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
),
// M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照,
// 页面不复制任何一条判据;表单值在 `App::launch_form`)。
launch: LaunchFacts {
allowed: instance.launch.graphics_backend_allowed,
mc_version: instance.launch.mc_version.clone(),
min_version: instance.launch.graphics_backend_min_version.clone(),
memory_effective_mb: instance.launch.memory_effective_mb,
java_effective: instance.launch.java_effective.clone(),
backend_effective: instance.launch.graphics_backend_effective.clone(),
options: instance.launch.graphics_backend_options.clone(),
},
}
}
+1
View File
@@ -22,6 +22,7 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
launch: unlucky_presenter::dto::LaunchArgsView::default(),
}
}
@@ -223,6 +223,7 @@ fn can_install_needs_a_selection_and_no_blockers() {
isolation: "isolated".to_string(),
version_id: "1.20.1".to_string(),
version_present: true,
launch: unlucky_presenter::dto::LaunchArgsView::default(),
}];
let catalog = Lang::En.catalog();
let page = PageState {
+5 -1
View File
@@ -62,6 +62,7 @@ use crate::renderer;
use crate::shot;
use crate::theme::Ui;
use super::instances;
use super::{app_with_appearance, demo_model, Model};
mod app;
@@ -118,6 +119,8 @@ struct Harness {
model: Model,
/// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。
appearance: Option<appearance::State>,
/// 实例页「启动参数」区的表单(M157):harness 没有 `App`,自己持有演示表单。
form: instances::LaunchForm,
window: Option<window::Id>,
/// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读,
/// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。
@@ -308,7 +311,8 @@ impl Harness {
}
fn view(&self) -> Element<'_, Message> {
app_with_appearance(&self.ui, &self.model, self.appearance.as_ref()).map(Message::Page)
app_with_appearance(&self.ui, &self.model, self.appearance.as_ref(), &self.form)
.map(Message::Page)
}
}
+1
View File
@@ -144,6 +144,7 @@ pub fn run(request: Request) -> Result<(), String> {
ui: Ui::new(boot.flavor, families),
model,
appearance: appearance.clone(),
form: crate::pages::demo_launch_form(),
request: boot.clone(),
window: None,
hwnd: None,
+12 -4
View File
@@ -48,6 +48,8 @@
mod data;
mod demo;
mod detail;
mod launch_args;
mod state;
mod text;
#[cfg(test)]
@@ -55,7 +57,8 @@ mod tests;
#[cfg(test)]
pub(crate) use data::{DEMO, NOTE, SELECTED};
pub(super) use demo::demo;
pub(crate) use demo::{demo, demo_form};
pub use state::{LaunchForm, Message as InstanceMessage};
pub(super) use text::Texts;
use iced::widget::{column, container, row, scrollable};
@@ -97,7 +100,12 @@ pub(super) const LIST_BOTTOM_INSET: Space = Space::X2;
///
/// 文案全部走 [`Texts`](`model.lang` 的语言):这个函数是实例页唯一的取词入口,
/// 页面里不留任何可见英文字面量(守门见 `instances/tests/i18n.rs`)。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<'a, Message> {
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Model,
form: &'a LaunchForm,
) -> Element<'a, Message> {
let block = &model.instances;
let forced = model.forced;
let texts = Texts::of(model.lang.catalog());
@@ -123,12 +131,12 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<
metrics,
list_column(ui, block, forced, texts),
match &block.detail {
Some(detail) => detail::detail_column(ui, metrics, detail, forced, texts),
Some(detail) => detail::detail_column(ui, metrics, detail, form, forced, texts),
None => detail::detail_empty(ui, texts),
},
),
_ => match &block.detail {
Some(detail) => detail::narrow_detail(ui, metrics, detail, forced, texts),
Some(detail) => detail::narrow_detail(ui, metrics, detail, form, forced, texts),
None => frame::body(metrics, detail::detail_empty(ui, texts)),
},
};
+61 -1
View File
@@ -4,9 +4,11 @@
//! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::LaunchArgsView;
use super::super::model::{Detail, Instances, Meta, Row};
use super::super::model::{Detail, Instances, LaunchFacts, Meta, Row};
use super::data::{DEMO, NOTE, SELECTED};
use super::state::LaunchForm;
/// 演示路径的目录:**恒英文**。
///
@@ -51,6 +53,7 @@ pub(crate) fn demo() -> Instances {
status: None,
can_launch: true,
can_stop: false,
launch: launch_facts(),
};
Instances {
@@ -65,3 +68,60 @@ fn meta(label: &str, value: &str) -> Meta {
value: value.to_string(),
}
}
/// 启动参数区的演示事实(M157 的 D30 出图夹具)。
///
/// `ULL_LAUNCH_ARGS_FIXTURE=gated` 出「版本不够、三态灰化」那一屏;缺省(`open`)是
/// 「26.2 实例 + Vulkan 可选」的正常态。**这是出图夹具,不是产品逻辑**:产品路径的门槛
/// 来自 presenter 快照(core 的 `graphics_backend_allowed`),与这个环境变量无关。
pub(crate) fn launch_facts() -> LaunchFacts {
let gated = gated_fixture();
LaunchFacts {
allowed: !gated,
mc_version: if gated { "1.20.4" } else { "26.2-snapshot-2" }.to_string(),
min_version: "26.2-snapshot-2".to_string(),
memory_effective_mb: if gated { 2048 } else { 4096 },
java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(),
backend_effective: if gated { "default" } else { "vulkan" }.to_string(),
options: ["default", "opengl", "vulkan"]
.iter()
.map(|id| (*id).to_string())
.collect(),
}
}
/// 演示用的表单初值(页面状态的所有者是 `App`;harness 没有 `App`,自己持有一份)。
///
/// 返回**拥有**的表单而不是 `&'static`:`text_editor::Content` 内含 `RefCell`,不满足
/// `Sync`,做不成 `static`;harness/测试各自持有即可(一帧借用一次)。
pub(crate) fn demo_form() -> LaunchForm {
LaunchForm::from_view(Some(DEMO[SELECTED].name), &demo_view())
}
fn demo_view() -> LaunchArgsView {
let gated = gated_fixture();
LaunchArgsView {
instance_id: DEMO[SELECTED].name.to_string(),
jvm_args: "-XX:+UseG1GC".to_string(),
game_args: String::new(),
memory_mb: (!gated).then_some(4096),
memory_effective_mb: if gated { 2048 } else { 4096 },
java_path: String::new(),
java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(),
graphics_backend: if gated { "default" } else { "vulkan" }.to_string(),
graphics_backend_effective: if gated { "default" } else { "vulkan" }.to_string(),
graphics_backend_allowed: !gated,
mc_version: if gated { "1.20.4" } else { "26.2-snapshot-2" }.to_string(),
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
graphics_backend_options: ["default", "opengl", "vulkan"]
.iter()
.map(|id| (*id).to_string())
.collect(),
}
}
fn gated_fixture() -> bool {
std::env::var("ULL_LAUNCH_ARGS_FIXTURE")
.map(|value| value.trim().eq_ignore_ascii_case("gated"))
.unwrap_or(false)
}
@@ -23,6 +23,7 @@ use super::super::layout::{self, FrameMetrics};
use super::super::model::Detail;
use super::super::{Action, Message};
use super::forced_state;
use super::state::LaunchForm;
use super::Texts;
// --------------------------------------------------------------- 详情列
@@ -31,6 +32,7 @@ pub(super) fn detail_column<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
detail: &'a Detail,
form: &'a LaunchForm,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
@@ -43,6 +45,8 @@ pub(super) fn detail_column<'a>(
.width(Length::Fill);
body = push_text(ui, metrics, body, detail);
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)
frame::left_aligned(metrics.column, body.push(action_row(ui, forced, texts)))
@@ -52,6 +56,7 @@ pub(super) fn narrow_detail<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
detail: &'a Detail,
form: &'a LaunchForm,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
@@ -74,6 +79,7 @@ pub(super) fn narrow_detail<'a>(
.width(Length::Fill);
body = push_text(ui, metrics, body, detail);
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
body = body.push(frame::text_role(
ui,
@@ -0,0 +1,218 @@
//! 实例详情页的「启动参数」区(M157)。
//!
//! | 控件 | 语义 | 消息 |
//! |---|---|---|
//! | JVM 参数(多行) | 实例层,与全局 `java.extra_args` **叠加**(实例段最高优先级) | [`Message::Jvm`] |
//! | 游戏参数(多行) | 实例层,追加在命令行最后 | [`Message::Game`] |
//! | 最大内存(单行数字) | 实例层;留空 = 跟随全局默认 | [`Message::Memory`] |
//! | Java 可执行文件(单行) | 实例层;留空 = 跟随全局 → 自动挑 | [`Message::Java`] |
//! | 图形后端(三态 chips) | `default` / `opengl` / `vulkan`;**版本不够时整体灰化 + 提示** | [`Message::Backend`] |
//!
//! 两条判据刻意不在这里重算:
//! 1. **能不能用 `--graphicsBackend`** 来自 [`LaunchFacts::allowed`](core 的 `graphics_backend_allowed`,
//! presenter 的 host 已经算好)——UI 不复制版本号比较;
//! 2. **生效值**(内存/Java/后端)同样来自 facts,表单里画的是实例层原文。
//!
//! 文案全走 [`Texts`](守门见 `instances/tests/i18n.rs` 的白名单与死 key 扫描)。
use iced::widget::{column, container, row};
use iced::{Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::super::model::LaunchFacts;
use super::super::Message as PageMessage;
use super::state::{LaunchForm, Message};
use super::Texts;
/// 页内消息 → 页面消息(本文件的控件树统一画 `pages::Message`)。
fn page(message: Message) -> PageMessage {
PageMessage::Instances(message)
}
/// 「启动参数」区(在详情列的正文段之后、动作行之前)。
pub(super) fn section<'a>(
ui: &'a Ui,
facts: &'a LaunchFacts,
form: &'a LaunchForm,
texts: Texts,
) -> Element<'a, PageMessage> {
let mut body = column![frame_title(ui, texts.launch_title())]
.spacing(ui.space(Space::X3))
.width(Length::Fill);
body = body.push(args_field(
ui,
texts.launch_jvm(),
texts.launch_jvm_hint(),
form.jvm.as_str(),
|value| page(Message::Jvm(value)),
));
body = body.push(args_field(
ui,
texts.launch_game(),
texts.launch_game_hint(),
form.game.as_str(),
|value| page(Message::Game(value)),
));
body = body.push(text_field_with_hint(
ui,
texts.launch_memory(),
form.memory.as_str(),
super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_memory_hint(facts.memory_effective_mb),
ui.color(Role::OnSurfaceVariant),
),
|value| page(Message::Memory(value)),
));
body = body.push(text_field_with_hint(
ui,
texts.launch_java(),
form.java.as_str(),
super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_java_hint().to_string(),
ui.color(Role::OnSurfaceVariant),
),
|value| page(Message::Java(value)),
));
body = body.push(backend_row(ui, facts, form, texts));
body = body.push(actions_row(ui, texts));
container(body)
.width(Length::Fill)
.padding(ui.space(Space::X3))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: ui.outlined(ShapeScale::Medium),
..container::Style::default()
})
.into()
}
fn frame_title<'a>(ui: &'a Ui, title: &'a str) -> Element<'a, PageMessage> {
super::super::frame::text_role(
ui,
TypeRole::LabelMedium,
true,
title,
ui.color(Role::OnSurfaceVariant),
)
}
/// 一段参数输入 + 一行提示(单行受控输入;参数按空白切分,与 core 的 `tokenize` 同口径)。
fn args_field<'a>(
ui: &'a Ui,
label: &'a str,
hint: &'a str,
value: &'a str,
on_input: impl Fn(String) -> PageMessage + 'a,
) -> Element<'a, PageMessage> {
column![
text_field(ui, Field::new(value).label(label), on_input),
super::super::frame::text_role(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 单行输入 + 一行提示(提示是**拥有的**字符串,所以不能走 `Field::supporting` 的借用口径)。
fn text_field_with_hint<'a>(
ui: &'a Ui,
label: &'a str,
value: &'a str,
hint: Element<'a, PageMessage>,
on_input: impl Fn(String) -> PageMessage + 'a,
) -> Element<'a, PageMessage> {
column![
text_field(ui, Field::new(value).label(label), on_input),
hint,
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 三态后端:版本不够时整体禁用(chips 不带 `on_press`)+ 一行门槛提示。
fn backend_row<'a>(
ui: &'a Ui,
facts: &'a LaunchFacts,
form: &'a LaunchForm,
texts: Texts,
) -> Element<'a, PageMessage> {
let allowed = facts.allowed;
let mut chips = row![].spacing(ui.space(Space::X2));
for id in &facts.options {
let label = texts.backend_label(id).unwrap_or(id.as_str());
let selected = form.backend == *id;
let spec = if allowed {
Chip::filter(label, selected, page(Message::Backend(id.clone())))
} else {
Chip::filter(label, selected, page(Message::Backend(id.clone()))).disabled()
};
chips = chips.push(chip(ui, spec));
}
let mut meta = column![
super::super::frame::text_role(
ui,
TypeRole::BodySmall,
false,
texts.launch_backend(),
ui.color(Role::OnSurface),
),
chips,
super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_effective(&facts.backend_effective),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !allowed {
// 灰化 + 明确提示:既不说「已生效」,也不静默吞掉用户的选择。
meta = meta.push(super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_backend_gated(&facts.mc_version),
ui.color(Role::Error),
));
}
meta.into()
}
fn actions_row<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, PageMessage> {
row![
buttons::filled(ui, Button::new(texts.launch_save(), page(Message::Save))),
buttons::text(ui, Button::new(texts.launch_reset(), page(Message::Reset))),
]
.spacing(ui.space(Space::X2))
.into()
}
@@ -0,0 +1,190 @@
//! 实例页的**页面状态**(M157):启动参数区的表单。
//!
//! ## 为什么状态在 app、页面只借一帧
//!
//! iced 的 `view` 不能持有跨帧状态,而启动参数区是可编辑的(两个多行框 + 两行单行框 +
//! 三态 chips)。所以表单状态的所有者是 `App`(`app/state.rs` 的 `launch_form` 字段),
//! 页面层只在这一帧借用;这与设置页「组状态归 `App`」是同一条纪律(`pages/settings/state.rs`)。
//!
//! ## 与快照的关系(唯一的重播种点)
//!
//! [`LaunchForm::sync`] 由 `App::refresh_view` 调用:
//!
//! | 情况 | 行为 |
//! |---|---|
//! | 换了一个实例 | **重播种**(用户没写完的草稿不能串到别的实例上) |
//! | 同一个实例、没改过 | 重播种(保存/外部改了配置后跟着刷新) |
//! | 同一个实例、改过(`dirty`) | **不覆盖**(每来一条事件就把输入抹掉是不可接受的) |
//!
//! 「留空 = 跟随下一层」由提交时把空串原样交给 host 表达(`Some("")` = 清空),
//! 表单自己不做任何回落计算——回落只发生在 core。
use unlucky_presenter::dto::{LaunchArgsPatch, LaunchArgsView};
/// 启动参数表单(五项 + 脏标记 + 绑定的实例 id)。
#[derive(Debug)]
pub struct LaunchForm {
/// 这份草稿属于哪个实例(`None` = 还没播种)。
pub bound: Option<String>,
/// 实例级 JVM 参数(空白分隔的文本)。
pub jvm: String,
/// 实例级游戏参数(空白分隔的文本)。
pub game: String,
/// 最大堆(文本形态,空 = 跟随全局)。
pub memory: String,
/// Java 可执行文件(空 = 跟随全局 / 自动挑)。
pub java: String,
/// 图形后端(`default` / `opengl` / `vulkan`)。
pub backend: String,
/// 用户改过、还没保存。
pub dirty: bool,
}
impl Default for LaunchForm {
fn default() -> Self {
Self::from_view(None, &LaunchArgsView::default())
}
}
impl LaunchForm {
/// 用一份快照播种(`bound` 为 `None` = 还没绑定实例)。
#[must_use]
pub fn from_view(bound: Option<&str>, view: &LaunchArgsView) -> Self {
Self {
bound: bound.map(str::to_string),
jvm: view.jvm_args.clone(),
game: view.game_args.clone(),
memory: view.memory_mb.map(|mb| mb.to_string()).unwrap_or_default(),
java: view.java_path.clone(),
backend: view.graphics_backend.clone(),
dirty: false,
}
}
/// 这份草稿是否正对着该实例。
#[must_use]
pub fn bound_to(&self, id: &str) -> bool {
self.bound.as_deref() == Some(id)
}
/// 重播种(见模块文档的三档)。
pub fn sync(&mut self, view: &LaunchArgsView) {
let same_instance = self.bound_to(&view.instance_id);
if same_instance && self.dirty {
return;
}
*self = Self::from_view(Some(&view.instance_id), view);
}
/// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。
#[must_use]
pub fn patch(&self) -> LaunchArgsPatch {
LaunchArgsPatch {
jvm_args: Some(self.jvm.clone()),
game_args: Some(self.game.clone()),
memory_mb: Some(self.memory.clone()),
java_path: Some(self.java.clone()),
graphics_backend: Some(self.backend.clone()),
}
}
/// 「恢复跟随全局」:五项清空,脏标记置位(等用户点保存)。
pub fn reset(&mut self) {
self.jvm.clear();
self.game.clear();
self.memory.clear();
self.java.clear();
self.backend = "default".to_string();
self.dirty = true;
}
}
/// 启动参数区的消息(`pages::Message::Instances` 的载荷)。
///
/// 只表达**页面意图**:编辑写进表单,保存/恢复才需要 app 层理解(前者 dispatch 一条
/// `AppCommand::SetInstanceLaunchArgs`,后者只是清表单)。
#[derive(Debug, Clone)]
pub enum Message {
/// 编辑 JVM 参数。
Jvm(String),
/// 编辑游戏参数。
Game(String),
/// 编辑最大内存(单行文本)。
Memory(String),
/// 编辑 Java 路径(单行文本)。
Java(String),
/// 选图形后端(三态 chips)。
Backend(String),
/// 保存(派发命令)。
Save,
/// 恢复跟随全局(清表单,仍需保存)。
Reset,
}
#[cfg(test)]
mod tests {
use super::*;
fn view(instance: &str, jvm: &str, memory: Option<u32>, backend: &str) -> LaunchArgsView {
LaunchArgsView {
instance_id: instance.to_string(),
jvm_args: jvm.to_string(),
game_args: String::new(),
memory_mb: memory,
memory_effective_mb: memory.unwrap_or(2048),
java_path: String::new(),
java_effective: String::new(),
graphics_backend: backend.to_string(),
graphics_backend_effective: backend.to_string(),
graphics_backend_allowed: true,
mc_version: "26.2".to_string(),
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
graphics_backend_options: vec![
"default".to_string(),
"opengl".to_string(),
"vulkan".to_string(),
],
}
}
/// 换实例必重播种;同实例的脏草稿不被事件冲掉。
#[test]
fn sync_reseeds_per_instance_but_keeps_dirty_edits() {
let mut form = LaunchForm::default();
form.sync(&view("a", "-Xss4m", Some(4096), "vulkan"));
assert_eq!(form.jvm, "-Xss4m");
assert_eq!(form.memory, "4096");
assert!(!form.dirty);
// 用户改动 → 脏 → 同实例的快照刷新不许覆盖。
form.memory = "8192".to_string();
form.dirty = true;
form.sync(&view("a", "-Xss4m", Some(4096), "vulkan"));
assert_eq!(form.memory, "8192", "脏草稿被事件冲掉了");
// 换实例 → 重播种(草稿不串台)。
form.sync(&view("b", "", None, "default"));
assert_eq!(form.memory, "");
assert!(!form.dirty);
assert!(form.bound_to("b"));
}
/// 补丁五项全给:空串就是「清空」,不留 `None` 让 host 猜。
#[test]
fn patch_always_covers_all_five_fields() {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", None, "default"));
form.memory = "6144".to_string();
form.backend = "opengl".to_string();
let patch = form.patch();
assert_eq!(patch.jvm_args.as_deref(), Some(""));
assert_eq!(patch.memory_mb.as_deref(), Some("6144"));
assert_eq!(patch.graphics_backend.as_deref(), Some("opengl"));
form.reset();
assert!(form.dirty);
let patch = form.patch();
assert_eq!(patch.jvm_args.as_deref(), Some(""));
assert_eq!(patch.memory_mb.as_deref(), Some(""));
assert_eq!(patch.graphics_backend.as_deref(), Some("default"));
}
}
@@ -28,14 +28,18 @@ use super::super::Texts;
///
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
const ALLOWED: &[&str] = &["Java", "pid"];
const ALLOWED: &[&str] = &["Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB"];
/// 页面取词的五个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 5] = [
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 6] = [
("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")),
("instances/text.rs", include_str!("../text.rs")),
(
"instances/launch_args.rs",
include_str!("../launch_args.rs"),
),
(
"app/view/instances.rs",
include_str!("../../../app/view/instances.rs"),
@@ -57,13 +61,29 @@ const PAGE_SOURCES: [(&str, &str); 5] = [
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
const ALLOWED_LITERALS: [&str; 6] = [
const ALLOWED_LITERALS: [&str; 17] = [
"{date} {time} UTC",
"isolated",
"shared",
"pid",
"reason",
"Instances",
// M157:图形后端三态的**稳定 id**(与 core 的 `GraphicsBackend::IDS` 同一份词表),
// `backend_label` 只认这三档,其余原样上屏。
"default",
"opengl",
"vulkan",
// M157:`Catalog::render` 的占位符名(`{version}` / `{value}` / `{default}`),不是文案。
"version",
"value",
// M157:`instances/demo.rs` 的**出图夹具**(用户数据的形状,不是界面文案):
// 一个示例 JVM 参数、两个示例 MC 版本、一个示例 Java 路径,以及夹具开关的环境变量名与取值。
"-XX:+UseG1GC",
"26.2-snapshot-2",
"1.20.4",
"C:/Java/temurin-21/bin/java.exe",
"ULL_LAUNCH_ARGS_FIXTURE",
"gated",
];
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
@@ -248,9 +268,13 @@ fn every_english_literal_in_the_page_sources_is_whitelisted() {
"text.rs 没有引用本族的 key"
);
assert!(
PAGE_SOURCES[4].1.contains("Key::InstancesStage"),
PAGE_SOURCES[5].1.contains("Key::InstancesStage"),
"阶段标签没有走目录"
);
assert!(
PAGE_SOURCES[4].1.contains("texts.launch_title()"),
"启动参数区没有走目录"
);
}
/// **死 key 守门**:目录里每一条 `Instances*` 都必须被页面(或产品快照转换)真的引用。
@@ -85,6 +85,67 @@ impl Texts {
self.text(Key::InstancesNarrowNote)
}
// ------------------------------------------------------------- 启动参数(M157)
pub(super) fn launch_title(self) -> &'static str {
self.text(Key::InstancesLaunchTitle)
}
pub(super) fn launch_jvm(self) -> &'static str {
self.text(Key::InstancesLaunchJvm)
}
pub(super) fn launch_jvm_hint(self) -> &'static str {
self.text(Key::InstancesLaunchJvmHint)
}
pub(super) fn launch_game(self) -> &'static str {
self.text(Key::InstancesLaunchGame)
}
pub(super) fn launch_game_hint(self) -> &'static str {
self.text(Key::InstancesLaunchGameHint)
}
pub(super) fn launch_memory(self) -> &'static str {
self.text(Key::InstancesLaunchMemory)
}
/// 「留空 = 跟随全局默认({default} MiB)」,`default` 是生效值(三级回落之后)。
pub(super) fn launch_memory_hint(self, default_mb: u32) -> String {
self.0.render(
Key::InstancesLaunchMemoryHint,
&[("default", &default_mb.to_string())],
)
}
pub(super) fn launch_java(self) -> &'static str {
self.text(Key::InstancesLaunchJava)
}
pub(super) fn launch_java_hint(self) -> &'static str {
self.text(Key::InstancesLaunchJavaHint)
}
pub(super) fn launch_backend(self) -> &'static str {
self.text(Key::InstancesLaunchBackend)
}
/// 「需游戏版本 26.2 及以上(本实例 1.20.4)」。
pub(super) fn launch_backend_gated(self, version: &str) -> String {
self.0
.render(Key::InstancesLaunchBackendGated, &[("version", version)])
}
/// 「生效:vulkan」。
pub(super) fn launch_effective(self, value: &str) -> String {
self.0
.render(Key::InstancesLaunchEffective, &[("value", value)])
}
/// 三态后端的显示名(稳定 id → 文案);**未知 id 返回 `None`**(调用方原样上屏,不猜)。
pub(super) fn backend_label(self, id: &str) -> Option<&'static str> {
Some(self.text(match id {
"default" => Key::InstancesBackendDefault,
"opengl" => Key::InstancesBackendOpengl,
"vulkan" => Key::InstancesBackendVulkan,
_ => return None,
}))
}
pub(super) fn launch_save(self) -> &'static str {
self.text(Key::InstancesLaunchSave)
}
pub(super) fn launch_reset(self) -> &'static str {
self.text(Key::InstancesLaunchReset)
}
fn text(self, key: Key) -> &'static str {
self.0.text(key)
}
+2
View File
@@ -91,6 +91,8 @@ pub enum Message {
/// 加一条内容库消息只动 `pages/content/message.rs`,不动本枚举的正文,也不动
/// `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。
Content(content::Message),
/// 实例页「启动参数」区的消息(**M157**):编辑写进页面状态,保存才 dispatch。
Instances(super::instances::InstanceMessage),
/// 账户页消息(**M114**:三轨登录 / 列表动作 / 表单输入;app 层翻成 `AppCommand`)。
///
/// 与 `Settings` 同一种做法:账户页自己的消息枚举在 `pages/account.rs`,这里只加一支入口。
+23 -6
View File
@@ -126,10 +126,12 @@ pub(crate) mod settings;
#[cfg(test)]
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 layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message};
pub use model::{Detail, Instances, Meta, Model, Row};
pub use model::{Detail, Instances, LaunchFacts, Meta, Model, Row};
pub use screen::Screen;
pub use settings::Language;
@@ -150,7 +152,11 @@ use frame::filled;
///
/// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用:
/// `view(&self)` 不能返回一棵借用局部变量的树。
pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
pub fn page<'a>(
ui: &'a Ui,
model: &'a Model,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(
ui,
model,
@@ -159,6 +165,7 @@ pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
groups: settings::demo_groups(),
},
Language::default(),
form,
)
}
@@ -174,6 +181,7 @@ pub fn page_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: &'a appearance::State,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(
ui,
@@ -183,6 +191,7 @@ pub fn page_with_appearance<'a>(
groups: settings::demo_groups(),
},
Language::default(),
form,
)
}
@@ -199,8 +208,9 @@ pub fn page_with_settings<'a>(
model: &'a Model,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(ui, model, states, language)
render(ui, model, states, language, launch_form)
}
fn render<'a>(
@@ -208,11 +218,12 @@ fn render<'a>(
model: &'a Model,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
match model.screen {
Screen::Instances => instances::view(ui, metrics, model),
Screen::Instances => instances::view(ui, metrics, model, launch_form),
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
// M109:内容库页同样跟 `model.lang`
@@ -227,8 +238,12 @@ fn render<'a>(
/// 产品路径**不走这里**(它走 [`page`] + `shell::view_with_content`,用真标题栏)。这个入口留给
/// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的
/// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
app_with_appearance(ui, model, None)
pub fn app<'a>(
ui: &'a Ui,
model: &'a Model,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
app_with_appearance(ui, model, None, form)
}
/// 同 [`app`],但可以带上**外观运行期状态**。
@@ -241,6 +256,7 @@ pub fn app_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: Option<&'a appearance::State>,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
// harness 路径没有真组状态:用演示状态([`settings::demo_groups`]),外观状态照传。
let body = render(
@@ -251,6 +267,7 @@ pub fn app_with_appearance<'a>(
groups: settings::demo_groups(),
},
Language::default(),
form,
);
let shell = row![nav::rail(ui, model.active), body]
+24
View File
@@ -64,6 +64,30 @@ pub struct Detail {
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>,
}
/// 实例页要画的东西。
+4 -2
View File
@@ -54,10 +54,11 @@ fn screens_declare_the_layout_their_page_needs() {
#[test]
fn every_screen_builds_at_the_three_comparison_widths() {
let ui = ui();
let form = demo_launch_form();
for width in [1180.0, 900.0, 700.0] {
for screen in Screen::ALL {
let model = demo_model(screen, width);
let _element: Element<'_, Message> = app(&ui, &model);
let _element: Element<'_, Message> = app(&ui, &model, &form);
}
}
}
@@ -66,9 +67,10 @@ fn every_screen_builds_at_the_three_comparison_widths() {
#[test]
fn an_empty_snapshot_still_builds() {
let ui = ui();
let form = demo_launch_form();
for screen in Screen::ALL {
let model = Model::new(screen, 1180.0, Some(screen.nav()));
let _element: Element<'_, Message> = app(&ui, &model);
let _element: Element<'_, Message> = app(&ui, &model, &form);
}
}