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:
1 parent
d68523b02a
commit
e2021645d7
56 files changed
+3776
-607
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)**:面板 α 只作用到允许半透明的层。
|
||||
|
||||
@@ -48,6 +48,7 @@ mod boot_env;
|
||||
mod clock;
|
||||
mod content;
|
||||
mod download;
|
||||
mod instances;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
|
||||
|
||||
@@ -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 模块文档的偏离登记)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`,这里只加一支入口。
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
/// 实例页要画的东西。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user