OneLauncher-Cracked/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs
Maciek 0ab544f265
feat: custom cluster instances (#859)
* feat: added adding new instances of a cluster with 'start from scratch' option enabling user to create a vanilla/modded cluster; fixed couple pre-existing bugs that wouldn't let other loaders boot up

* feat: fully implemented cluster creation modal design

* chore: refactoring

* feat: expanded the tags section on customization step; found that fabric doesnt work with 1.8.9

* feat: added gallery covers handling for new cluster instances

* feat: added 'redirecting' from fabric to orinthe on versions pre-1.14

* feat: added java-version-override

* feat: added loader types migrations (right now they dont migrate mods because there are no bundles)

* chore: wider loader dropdown for multiloader cluster (if it will be used in the future)

* chore: fixed more issues with isolated and shared dir, with migrations and instances naming

* chore: refactor; taken the loader version dropdown from version picker view back to loader selector

* chore: deleted Quilt from UI

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-29 19:08:21 +02:00

1014 lines
32 KiB
Rust

use std::sync::Arc;
use freya::prelude::*;
use oneclient_common::Patch;
use oneclient_common::domain::GameLoader;
use oneclient_core::settings::{GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution};
use oneclient_java::JavaRuntime;
use crate::components::{
Button, Dropdown, Icon, IconType, ScrollArea, TextInput, memory_field, toggle,
toggle_controlled, validate_number,
};
use crate::hooks::{
ClusterAction, java_runtimes, loader_versions, mutation_is_running, query_error,
settled_or_loading, try_game_profile, use_cluster_mutation, use_clusters, use_dispatch,
use_game_profile, use_game_snapshot, use_java_runtimes, use_loader_versions,
use_release_migration_checking, use_settings_snapshot,
};
use crate::layout::cluster_content;
use crate::theme::colors;
use crate::ui::centered_note;
use crate::view::app::clusters::{DeleteInstanceModal, EditInstanceModal, InstanceFacts};
use crate::view::app::settings::{section_header, settings_row, settings_row_disabled};
use oneclient_core::clusters::rank_migration_sources;
use super::cluster_not_found;
use crate::hooks::use_cluster;
#[derive(PartialEq)]
pub struct ClusterSettings {
pub cluster_id: i64,
}
impl Component for ClusterSettings {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let global = use_settings_snapshot().settings.global_game_settings;
let cluster = use_cluster(cluster_id);
let profile_name = cluster
.as_ref()
.and_then(|c| c.setting_profile_name.clone());
let profile_query = use_game_profile(profile_name);
let mc_version = cluster
.as_ref()
.map(|c| c.mc_version.clone())
.unwrap_or_default();
let loader = cluster
.as_ref()
.map(|c| c.mc_loader)
.unwrap_or(GameLoader::Fabric);
let versions_query = use_loader_versions(mc_version, loader);
let runtimes_query = use_java_runtimes();
let Some(cluster) = cluster else {
return cluster_not_found();
};
let Some(profile) = try_game_profile(&profile_query) else {
let note = match query_error(&profile_query) {
Some(err) => format!("Could not load these settings: {err}"),
None => "Loading settings...".to_string(),
};
return cluster_content().child(centered_note(&note)).into_element();
};
let versions = loader_versions(&versions_query);
let runtimes = java_runtimes(&runtimes_query);
let instance_section: Vec<Element> = if cluster.user_created {
vec![
section_header("INSTANCE").into_element(),
InstanceRow {
facts: InstanceFacts {
cluster_id,
name: cluster.name.clone(),
description: cluster.description.clone(),
tags: cluster.tags.clone(),
cover: cluster.cover_file(),
mc_version: cluster.mc_version.clone(),
mc_loader: cluster.mc_loader,
kind: cluster.kind,
},
}
.into_element(),
]
} else {
Vec::new()
};
let mod_loader = !cluster.lacks_mod_loader();
let loader_section: Vec<Element> = if mod_loader {
vec![
section_header("LOADER").into_element(),
LoaderRow {
cluster_id,
loader,
selected: cluster.mc_loader_version.clone(),
versions,
locked: cluster.user_created,
}
.into_element(),
]
} else {
Vec::new()
};
let migrate_row: Vec<Element> = if mod_loader {
vec![MigrateFromRow { cluster_id }.into_element()]
} else {
Vec::new()
};
cluster_content()
.child(
ScrollArea::new()
.width(Size::fill())
.height(Size::fill())
.scrollbar_gutter(true)
.spacing(4.)
.children(instance_section)
.child(section_header("GAME"))
.child(
ToggleRow {
cluster_id,
value: profile.force_fullscreen,
global: global.force_fullscreen.unwrap_or(false),
}
.into_element(),
)
.child(
ResolutionRow {
cluster_id,
value: profile.resolution,
global: global.resolution.unwrap_or_default(),
}
.into_element(),
)
.child(
MemoryRow {
cluster_id,
value: profile.mem_max,
global: global
.mem_max
.unwrap_or_else(oneclient_common::default_mem_max),
}
.into_element(),
)
.append_children(loader_section)
.child(section_header("DIRECTORY"))
.child(
DedicatedDirRow {
cluster_id,
dedicated: cluster.uses_dedicated_dir(),
locked: cluster.is_isolated(),
}
.into_element(),
)
.child(section_header("SHORTCUT"))
.child(ShortcutRow { cluster_id }.into_element())
.child(section_header("JAVA"))
.child(
JavaRow {
cluster_id,
value: profile.java_path.clone(),
global: global.java_path.clone(),
runtimes,
}
.into_element(),
)
.child(text_row(cluster_id, TextField::JvmArgs, &profile, &global))
.child(section_header("PROCESS"))
.child(text_row(cluster_id, TextField::Pre, &profile, &global))
.child(text_row(cluster_id, TextField::Wrapper, &profile, &global))
.child(text_row(cluster_id, TextField::Post, &profile, &global))
.child(section_header("CONTENT"))
.child(
BrowserUpdateModeRow {
cluster_id,
value: profile.browser_update_mode,
global: global.browser_update_mode.unwrap_or_default(),
}
.into_element(),
)
.append_children(migrate_row)
.child(section_header("REPAIR"))
.child(VerifyFilesRow { cluster_id }.into_element()),
)
.into_element()
}
}
#[derive(Clone, Copy, PartialEq)]
enum Field {
ForceFullscreen,
Resolution,
MemMax,
JavaPath,
BrowserUpdateMode,
}
fn clear_update(field: Field) -> ProfileUpdate {
let mut u = ProfileUpdate::default();
match field {
Field::ForceFullscreen => u.force_fullscreen = Patch::Clear,
Field::Resolution => u.resolution = Patch::Clear,
Field::MemMax => u.mem_max = Patch::Clear,
Field::JavaPath => u.java_path = Patch::Clear,
Field::BrowserUpdateMode => u.browser_update_mode = Patch::Clear,
}
u
}
fn runtime_label(runtime: &JavaRuntime) -> String {
format!("{} {} ({})", runtime.vendor, runtime.major, runtime.version)
}
fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElement {
let color = if overridden {
colors::fg_secondary()
} else {
colors::fg_secondary().with_a(60)
};
Button::new()
.small()
.ghost()
.icon()
.tooltip(if overridden {
"Reset to the global setting"
} else {
"Already using the global setting"
})
.corner_radius(CornerRadius::new_all(7.))
.maybe(overridden, |el| el.on_press(move |_| on_reset.call(())))
.enabled(overridden)
.child(Icon::new(IconType::RefreshCcw02).size(15.).color(color))
.into_element()
}
fn override_cell(
control: impl IntoElement,
overridden: bool,
on_reset: EventHandler<()>,
) -> impl IntoElement {
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(10.)
.child(control)
.child(reset_button(overridden, on_reset))
.into_element()
}
#[derive(PartialEq)]
struct ToggleRow {
cluster_id: i64,
value: Option<bool>,
global: bool,
}
impl Component for ToggleRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let overridden = self.value.is_some();
let global = self.global;
let dispatch = use_dispatch();
let initial = self.value.unwrap_or(self.global);
let mut state = use_state(move || initial);
let mut last = use_state(move || initial);
{
let dispatch = dispatch.clone();
use_side_effect(move || {
let v = *state.read();
if v == *last.peek() {
return;
}
last.set(v);
dispatch.update_cluster_profile(
cluster_id,
ProfileUpdate {
force_fullscreen: Patch::Set(v),
..Default::default()
},
);
});
}
let on_reset: EventHandler<()> = (move |()| {
last.set(global);
state.set(global);
dispatch.update_cluster_profile(cluster_id, clear_update(Field::ForceFullscreen));
})
.into();
settings_row(
IconType::Maximize01,
"Force Fullscreen",
"Force Minecraft to start in fullscreen mode.",
override_cell(toggle(state), overridden, on_reset),
)
}
}
#[derive(PartialEq)]
struct InstanceRow {
facts: InstanceFacts,
}
impl Component for InstanceRow {
fn render(&self) -> impl IntoElement {
let mut editing = use_state(|| false);
let mut deleting = use_state(|| false);
let cluster_id = self.facts.cluster_id;
let name = self.facts.name.clone();
let facts = self.facts.clone();
let buttons = rect()
.horizontal()
.spacing(8.)
.child(
Button::new()
.small()
.secondary()
.on_press(move |_| editing.set(true))
.text("Edit"),
)
.child(
Button::new()
.small()
.danger()
.on_press(move |_| deleting.set(true))
.text("Delete"),
);
rect()
.vertical()
.width(Size::fill())
.child(settings_row(
IconType::Pencil01,
"Instance Details",
"Change this instance's name, description, tags and cover image, or remove it from your list.",
buttons,
))
.maybe_child(editing.read().then(|| {
EditInstanceModal::new(facts.clone(), move |()| editing.set(false))
.into_element()
}))
.maybe_child(deleting.read().then(|| {
DeleteInstanceModal::new(cluster_id, name.clone(), move |()| deleting.set(false))
.into_element()
}))
}
}
#[derive(PartialEq)]
struct ShortcutRow {
cluster_id: i64,
}
impl Component for ShortcutRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let dispatch = use_dispatch();
let button = Button::new()
.small()
.secondary()
.on_press(move |_| dispatch.create_cluster_shortcut(cluster_id))
.text("Create Shortcut");
settings_row(
IconType::Rocket02,
"Desktop Shortcut",
"Save a shortcut that starts this version straight from your desktop, \
without opening the launcher first.",
button,
)
}
}
#[derive(PartialEq)]
struct VerifyFilesRow {
cluster_id: i64,
}
impl Component for VerifyFilesRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let mutation = use_cluster_mutation();
let running = mutation_is_running(&mutation);
let on_press = move |_| {
mutation.mutate(ClusterAction::VerifyFiles { cluster_id });
};
// The pass reads every installed file so say it takes a while otherwise the button reads as broken
let button = Button::new()
.small()
.secondary()
.enabled(!running)
.maybe(!running, |el| el.on_press(on_press))
.text(if running {
"Verifying..."
} else {
"Verify Files"
});
settings_row(
IconType::ClipboardCheck,
"Verify Files",
"Check every installed file against its official checksum and \
re-download anything corrupt or missing. Takes a few minutes.",
button,
)
}
}
#[derive(PartialEq)]
struct DedicatedDirRow {
cluster_id: i64,
dedicated: bool,
locked: bool,
}
impl Component for DedicatedDirRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let dedicated = self.dedicated;
let mutation = use_cluster_mutation();
if self.locked {
return settings_row_disabled(
IconType::Folder,
"Dedicated Directory",
"Vanilla and modded instances always run in their own folder, so their worlds, settings and packs stay separate.",
toggle_controlled(true, (|()| {}).into()),
)
.into_element();
}
let on_toggle: EventHandler<()> = (move |()| {
mutation.mutate(ClusterAction::SetDedicatedDir {
cluster_id,
dedicated: !dedicated,
});
})
.into();
settings_row(
IconType::Folder,
"Dedicated Directory",
"Run this cluster in its own .minecraft instead of the shared one.",
toggle_controlled(dedicated, on_toggle),
)
.into_element()
}
}
#[derive(PartialEq)]
struct MemoryRow {
cluster_id: i64,
value: Option<u32>,
global: u32,
}
impl Component for MemoryRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let overridden = self.value.is_some();
let dispatch = use_dispatch();
let global = self.global;
let initial = self.value.map(|v| v.to_string()).unwrap_or_default();
let mut memory = use_state({
let v = initial.clone();
move || v
});
let mut last = use_state(move || initial);
{
let dispatch = dispatch.clone();
use_side_effect(move || {
let raw = memory.read().clone();
if raw == *last.peek() {
return;
}
last.set(raw.clone());
let mem_max = match raw.trim() {
"" => Patch::Clear,
m => m.parse::<u32>().map(Patch::Set).unwrap_or(Patch::Unchanged),
};
dispatch.update_cluster_profile(
cluster_id,
ProfileUpdate {
mem_max,
..Default::default()
},
);
});
}
let on_reset: EventHandler<()> = (move |()| {
last.set(String::new());
memory.set(String::new());
dispatch.update_cluster_profile(cluster_id, clear_update(Field::MemMax));
})
.into();
let control = memory_field(memory, "Global", global);
settings_row(
IconType::Database01,
"Memory",
"The amount of memory in megabytes allocated for the game. Presets leave 2 GB for the system.",
override_cell(control, overridden, on_reset),
)
}
}
#[derive(PartialEq)]
struct ResolutionRow {
cluster_id: i64,
value: Option<Resolution>,
global: Resolution,
}
impl Component for ResolutionRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let overridden = self.value.is_some();
let dispatch = use_dispatch();
let global = self.global;
let resolved = self.value.unwrap_or(self.global);
let mut width = use_state({
let v = resolved.width.to_string();
move || v
});
let mut height = use_state({
let v = resolved.height.to_string();
move || v
});
let mut last = use_state(move || (resolved.width.to_string(), resolved.height.to_string()));
{
let dispatch = dispatch.clone();
use_side_effect(move || {
let w = width.read().clone();
let h = height.read().clone();
if (w.clone(), h.clone()) == *last.peek() {
return;
}
last.set((w.clone(), h.clone()));
let resolution = match (w.trim(), h.trim()) {
("", "") => Patch::Clear,
(w, h) => match (w.parse::<u32>(), h.parse::<u32>()) {
(Ok(w), Ok(h)) => Patch::Set(Resolution::new(w, h)),
_ => Patch::Unchanged,
},
};
dispatch.update_cluster_profile(
cluster_id,
ProfileUpdate {
resolution,
..Default::default()
},
);
});
}
let on_reset: EventHandler<()> = (move |()| {
let gw = global.width.to_string();
let gh = global.height.to_string();
last.set((gw.clone(), gh.clone()));
width.set(gw);
height.set(gh);
dispatch.update_cluster_profile(cluster_id, clear_update(Field::Resolution));
})
.into();
let control = rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.child(
TextInput::new(width)
.placeholder("854")
.on_validate(validate_number)
.text_align(TextAlign::Center)
.width(Size::px(70.)),
)
.child(
Icon::new(IconType::X)
.size(14.)
.color(colors::fg_secondary()),
)
.child(
TextInput::new(height)
.placeholder("480")
.on_validate(validate_number)
.text_align(TextAlign::Center)
.width(Size::px(70.)),
);
settings_row(
IconType::LayoutTop,
"Resolution",
"The game window resolution in pixels.",
override_cell(control, overridden, on_reset),
)
}
}
#[derive(Clone, Copy, PartialEq)]
enum TextField {
JvmArgs,
Pre,
Wrapper,
Post,
}
impl TextField {
fn meta(self) -> (IconType, &'static str, &'static str, &'static str) {
match self {
Self::JvmArgs => (
IconType::Terminal,
"JVM Arguments",
"Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.",
"-XX:+UseG1GC",
),
Self::Pre => (
IconType::FilePlus02,
"Pre-Launch Command",
"Command to run before launching the game.",
"echo 'Game started'",
),
Self::Wrapper => (
IconType::ParagraphWrap,
"Wrapper Command",
"Command to run when launching the game.",
"gamescope",
),
Self::Post => (
IconType::FileX02,
"Post-Exit Command",
"Command to run after exiting the game.",
"echo 'Game exited'",
),
}
}
fn value(self, profile: &GameSettingsProfile) -> Option<String> {
match self {
Self::JvmArgs => profile.launch_args.clone(),
Self::Pre => profile.hook_pre.clone(),
Self::Wrapper => profile.hook_wrapper.clone(),
Self::Post => profile.hook_post.clone(),
}
}
fn patch(self, value: Patch<String>) -> ProfileUpdate {
let mut u = ProfileUpdate::default();
match self {
Self::JvmArgs => u.launch_args = value,
Self::Pre => u.hook_pre = value,
Self::Wrapper => u.hook_wrapper = value,
Self::Post => u.hook_post = value,
}
u
}
}
fn text_row(
cluster_id: i64,
field: TextField,
profile: &GameSettingsProfile,
global: &GameSettingsProfile,
) -> impl IntoElement {
TextRow {
cluster_id,
field,
value: field.value(profile),
global: field.value(global).unwrap_or_default(),
}
.into_element()
}
#[derive(PartialEq)]
struct TextRow {
cluster_id: i64,
field: TextField,
value: Option<String>,
global: String,
}
impl Component for TextRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let field = self.field;
let overridden = self.value.is_some();
let (icon, title, description, placeholder) = field.meta();
let dispatch = use_dispatch();
let global = self.global.clone();
let initial = self.value.clone().unwrap_or_else(|| self.global.clone());
let mut text = use_state({
let v = initial.clone();
move || v
});
let mut last = use_state(move || initial);
{
let dispatch = dispatch.clone();
use_side_effect(move || {
let raw = text.read().clone();
if raw == *last.peek() {
return;
}
last.set(raw.clone());
let patch = match raw.trim() {
"" => Patch::Clear,
v => Patch::Set(v.to_string()),
};
dispatch.update_cluster_profile(cluster_id, field.patch(patch));
});
}
let on_reset: EventHandler<()> = (move |()| {
last.set(global.clone());
text.set(global.clone());
dispatch.update_cluster_profile(cluster_id, field.patch(Patch::Clear));
})
.into();
let control = TextInput::new(text)
.placeholder(placeholder)
.expandable(true)
.width(Size::px(220.));
settings_row(
icon,
title,
description,
override_cell(control, overridden, on_reset),
)
}
}
#[derive(PartialEq)]
struct JavaRow {
cluster_id: i64,
value: Option<String>,
global: Option<String>,
runtimes: Vec<JavaRuntime>,
}
impl Component for JavaRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let overridden = self.value.is_some();
let dispatch = use_dispatch();
let runtimes = self.runtimes.clone();
let resolved = self.value.clone().or_else(|| self.global.clone());
let control: Element = if runtimes.is_empty() {
label()
.text("No runtimes installed")
.font_size(12.)
.color(colors::fg_secondary())
.into_element()
} else {
let mut options: Vec<String> = vec!["Automatic".into()];
options.extend(runtimes.iter().map(runtime_label));
let selected = resolved
.as_ref()
.and_then(|path| runtimes.iter().find(|r| &r.absolute_path == path))
.map(runtime_label)
.unwrap_or_else(|| "Automatic".into());
let paths: Vec<String> = runtimes.iter().map(|r| r.absolute_path.clone()).collect();
let dispatch = dispatch.clone();
Dropdown::new(selected, options)
.width(Size::px(260.))
.height(Size::px(34.))
.on_select(move |idx: usize| {
if idx == 0 {
dispatch.update_cluster_profile(cluster_id, clear_update(Field::JavaPath));
} else if let Some(path) = paths.get(idx - 1) {
dispatch.update_cluster_profile(
cluster_id,
ProfileUpdate {
java_path: Patch::Set(path.clone()),
..Default::default()
},
);
}
})
.into_element()
};
let on_reset: EventHandler<()> = (move |()| {
dispatch.update_cluster_profile(cluster_id, clear_update(Field::JavaPath));
})
.into();
settings_row(
IconType::CodeSnippet02,
"Java Runtime",
"The Java runtime used to launch this cluster.",
override_cell(control, overridden, on_reset),
)
}
}
#[derive(PartialEq)]
struct BrowserUpdateModeRow {
cluster_id: i64,
value: Option<PackageUpdateMode>,
global: PackageUpdateMode,
}
impl Component for BrowserUpdateModeRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let overridden = self.value.is_some();
let resolved = self.value.unwrap_or(self.global);
let dispatch = use_dispatch();
let options: Vec<String> = PackageUpdateMode::ALL
.iter()
.map(|mode| mode.label().to_string())
.collect();
let control = {
let dispatch = dispatch.clone();
Dropdown::new(resolved.label(), options)
.width(Size::px(220.))
.height(Size::px(34.))
.on_select(move |idx: usize| {
if let Some(mode) = PackageUpdateMode::ALL.get(idx).copied() {
dispatch.update_cluster_profile(
cluster_id,
ProfileUpdate {
browser_update_mode: Patch::Set(mode),
..Default::default()
},
);
}
})
};
let on_reset: EventHandler<()> = (move |()| {
dispatch.update_cluster_profile(cluster_id, clear_update(Field::BrowserUpdateMode));
})
.into();
settings_row(
IconType::RefreshCw01,
"Browser Package Updates",
"What to do when content installed from the browser has a newer version. Bundle content is not affected.",
override_cell(control, overridden, on_reset),
)
}
}
#[derive(PartialEq)]
struct MigrateFromRow {
cluster_id: i64,
}
impl Component for MigrateFromRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let dispatch = use_dispatch();
let checking = use_release_migration_checking(cluster_id);
let running = use_game_snapshot().is_running(cluster_id);
let clusters = settled_or_loading(&use_clusters()).unwrap_or_default();
let mut picked = use_state(|| None::<i64>);
let sources = clusters
.iter()
.find(|cluster| cluster.id == cluster_id)
.map(|target| rank_migration_sources(target, &clusters))
.unwrap_or_default();
let selected = picked
.read()
.and_then(|id| sources.iter().find(|source| source.id == id))
.or_else(|| sources.first())
.cloned();
let selected_id = selected.as_ref().map(|source| source.id);
let mut button = Button::new()
.secondary()
.enabled(selected_id.is_some() && !checking && !running)
.on_press(move |_| {
if let Some(source_id) = selected_id {
dispatch.open_manual_migration(cluster_id, source_id);
}
});
button = if checking {
button
.child(Icon::new(IconType::Loading02).size(14.))
.text("Checking…")
} else {
button
.child(Icon::new(IconType::Copy01).size(14.))
.text("Migrate...")
};
if running {
button = button.tooltip("Close the game before migrating");
}
let picker: Element = if sources.is_empty() {
label()
.text("No other clusters with this loader")
.font_size(12.)
.color(colors::fg_secondary())
.into_element()
} else {
let ids: Vec<i64> = sources.iter().map(|source| source.id).collect();
let names: Vec<String> = sources.iter().map(|source| source.name.clone()).collect();
Dropdown::new(
selected.map(|source| source.name).unwrap_or_default(),
names,
)
.width(Size::px(180.))
.height(Size::px(34.))
.on_select(move |index: usize| {
if let Some(id) = ids.get(index) {
picked.set(Some(*id));
}
})
.into_element()
};
settings_row(
IconType::Copy01,
"Migrate",
"Bring mods, resource packs and shaders you installed in another cluster. Compatible versions are downloaded; the other cluster is left as it is.",
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.child(picker)
.child(button),
)
}
}
#[derive(PartialEq)]
struct LoaderRow {
cluster_id: i64,
loader: GameLoader,
selected: Option<String>,
versions: Arc<[String]>,
locked: bool,
}
impl Component for LoaderRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let dispatch = use_dispatch();
let versions = self.versions.clone();
let selected = self
.selected
.clone()
.unwrap_or_else(|| versions.first().cloned().unwrap_or_else(|| "Latest".into()));
if self.locked {
return settings_row_disabled(
IconType::Rocket02,
"Loader Version",
"Set when the instance was created.",
label()
.text(format!("{} {selected}", self.loader))
.font_size(12.)
.color(colors::fg_secondary()),
)
.into_element();
}
let control: Element = if versions.is_empty() {
label()
.text("No versions available")
.font_size(12.)
.color(colors::fg_secondary())
.into_element()
} else {
let options = versions.to_vec();
Dropdown::new(selected, options.clone())
.width(Size::px(220.))
.height(Size::px(34.))
.on_select(move |idx: usize| {
if let Some(version) = options.get(idx) {
dispatch.set_cluster_loader_version(cluster_id, version.clone());
}
})
.into_element()
};
settings_row(
IconType::Rocket02,
"Loader Version",
format!(
"The {} loader version used to launch this cluster.",
self.loader
),
control,
)
.into_element()
}
}