From 370bf6e10a904a9ee91b2f129d121506f6d94a67 Mon Sep 17 00:00:00 2001 From: rozwader Date: Wed, 19 Aug 2026 08:53:12 +0200 Subject: [PATCH] feat: added memory allocation presets to minecraft and cluster settings --- .../src/components/memory_field.rs | 51 ++++++++++++ packages/oneclient_app/src/components/mod.rs | 4 +- .../src/components/text_input.rs | 12 +++ packages/oneclient_app/src/utils.rs | 80 +++++++++++++++++++ .../src/view/app/cluster/cluster_settings.rs | 17 +--- .../src/view/app/settings/minecraft.rs | 24 +----- 6 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 packages/oneclient_app/src/components/memory_field.rs diff --git a/packages/oneclient_app/src/components/memory_field.rs b/packages/oneclient_app/src/components/memory_field.rs new file mode 100644 index 00000000..76176220 --- /dev/null +++ b/packages/oneclient_app/src/components/memory_field.rs @@ -0,0 +1,51 @@ +use freya::prelude::*; + +use crate::components::{Dropdown, TextInput, validate_memory}; +use crate::theme::colors; +use crate::utils::{format_memory_gb, memory_presets_mb}; + +const UNSET_LABEL: &str = "Default"; +const CUSTOM_LABEL: &str = "Custom"; + +/// Presets picker with an input for memory allocation +pub fn memory_field(mut memory: State) -> impl IntoElement { + let presets = memory_presets_mb(); + let selected = match memory.read().trim() { + "" => UNSET_LABEL.to_string(), + value => value + .parse::() + .ok() + .filter(|mb| presets.contains(mb)) + .map(format_memory_gb) + .unwrap_or_else(|| CUSTOM_LABEL.to_string()), + }; + + let options: Vec = presets.iter().copied().map(format_memory_gb).collect(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + Dropdown::new(selected, options) + .width(Size::px(100.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if let Some(mb) = presets.get(idx).copied() { + memory.set(mb.to_string()); + } + }), + ) + .child( + TextInput::new(memory) + .width(Size::px(90.)) + .placeholder("4096") + .on_validate(validate_memory) + .trailing( + label() + .text("MB") + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index af74c911..c8d30f3e 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -16,6 +16,7 @@ mod java_prompt; mod link_confirm; mod local_image; mod log_viewer; +mod memory_field; mod microsoft_login; mod navbar; mod notifications; @@ -55,6 +56,7 @@ pub use java_prompt::JavaPromptOverlay; pub use link_confirm::ConfirmLinkOverlay; pub use local_image::LocalImage; pub use log_viewer::LogViewer; +pub use memory_field::memory_field; pub use microsoft_login::use_microsoft_login; pub(crate) use microsoft_login::login_dialog; pub(crate) use navbar::window_controls; @@ -73,7 +75,7 @@ pub use segmented_control::{Segment, SegmentedControl}; pub use splash_curtain::SplashCurtain; pub use status_bar::StatusBar; pub use tab_bar::{TabBar, TabItem}; -pub use text_input::{TextInput, validate_number}; +pub use text_input::{TextInput, validate_memory, validate_number}; pub use toasts::Toasts; pub use toggle::{toggle, toggle_controlled, toggle_disabled}; pub use update_prompt::UpdatePromptOverlay; diff --git a/packages/oneclient_app/src/components/text_input.rs b/packages/oneclient_app/src/components/text_input.rs index 736bd2e5..6fad7509 100644 --- a/packages/oneclient_app/src/components/text_input.rs +++ b/packages/oneclient_app/src/components/text_input.rs @@ -25,6 +25,18 @@ pub fn validate_number(validator: InputValidator) { validator.set_valid(valid); } +/// Validates the input if user has entered more MB of memory than his machine has +pub fn validate_memory(validator: InputValidator) { + let text = validator.text(); + + let valid = text.is_empty() + || text + .parse::() + .is_ok_and(|mb| mb <= crate::utils::total_ram_mb()); + + validator.set_valid(valid); +} + #[derive(Clone, PartialEq)] pub struct TextInput { input: Input, diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index 04295aa4..e8cf54d2 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -5,6 +5,7 @@ use chrono::{Datelike, NaiveDate}; use oneclient_core::clusters::Cluster; use oneclient_common::domain::GameLoader; use oneclient_common::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; +use sysinfo::{MemoryRefreshKind, RefreshKind, System}; pub type ClusterGroups = BTreeMap>; @@ -204,6 +205,56 @@ pub fn format_res((w, h): (u32, u32)) -> String { format!("{w}×{h}") } +/// Prevents user from choosing his max amount of ram preset (e.g Someone has 16GB of RAM, +/// so the max preset is 16GB - 2GB = 14GB) +const MEMORY_HEADROOM_GB: u32 = 2; +const MEMORY_PRESETS_GB: [u32; 10] = [2, 4, 6, 8, 12, 16, 24, 32, 48, 64]; + +pub fn total_ram_mb() -> u32 { + static TOTAL_RAM_MB: std::sync::OnceLock = std::sync::OnceLock::new(); + + *TOTAL_RAM_MB.get_or_init(|| { + let system = System::new_with_specifics( + RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_ram()), + ); + (system.total_memory() / 1024 / 1024).min(u32::MAX as u64) as u32 + }) +} + +pub fn memory_presets_mb() -> Vec { + presets_for_total_gb((total_ram_mb() as f32 / 1024.).round() as u32) +} + +fn presets_for_total_gb(total_gb: u32) -> Vec { + let usable_gb = total_gb.saturating_sub(MEMORY_HEADROOM_GB); + if usable_gb == 0 { + return vec![1024]; + } + + let mut presets: Vec = MEMORY_PRESETS_GB + .iter() + .copied() + .filter(|gb| *gb <= usable_gb) + .map(|gb| gb * 1024) + .collect(); + + let usable_mb = usable_gb * 1024; + if presets.last() != Some(&usable_mb) { + presets.push(usable_mb); + } + + presets +} + +/// `8192` -> `8 GB` `1536` -> `1.5 GB` +pub fn format_memory_gb(mb: u32) -> String { + if mb.is_multiple_of(1024) { + format!("{} GB", mb / 1024) + } else { + format!("{:.1} GB", mb as f32 / 1024.) + } +} + /// `7384` -> `2h 3m` `540` -> `9m` `0` -> `0m` pub fn format_duration_hm(secs: i64) -> String { if secs <= 0 { @@ -396,6 +447,35 @@ mod tests { assert_eq!(line_art_key(line(26, Some(1)), &modern), Some((1, None))); } + #[test] + fn presets_stop_two_gigabytes_short_of_the_machine() { + assert_eq!( + presets_for_total_gb(16), + vec![2048, 4096, 6144, 8192, 12288, 14336] + ); + assert_eq!( + presets_for_total_gb(32), + vec![2048, 4096, 6144, 8192, 12288, 16384, 24576, 30720] + ); + } + + #[test] + fn a_ceiling_landing_on_a_round_step_is_not_repeated() { + assert_eq!(presets_for_total_gb(8), vec![2048, 4096, 6144]); + } + + #[test] + fn a_tiny_machine_still_gets_one_preset() { + assert_eq!(presets_for_total_gb(4), vec![2048]); + assert_eq!(presets_for_total_gb(2), vec![1024]); + } + + #[test] + fn memory_labels_drop_the_decimal_when_whole() { + assert_eq!(format_memory_gb(8192), "8 GB"); + assert_eq!(format_memory_gb(1536), "1.5 GB"); + } + #[test] fn a_bare_legacy_version_keeps_its_line_name() { let clusters = [versioned(1, "1.21")]; diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 5749b611..29284ea6 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -7,8 +7,8 @@ use oneclient_core::settings::{ }; use crate::components::{ - Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, - validate_number, + Button, Dropdown, Icon, IconType, ScrollArea, TextInput, memory_field, toggle, + toggle_controlled, validate_number, }; use crate::hooks::{ ClusterAction, java_runtimes, loader_versions, mutation_is_running, try_game_profile, @@ -355,21 +355,12 @@ impl Component for MemoryRow { }) .into(); - let control = TextInput::new(memory) - .width(Size::px(90.)) - .placeholder("4096") - .on_validate(validate_number) - .trailing( - label() - .text("MB") - .font_size(12.) - .color(colors::fg_secondary()), - ); + let control = memory_field(memory); settings_row( IconType::Database01, "Memory", - "The amount of memory in megabytes allocated for the game.", + "The amount of memory in megabytes allocated for the game. Presets leave 2 GB for the system.", override_cell(control, overridden, on_reset), ) } diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index da5a19db..de877528 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -3,7 +3,9 @@ use oneclient_common::Patch; use oneclient_core::settings::{PackageUpdateMode, ProfileUpdate, Resolution}; use super::settings_page; -use crate::components::{Dropdown, Icon, IconType, TextInput, toggle, validate_number}; +use crate::components::{ + Dropdown, Icon, IconType, TextInput, memory_field, toggle, validate_number, +}; use crate::hooks::{use_dispatch, use_settings_snapshot}; use crate::theme::colors; use crate::view::app::settings::{section_header, settings_row}; @@ -92,7 +94,7 @@ impl Component for SettingsMinecraft { .child(settings_row( IconType::Database01, "Memory", - "The amount of memory in megabytes allocated for the game.", + "The amount of memory in megabytes allocated for the game. Presets leave 2 GB for the system.", memory_field(memory), )) .child(settings_row( @@ -237,21 +239,3 @@ fn resolution_field(width: State, height: State) -> impl IntoEle .into_element() } -fn memory_field(memory: State) -> impl IntoElement { - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(8.) - .child( - TextInput::new(memory) - .width(Size::px(90.)) - .placeholder("4096") - .on_validate(validate_number) - .trailing( - label() - .text("MB") - .font_size(12.) - .color(colors::fg_secondary()), - ), - ) -}