diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 50196c6d..c01c78ee 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -28,6 +28,7 @@ pub use active_cluster::{ }; pub use actions::{Actions, NotificationBuilder, PumpSignal, modpack_job_running}; +pub use queries::ALL_VERSIONS; pub(crate) use queries::use_folder_watch; pub use queries::{ AddOfflineAccountKeys, AvailableBundlesQuery, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, @@ -64,9 +65,9 @@ pub use queries::{ use_global_analytics, use_java_majors, use_java_runtimes, use_leftovers, use_loader_versions, use_local_image, use_log_action, use_log_content, use_migratable_routes, use_migration, use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch, - use_package_project, use_package_search, use_package_updates, use_package_versions, - use_package_versions_when, use_player_profile, use_player_skin, use_provider_versions, - use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action, + use_package_project, use_package_search, use_package_updates, use_package_versions_when, + use_player_profile, use_player_skin, use_provider_versions, use_refresh_account, + use_refresh_all_accounts, use_remove_account, use_screenshot_action, use_screenshot_folder_watch, use_set_default_account, use_storage_action, use_storage_report, use_terms, use_upload_log, use_version_art, use_version_art_gallery, use_version_loaders, use_version_metadata, use_versions, version_list, versions_metadata, versions_total, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 5a3e03a0..d838f542 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -79,11 +79,12 @@ pub use mutations::{ invalidate_profile_queries, use_cluster_mutation, }; pub use package_updates::{package_updates, stale_hashes, use_package_updates}; +pub use packages::ALL_VERSIONS; pub use packages::{ BROWSE_PAGE_SIZE, VERSIONS_PAGE_SIZE, category_list, content_type_for_slug, package_meta_batch, project_detail, search_items, search_pending, search_total, use_package_categories, - use_package_meta_batch, use_package_project, use_package_search, use_package_versions, - use_package_versions_when, version_list, versions_total, + use_package_meta_batch, use_package_project, use_package_search, use_package_versions_when, + version_list, versions_total, }; pub use player_profile::use_player_profile; pub use screenshots::{ diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs index 6a505044..e0a0ad68 100644 --- a/packages/oneclient_app/src/hooks/queries/packages.rs +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -12,6 +12,7 @@ use oneclient_core::LauncherError; pub const BROWSE_PAGE_SIZE: usize = DEFAULT_PAGE_SIZE; pub const VERSIONS_PAGE_SIZE: usize = 20; +pub const ALL_VERSIONS: usize = usize::MAX; const VERSIONS_STALE: Duration = Duration::from_secs(5 * 60); @@ -209,6 +210,7 @@ pub struct PackageVersionsKeys { pub game_version: Option, pub loader: Option, pub page: usize, + pub page_size: usize, } impl QueryCapability for PackageVersionsQuery { @@ -228,8 +230,8 @@ impl QueryCapability for PackageVersionsQuery { &keys.project_id, keys.game_version.as_deref(), keys.loader, - keys.page * VERSIONS_PAGE_SIZE, - VERSIONS_PAGE_SIZE, + keys.page.saturating_mul(keys.page_size), + keys.page_size, &state.services.content(), ) .await?) @@ -238,16 +240,6 @@ impl QueryCapability for PackageVersionsQuery { } } -pub fn use_package_versions( - provider: ProviderId, - project_id: String, - game_version: Option, - loader: Option, - page: usize, -) -> UseQuery { - use_package_versions_when(true, provider, project_id, game_version, loader, page) -} - pub fn use_package_versions_when( enabled: bool, provider: ProviderId, @@ -255,6 +247,7 @@ pub fn use_package_versions_when( game_version: Option, loader: Option, page: usize, + page_size: usize, ) -> UseQuery { let keys = enabled.then_some(PackageVersionsKeys { provider, @@ -262,6 +255,7 @@ pub fn use_package_versions_when( game_version, loader, page, + page_size, }); use_query(Query::new(keys, PackageVersionsQuery).stale_time(VERSIONS_STALE)) diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index 609b817f..639ae136 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -9,8 +9,8 @@ use oneclient_core::clusters::ModpackSource; use crate::components::{Button, Icon, IconType}; use crate::hooks::{ - content_type_for_slug, use_browser_compat, use_cluster, use_dispatch, use_installs_snapshot, - use_package_versions_when, version_list, + ALL_VERSIONS, VERSIONS_PAGE_SIZE, content_type_for_slug, use_browser_compat, use_cluster, + use_dispatch, use_installs_snapshot, use_package_versions_when, version_list, }; use crate::routes::Route; use crate::theme::colors; @@ -367,10 +367,17 @@ impl Component for InstallButton { game_version, loader, 0, + if is_modpack { + ALL_VERSIONS + } else { + VERSIONS_PAGE_SIZE + }, )); let latest = preferred_version(&versions, self.content_type).map(|v| v.version_id.clone()); let is_datapack = self.content_type == ContentType::DataPack; let mut world_prompt = use_state(|| None::); + let mut modpack_prompt = use_state(|| false); + let mut choices = use_state(Vec::new); // Nothing to start twice while an install is running or before versions arrive let (installing, waiting) = @@ -418,11 +425,17 @@ impl Component for InstallButton { if is_datapack { world_prompt.set(Some(version_id)); } else if is_modpack { - dispatch.install_modpack(ModpackSource::Provider { - provider, - project_id: project_id.clone(), - version_id, - }); + let picks = minecraft_choices(&versions); + if picks.len() > 1 { + choices.set(picks); + modpack_prompt.set(true); + } else { + dispatch.install_modpack(ModpackSource::Provider { + provider, + project_id: project_id.clone(), + version_id, + }); + } } else { dispatch.install_package( cluster_id, @@ -455,9 +468,15 @@ impl Component for InstallButton { .maybe_child(world_prompt.read().is_some().then_some(WorldInstallPrompt { cluster_id, provider, - project_id, + project_id: project_id.clone(), pending: world_prompt, })) + .maybe_child(modpack_prompt.read().then(|| ModpackVersionPrompt { + provider, + project_id, + choices: choices.read().clone(), + open: modpack_prompt, + })) .into_element() } } diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 558d7235..31ab40f8 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -31,8 +31,8 @@ use crate::ui::grid_columns_for_width; use crate::view::app::cluster::supports_datapacks; use super::{ - InstallSource, Installed, PackageBanner, Thumbnail, WorldInstallPrompt, installed_map, - preferred_version, + InstallSource, Installed, ModpackVersionPrompt, PackageBanner, Thumbnail, WorldInstallPrompt, + installed_map, minecraft_choices, preferred_version, }; use crate::utils::{abbreviate_number, sort_clusters_for_home}; diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 54c4830e..8599b2c0 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -4,7 +4,9 @@ mod package; pub use index::Browser; pub(crate) use index::{browsable_type, encode_package_id}; +mod modpack_prompt; mod world_prompt; +use modpack_prompt::{ModpackVersionPrompt, minecraft_choices}; use world_prompt::WorldInstallPrompt; /// Projects shipping both a mod and a data pack tag the mod files with a loader diff --git a/packages/oneclient_app/src/view/app/browser/modpack_prompt.rs b/packages/oneclient_app/src/view/app/browser/modpack_prompt.rs new file mode 100644 index 00000000..5694116a --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/modpack_prompt.rs @@ -0,0 +1,276 @@ +use std::collections::HashSet; + +use freya::prelude::*; +use oneclient_common::version::parse_mc_version; +use oneclient_content::packages::ProviderId; +use oneclient_content::packages::types::{ReleaseType, VersionSummary}; +use oneclient_core::clusters::ModpackSource; + +use crate::components::{Button, Icon, IconType, ScrollArea}; +use crate::hooks::use_dispatch; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::app::cluster::dialog; + +const ROW_H: f32 = 44.; +const ROW_GAP: f32 = 6.; +const VISIBLE_ROWS: usize = 5; + +#[derive(Clone, PartialEq)] +pub(crate) struct MinecraftChoice { + mc: String, + version_id: String, + detail: String, +} + +pub(crate) fn minecraft_choices(versions: &[VersionSummary]) -> Vec { + let mut seen = HashSet::new(); + let mut order: Vec<_> = versions + .iter() + .flat_map(|v| &v.game_versions) + .filter(|mc| seen.insert(mc.as_str())) + .filter(|mc| mc.bytes().all(|b| b.is_ascii_digit() || b == b'.')) + .filter_map(|mc| parse_mc_version(mc).map(|p| (mc.clone(), (p.major, p.minor, p.patch)))) + .collect(); + order.sort_by(|a, b| b.1.cmp(&a.1)); + + let mut choices: Vec = Vec::new(); + for (mc, _) in order { + let for_mc: Vec<_> = versions + .iter() + .filter(|v| v.game_versions.contains(&mc)) + .collect(); + let mut loader_sets: Vec<&Vec<_>> = Vec::new(); + for v in &for_mc { + if !loader_sets.contains(&&v.loaders) { + loader_sets.push(&v.loaders); + } + } + for loaders in loader_sets { + let same = || for_mc.iter().filter(|v| &v.loaders == loaders); + let Some(pick) = same() + .find(|v| matches!(v.release_type, ReleaseType::Release)) + .or_else(|| same().next()) + else { + continue; + }; + push_choice(&mut choices, &mc, pick); + } + } + choices +} + +fn push_choice(choices: &mut Vec, mc: &str, pick: &VersionSummary) { + if let Some(existing) = choices.iter_mut().find(|c| c.version_id == pick.version_id) { + existing.mc.push_str(" / "); + existing.mc.push_str(mc); + return; + } + let mut detail = vec![pick.version_number.clone()]; + detail.extend(pick.loaders.iter().map(|l| l.to_string())); + match pick.release_type { + ReleaseType::Release => {} + ReleaseType::Beta => detail.push("Beta".to_string()), + ReleaseType::Alpha => detail.push("Alpha".to_string()), + } + detail.push(pick.published.format("%Y-%m-%d").to_string()); + choices.push(MinecraftChoice { + mc: mc.to_string(), + version_id: pick.version_id.clone(), + detail: detail.join(" ยท "), + }); +} + +#[derive(PartialEq)] +pub(crate) struct ModpackVersionPrompt { + pub provider: ProviderId, + pub project_id: String, + pub choices: Vec, + pub open: State, +} + +impl Component for ModpackVersionPrompt { + fn render(&self) -> impl IntoElement { + let provider = self.provider; + let project_id = self.project_id.clone(); + let mut open = self.open; + let dispatch = use_dispatch(); + let mut selected = use_state(|| 0usize); + let current = (*selected.read()).min(self.choices.len().saturating_sub(1)); + + let rows: Vec = self + .choices + .iter() + .enumerate() + .map(|(i, choice)| { + choice_row(choice, i == current, move || selected.set(i)).into_element() + }) + .collect(); + let shown = self.choices.len().min(VISIBLE_ROWS) as f32; + let list_h = shown * ROW_H + (shown - 1.).max(0.) * ROW_GAP; + let control = ScrollArea::new() + .width(Size::fill()) + .height(Size::px(list_h)) + .spacing(ROW_GAP) + .children(rows) + .into_element(); + + let version_id = self.choices.get(current).map(|c| c.version_id.clone()); + let install = move |_| { + let Some(version_id) = version_id.clone() else { + return; + }; + dispatch.install_modpack(ModpackSource::Provider { + provider, + project_id: project_id.clone(), + version_id, + }); + open.set(false); + }; + + dialog( + "Which Minecraft version?".to_string(), + "This modpack is made for several Minecraft versions. Pick the one to install." + .to_string(), + Some(control), + move || open.set(false), + [ + Button::new() + .secondary() + .on_press(move |_| open.set(false)) + .text("Cancel") + .into_element(), + Button::new() + .primary() + .on_press(install) + .child(Icon::new(IconType::Download01).size(14.)) + .text("Install") + .into_element(), + ], + ) + } +} + +fn choice_row( + choice: &MinecraftChoice, + active: bool, + mut on_select: impl FnMut() + 'static, +) -> impl IntoElement { + let ring = if active { + colors::brand() + } else { + colors::component_border() + }; + let radio = rect() + .width(Size::px(16.)) + .height(Size::px(16.)) + .center() + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1.5, ring)) + .maybe_child(active.then(|| { + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(colors::brand()) + })); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(ROW_H)) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., ring)) + .on_press(move |_| on_select()) + .child(radio) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text(format!("Minecraft {}", choice.mc)) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(choice.detail.clone()) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn version(id: &str, mc: &[&str], release_type: ReleaseType) -> VersionSummary { + VersionSummary { + version_id: id.to_string(), + game_versions: mc.iter().map(|s| s.to_string()).collect(), + project_id: String::new(), + name: String::new(), + version_number: String::new(), + published: Default::default(), + release_type, + loaders: Vec::new(), + downloads: 0, + file_size: 0, + dependencies: Vec::new(), + } + } + + #[test] + fn one_choice_per_minecraft_version_preferring_releases() { + let versions = [ + version("b3", &["26.3"], ReleaseType::Beta), + version("r3", &["26.3"], ReleaseType::Release), + version("b2", &["26.2"], ReleaseType::Beta), + version( + "r1", + &["1.21.11", "Fabric", "26.1", "1.21.5-rc1", "Client"], + ReleaseType::Release, + ), + ]; + let picks: Vec<(String, String)> = minecraft_choices(&versions) + .into_iter() + .map(|c| (c.mc, c.version_id)) + .collect(); + let expect = [("26.3", "r3"), ("26.2", "b2"), ("26.1 / 1.21.11", "r1")]; + assert_eq!( + picks, + expect.map(|(a, b)| (a.to_string(), b.to_string())).to_vec() + ); + } + + #[test] + fn one_choice_per_loader_on_the_same_minecraft_version() { + use oneclient_common::domain::GameLoader; + let with = |id, loader, release_type| VersionSummary { + loaders: vec![loader], + ..version(id, &["1.21.1"], release_type) + }; + let versions = [ + with("fabric-new", GameLoader::Fabric, ReleaseType::Release), + with("forge-beta", GameLoader::Forge, ReleaseType::Beta), + with("fabric-old", GameLoader::Fabric, ReleaseType::Release), + with("forge-rel", GameLoader::Forge, ReleaseType::Release), + ]; + let ids: Vec = minecraft_choices(&versions) + .into_iter() + .map(|c| c.version_id) + .collect(); + assert_eq!(ids, ["fabric-new", "forge-rel"]); + } +} diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index 48ed42cf..5a8f87ea 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -8,17 +8,19 @@ use oneclient_core::clusters::ModpackSource; use crate::components::{ScrollArea, use_shared_delete}; use crate::hooks::use_cluster; use crate::hooks::{ - bundles_with_status_items, cluster_content_items, content_type_for_slug, package_meta_batch, - project_detail, use_browser_compat, use_bundles_with_status, use_cluster_content, - use_dispatch, use_installs_snapshot, use_link_confirm, use_package_meta_batch, - use_package_project, use_package_versions, version_list, versions_total, + ALL_VERSIONS, VERSIONS_PAGE_SIZE, bundles_with_status_items, cluster_content_items, + content_type_for_slug, package_meta_batch, project_detail, use_browser_compat, + use_bundles_with_status, use_cluster_content, use_dispatch, use_installs_snapshot, + use_link_confirm, use_package_meta_batch, use_package_project, use_package_versions_when, + version_list, versions_total, }; use crate::theme::colors; use crate::ui::border_all_color; use super::{ - Installed, InstalledVersion, PackageBanner, Thumbnail, WorldInstallPrompt, activity_badge, - installed_badge, installed_map, preferred_version, + Installed, InstalledVersion, ModpackVersionPrompt, PackageBanner, Thumbnail, + WorldInstallPrompt, activity_badge, installed_badge, installed_map, minecraft_choices, + preferred_version, }; use crate::utils::abbreviate_number; @@ -40,9 +42,17 @@ struct Installer { provider: ProviderId, world_prompt: Option>>, modpack: bool, + modpack_prompt: Option>, } impl Installer { + fn install_latest(&self, project_id: String, version_id: String) { + match self.modpack_prompt { + Some(mut prompt) => prompt.set(true), + None => self.install(project_id, version_id), + } + } + fn install(&self, project_id: String, version_id: String) { if self.modpack { self.dispatch.install_modpack(ModpackSource::Provider { @@ -148,13 +158,15 @@ impl Component for BrowserPackage { let dispatch = use_dispatch(); let confirm = use_link_confirm(); let world_prompt = use_state(|| None::); + let modpack_prompt = use_state(|| false); let is_datapack = content_type == ContentType::DataPack; - let installer = Installer { + let mut installer = Installer { dispatch: dispatch.clone(), cluster_id, provider, world_prompt: is_datapack.then_some(world_prompt), modpack: content_type == ContentType::Modpack, + modpack_prompt: None, }; let cluster = use_cluster(cluster_id); @@ -178,13 +190,25 @@ impl Component for BrowserPackage { } let project_query = use_package_project(provider, project_id.clone()); - let versions_query = use_package_versions( + let is_modpack = content_type == ContentType::Modpack; + let versions_query = use_package_versions_when( + !is_modpack, provider, project_id.clone(), game_version, loader, *versions_page.read(), + VERSIONS_PAGE_SIZE, ); + let all_versions = version_list(&use_package_versions_when( + is_modpack, + provider, + project_id.clone(), + None, + None, + 0, + ALL_VERSIONS, + )); let (installing, waiting) = use_installs_snapshot().package_busy( content_type == ContentType::Modpack, @@ -216,8 +240,20 @@ impl Component for BrowserPackage { } }); - let versions = version_list(&versions_query); - let total_versions = versions_total(&versions_query); + let (versions, total_versions) = if is_modpack { + let page = all_versions + .iter() + .skip(versions_page.read().saturating_mul(VERSIONS_PAGE_SIZE)) + .take(VERSIONS_PAGE_SIZE) + .cloned() + .collect(); + (page, all_versions.len()) + } else { + ( + version_list(&versions_query), + versions_total(&versions_query), + ) + }; let dependency_ids: Vec = versions .iter() .flat_map(|v| &v.dependencies) @@ -229,8 +265,11 @@ impl Component for BrowserPackage { .into_iter() .map(|(id, meta)| (id, meta.name)) .collect(); + let latest_source = if is_modpack { &all_versions } else { &versions }; let latest_version = - preferred_version(&versions, content_type).map(|v| v.version_id.clone()); + preferred_version(latest_source, content_type).map(|v| v.version_id.clone()); + let choices = minecraft_choices(&all_versions); + installer.modpack_prompt = (choices.len() > 1).then_some(modpack_prompt); let gallery = project .as_ref() @@ -303,6 +342,12 @@ impl Component for BrowserPackage { project_id: project_id.clone(), pending: world_prompt, })) + .maybe_child(modpack_prompt.read().then(|| ModpackVersionPrompt { + provider, + project_id: project_id.clone(), + choices, + open: modpack_prompt, + })) .into_element() } } diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 72b4a706..8873a243 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -108,7 +108,7 @@ pub(super) fn sidebar( .enabled(can_install) .on_press(move |_| { if let Some(version_id) = latest_version.clone() { - installer.install(project_id.clone(), version_id); + installer.install_latest(project_id.clone(), version_id); } }) .child(Icon::new(IconType::Download01).size(14.))