From 57408aa7102ae9121ed47da4ba4e7bf296f02877 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:18:12 +0200 Subject: [PATCH] feat(ui): show all individual versions --- .../src/components/cluster_landscape_art.rs | 7 - .../src/components/dynamic_art.rs | 9 - packages/oneclient_app/src/components/mod.rs | 4 +- ...{major_version_card.rs => version_card.rs} | 33 +-- packages/oneclient_app/src/utils.rs | 192 ++++++++++++++++-- .../src/view/app/clusters/page.rs | 136 +++++++------ 6 files changed, 267 insertions(+), 114 deletions(-) rename packages/oneclient_app/src/components/{major_version_card.rs => version_card.rs} (86%) diff --git a/packages/oneclient_app/src/components/cluster_landscape_art.rs b/packages/oneclient_app/src/components/cluster_landscape_art.rs index a9265687..ab59eab6 100644 --- a/packages/oneclient_app/src/components/cluster_landscape_art.rs +++ b/packages/oneclient_app/src/components/cluster_landscape_art.rs @@ -24,13 +24,6 @@ impl ClusterLandscapeArt { selected, } } - - pub fn for_major(major: u32, selected: bool) -> Self { - Self { - art: DynamicArt::for_major(major).max_edge(512), - selected, - } - } } impl Component for ClusterLandscapeArt { diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index 537a7db2..4edf7745 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -28,15 +28,6 @@ impl DynamicArt { } } - pub fn for_major(major: u32) -> Self { - Self { - major: Some(major), - key: None, - loader: None, - max_edge: DEFAULT_IMAGE_EDGE, - } - } - pub fn for_cluster(cluster: &Cluster) -> Self { let parsed = parse_mc_version(&cluster.mc_version); Self { diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 5977ebd9..63f0a138 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -15,7 +15,6 @@ mod java_prompt; mod link_confirm; mod local_image; mod log_viewer; -mod major_version_card; mod microsoft_login; mod navbar; mod notifications; @@ -34,6 +33,7 @@ mod text_input; mod toasts; mod toggle; mod update_prompt; +mod version_card; pub use account_switcher::AccountSwitcher; pub use active_cluster_panel::ActiveClusterPanel; @@ -52,7 +52,6 @@ pub use java_prompt::JavaPromptOverlay; pub use link_confirm::ConfirmLinkOverlay; pub use local_image::LocalImage; pub use log_viewer::LogViewer; -pub use major_version_card::MajorVersionCard; pub use microsoft_login::use_microsoft_login; pub(crate) use microsoft_login::login_dialog; pub(crate) use navbar::window_controls; @@ -74,3 +73,4 @@ pub use text_input::{TextInput, validate_number}; pub use toasts::Toasts; pub use toggle::{toggle, toggle_controlled, toggle_disabled}; pub use update_prompt::UpdatePromptOverlay; +pub use version_card::VersionCard; diff --git a/packages/oneclient_app/src/components/major_version_card.rs b/packages/oneclient_app/src/components/version_card.rs similarity index 86% rename from packages/oneclient_app/src/components/major_version_card.rs rename to packages/oneclient_app/src/components/version_card.rs index 641b6ff2..1b945564 100644 --- a/packages/oneclient_app/src/components/major_version_card.rs +++ b/packages/oneclient_app/src/components/version_card.rs @@ -1,48 +1,52 @@ use freya::prelude::*; +use oneclient_core::VersionKey; use oneclient_core::clusters::Cluster; use crate::components::ClusterLandscapeArt; use crate::theme::colors; use crate::ui::border_all_color; -use crate::utils::{loader_tags, major_pretty_name}; +use crate::utils::{ReleaseLine, line_art_key, line_title, loader_tags}; const CARD_HEIGHT_PX: f32 = 240.; -pub struct MajorVersionCard { - pub major: u32, +pub struct VersionCard { + pub line: ReleaseLine, + pub art_key: Option, pub tags: Vec, pub title: String, pub selected: bool, pub on_press: EventHandler>, } -impl MajorVersionCard { +impl VersionCard { pub fn new( - major: u32, + line: ReleaseLine, clusters: &[Cluster], selected: bool, on_press: impl Into>>, ) -> Self { Self { - major, + line, + art_key: line_art_key(line, clusters), tags: loader_tags(clusters), - title: major_pretty_name(major), + title: line_title(line, clusters), selected, on_press: on_press.into(), } } } -impl PartialEq for MajorVersionCard { +impl PartialEq for VersionCard { fn eq(&self, other: &Self) -> bool { - self.major == other.major + self.line == other.line + && self.art_key == other.art_key && self.tags == other.tags && self.title == other.title && self.selected == other.selected } } -impl Component for MajorVersionCard { +impl Component for VersionCard { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); @@ -69,7 +73,7 @@ impl Component for MajorVersionCard { }; rect() - .key(self.major) + .key(self.line) .width(Size::flex(1.0)) .height(Size::px(CARD_HEIGHT_PX)) .a11y_id(a11y_id) @@ -94,7 +98,12 @@ impl Component for MajorVersionCard { .width(Size::fill()) .height(Size::fill()) .position(Position::new_absolute()) - .child(ClusterLandscapeArt::for_major(self.major, false)), + .child(ClusterLandscapeArt::for_version( + self.line.major, + self.art_key, + None, + false, + )), ) .child( rect() diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index 3496cb5a..14273e7f 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -4,25 +4,91 @@ use std::collections::BTreeMap; use chrono::{Datelike, NaiveDate}; use oneclient_core::clusters::Cluster; use oneclient_core::packages::domain::GameLoader; -use oneclient_core::{VersionKey, format_mc_version, parse_mc_version}; +use oneclient_core::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; -pub type ClusterGroups = BTreeMap>; +pub type ClusterGroups = BTreeMap>; + +/// One entry on the versions page. The modern scheme puts a full release in the +/// first two components (`26.1`, `26.2`), so each minor is its own line; legacy +/// `1.x` versions keep the whole major (`1.21`) as one line. A line that holds a +/// single concrete version is presented as that version — see [`line_title`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ReleaseLine { + pub major: u32, + /// `Some` only for the modern scheme, where the minor is part of the line. + pub minor: Option, +} + +impl ReleaseLine { + fn from_parsed(parsed: &ParsedMcVersion) -> Option { + let minor = if parsed.major >= 26 { + Some(parsed.minor?) + } else { + None + }; + Some(Self { + major: parsed.major, + minor, + }) + } + + pub fn from_version(version: &str) -> Option { + Self::from_parsed(&parse_mc_version(version)?) + } + + pub fn for_cluster(cluster: &Cluster) -> Option { + Self::from_version(&cluster.mc_version) + } + + /// The generic name of the line: `26.1`, `1.21`. + pub fn pretty_name(&self) -> String { + match self.minor { + Some(minor) => format!("{}.{minor}", self.major), + None => format!("1.{}", self.major), + } + } + + /// Metadata/art lookup key for the line itself, when no single version + /// stands in for it. + fn art_key(&self) -> Option { + self.minor.map(|minor| (minor, None)) + } +} fn cluster_key(cluster: &Cluster) -> Option { parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.key()) } -pub fn group_clusters_by_major(clusters: &[Cluster]) -> ClusterGroups { +/// The one concrete version behind `clusters`, if they all share it. +fn sole_version_key(clusters: &[Cluster]) -> Option { + match version_keys(clusters).as_slice() { + [only] => Some(*only), + _ => None, + } +} + +/// A line with a single version is shown as that full version (`26.1.2`); +/// otherwise the generic line name (`1.21`), with the versions in a dropdown. +pub fn line_title(line: ReleaseLine, clusters: &[Cluster]) -> String { + match sole_version_key(clusters) { + Some(key) => version_label(line.major, key), + None => line.pretty_name(), + } +} + +/// Metadata/art key matching [`line_title`]. +pub fn line_art_key(line: ReleaseLine, clusters: &[Cluster]) -> Option { + sole_version_key(clusters).or_else(|| line.art_key()) +} + +pub fn group_clusters_by_release(clusters: &[Cluster]) -> ClusterGroups { let mut groups: ClusterGroups = BTreeMap::new(); for cluster in clusters { - let Some(parsed) = parse_mc_version(&cluster.mc_version) else { + let Some(line) = ReleaseLine::for_cluster(cluster) else { continue; }; - groups - .entry(parsed.major) - .or_default() - .push(cluster.clone()); + groups.entry(line).or_default().push(cluster.clone()); } for list in groups.values_mut() { @@ -32,14 +98,6 @@ pub fn group_clusters_by_major(clusters: &[Cluster]) -> ClusterGroups { groups } -pub fn major_pretty_name(major: u32) -> String { - if major >= 26 { - format!("{major}") - } else { - format!("1.{major}") - } -} - pub fn loader_tags(clusters: &[Cluster]) -> Vec { let mut tags = Vec::new(); for cluster in clusters { @@ -67,7 +125,7 @@ pub fn version_keys(clusters: &[Cluster]) -> Vec { keys } -pub fn loaders_for_major(clusters: &[Cluster]) -> Vec { +pub fn loaders_for_line(clusters: &[Cluster]) -> Vec { let mut loaders = Vec::new(); for cluster in clusters { if cluster.mc_loader.is_modded() && !loaders.contains(&cluster.mc_loader) { @@ -93,13 +151,14 @@ pub fn resolve_cluster( .cloned() } -pub fn default_major(groups: &ClusterGroups, active: Option) -> Option { +/// The line the page opens on: the active cluster's line, else the newest. +pub fn default_line(groups: &ClusterGroups, active: Option) -> Option { if let Some(cluster) = active - && let Some(parsed) = parse_mc_version(&cluster.mc_version) + && let Some(line) = ReleaseLine::for_cluster(&cluster) { - return Some(parsed.major); + return Some(line); } - groups.keys().next().copied() + groups.keys().next_back().copied() } pub fn default_version_key( @@ -119,7 +178,7 @@ pub fn default_version_key( } pub fn default_loader(clusters: &[Cluster], preferred: Option) -> Option { - let loaders = loaders_for_major(clusters); + let loaders = loaders_for_line(clusters); if loaders.is_empty() { return clusters.first().map(|c| c.mc_loader); } @@ -264,3 +323,92 @@ pub fn is_wayland() -> bool { } }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::cluster; + + fn versioned(id: i64, mc_version: &str) -> Cluster { + Cluster { + mc_version: mc_version.to_string(), + ..cluster(id) + } + } + + fn line(major: u32, minor: Option) -> ReleaseLine { + ReleaseLine { major, minor } + } + + fn lines_of(clusters: &[Cluster]) -> Vec { + group_clusters_by_release(clusters) + .keys() + .copied() + .collect() + } + + #[test] + fn modern_minors_are_separate_lines() { + let clusters = [ + versioned(1, "26.1"), + versioned(2, "26.2"), + versioned(3, "26.2.1"), + ]; + + assert_eq!( + lines_of(&clusters), + vec![line(26, Some(1)), line(26, Some(2))] + ); + } + + #[test] + fn legacy_minors_share_one_line() { + let clusters = [versioned(1, "1.21.5"), versioned(2, "1.21.11")]; + + assert_eq!(lines_of(&clusters), vec![line(21, None)]); + } + + #[test] + fn a_lone_version_is_shown_in_full() { + let clusters = [versioned(1, "26.1.2")]; + let line = line(26, Some(1)); + + assert_eq!(line_title(line, &clusters), "26.1.2"); + assert_eq!(line_art_key(line, &clusters), Some((1, Some(2)))); + } + + #[test] + fn several_loaders_of_one_version_still_count_as_one() { + let clusters = [ + versioned(1, "26.1.2"), + Cluster { + mc_loader: GameLoader::Vanilla, + ..versioned(2, "26.1.2") + }, + ]; + + assert_eq!(line_title(line(26, Some(1)), &clusters), "26.1.2"); + } + + #[test] + fn several_versions_fall_back_to_the_generic_name() { + let legacy = [ + versioned(1, "1.21.1"), + versioned(2, "1.21.10"), + versioned(3, "1.21.11"), + ]; + assert_eq!(line_title(line(21, None), &legacy), "1.21"); + assert_eq!(line_art_key(line(21, None), &legacy), None); + + let modern = [versioned(4, "26.1"), versioned(5, "26.1.2")]; + assert_eq!(line_title(line(26, Some(1)), &modern), "26.1"); + assert_eq!(line_art_key(line(26, Some(1)), &modern), Some((1, None))); + } + + #[test] + fn a_bare_legacy_version_keeps_its_line_name() { + let clusters = [versioned(1, "1.21")]; + + assert_eq!(line_title(line(21, None), &clusters), "1.21"); + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index 4adc5fa6..7cc844c1 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -5,7 +5,9 @@ use oneclient_core::VersionKey; use oneclient_core::clusters::Cluster; use oneclient_core::packages::domain::GameLoader; -use crate::components::{Button, ClusterLandscapeArt, Dropdown, Icon, IconType, MajorVersionCard}; +use crate::components::{ + Button, ClusterLandscapeArt, Dropdown, Icon, IconType, ScrollArea, VersionCard, +}; use crate::hooks::{ use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata, @@ -14,8 +16,8 @@ use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::{ - default_loader, default_major, default_version_key, group_clusters_by_major, loaders_for_major, - major_pretty_name, resolve_cluster, version_keys, version_label, + ReleaseLine, default_line, default_loader, default_version_key, group_clusters_by_release, + line_title, loaders_for_line, resolve_cluster, version_keys, version_label, }; use crate::view::app::launch_button_state; @@ -32,7 +34,7 @@ impl Component for Clusters { fn render(&self) -> impl IntoElement { let clusters_query = use_clusters(); let active_id = use_active_cluster_id(); - let mut selected_major = use_state(|| None::); + let mut selected_line = use_state(|| None::); let mut selected_version = use_state(|| None::); let mut selected_loader = use_state(|| None::); let mut grid_columns = use_state(|| 2_usize); @@ -48,10 +50,11 @@ impl Component for Clusters { } }; - let groups = group_clusters_by_major(&clusters); - let majors: Vec = groups.keys().copied().collect(); + let groups = group_clusters_by_release(&clusters); + // Newest version first. + let lines: Vec = groups.keys().rev().copied().collect(); - if majors.is_empty() { + if lines.is_empty() { return rect() .vertical() .width(Size::fill()) @@ -78,42 +81,42 @@ impl Component for Clusters { .read() .and_then(|id| clusters.iter().find(|c| c.id == id).cloned()); - if selected_major.read().is_none() { - *selected_major.write() = default_major(&groups, active_cluster.clone()); + if selected_line.read().is_none() { + *selected_line.write() = default_line(&groups, active_cluster.clone()); } - let major = (*selected_major.read()) - .filter(|m| groups.contains_key(m)) - .unwrap_or(majors[0]); + let line = (*selected_line.read()) + .filter(|l| groups.contains_key(l)) + .unwrap_or(lines[0]); - if *selected_major.read() != Some(major) { - *selected_major.write() = Some(major); + if *selected_line.read() != Some(line) { + *selected_line.write() = Some(line); } - let clusters_for_major = groups.get(&major).cloned().unwrap_or_default(); + let clusters_for_line = groups.get(&line).cloned().unwrap_or_default(); if selected_version.read().is_none() { let preferred = active_cluster .as_ref() .and_then(|c| oneclient_core::parse_mc_version(&c.mc_version)) .and_then(|p| p.key()); - *selected_version.write() = default_version_key(&clusters_for_major, preferred); + *selected_version.write() = default_version_key(&clusters_for_line, preferred); } if selected_loader.read().is_none() { let preferred = active_cluster.as_ref().map(|c| c.mc_loader); - *selected_loader.write() = default_loader(&clusters_for_major, preferred); + *selected_loader.write() = default_loader(&clusters_for_line, preferred); } let cluster = resolve_cluster( - &clusters_for_major, + &clusters_for_line, *selected_version.read(), *selected_loader.read(), ) - .or_else(|| clusters_for_major.first().cloned()); + .or_else(|| clusters_for_line.first().cloned()); let columns = *grid_columns.read(); - let grid_rows = chunk_majors(&majors, columns); + let grid_rows = chunk_lines(&lines, columns); rect() .vertical() @@ -142,42 +145,49 @@ impl Component for Clusters { .vertical() .width(Size::flex(1.0)) .height(Size::fill()) - .spacing(GRID_GAP_PX) - .children(grid_rows.into_iter().map(|row| { - let row_len = row.len(); - rect() - .horizontal() + .overflow(Overflow::Clip) + .child( + ScrollArea::new() .width(Size::fill()) - .content(Content::Flex) + .height(Size::fill()) .spacing(GRID_GAP_PX) - .children(row.into_iter().map(|major| { - let list = groups.get(&major).cloned().unwrap_or_default(); - let selected = *selected_major.read() == Some(major); - let mut selected_major = selected_major; - let mut selected_version = selected_version; - let mut selected_loader = selected_loader; - - MajorVersionCard::new(major, &list, selected, move |_| { - *selected_major.write() = Some(major); - *selected_version.write() = None; - *selected_loader.write() = None; - }) - .into_element() - })) - .children((row_len..columns).map(|_| { + .children(grid_rows.into_iter().map(|row| { + let row_len = row.len(); rect() - .width(Size::flex(1.0)) - .height(Size::px(CARD_HEIGHT_PX)) + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(GRID_GAP_PX) + .children(row.into_iter().map(|line| { + let list = + groups.get(&line).cloned().unwrap_or_default(); + let selected = *selected_line.read() == Some(line); + let mut selected_line = selected_line; + let mut selected_version = selected_version; + let mut selected_loader = selected_loader; + + VersionCard::new(line, &list, selected, move |_| { + *selected_line.write() = Some(line); + *selected_version.write() = None; + *selected_loader.write() = None; + }) + .into_element() + })) + .children((row_len..columns).map(|_| { + rect() + .width(Size::flex(1.0)) + .height(Size::px(CARD_HEIGHT_PX)) + .into_element() + })) .into_element() - })) - .into_element() - })), + })), + ), ) .child(match cluster { Some(cluster) => detail_sidebar( - major, + line, cluster, - &clusters_for_major, + &clusters_for_line, selected_version, selected_loader, ), @@ -188,9 +198,9 @@ impl Component for Clusters { } fn detail_sidebar( - major: u32, + line: ReleaseLine, cluster: Cluster, - clusters_for_major: &[Cluster], + clusters_for_line: &[Cluster], selected_version: State>, selected_loader: State>, ) -> Element { @@ -199,14 +209,14 @@ fn detail_sidebar( let game = use_game_snapshot(); let launcher = use_launcher(); let syncing = launcher.fetching || launcher.syncing_bundles; - let keys = version_keys(clusters_for_major); - let loaders = loaders_for_major(clusters_for_major); + let keys = version_keys(clusters_for_line); + let loaders = loaders_for_line(clusters_for_line); let cluster_id = cluster.id; - let version_title = major_pretty_name(major); + let version_title = line_title(line, clusters_for_line); let version_value = *selected_version.read(); let loader_value = *selected_loader.read(); - let metadata = use_version_metadata(Some(major), version_value, loader_value); + let metadata = use_version_metadata(Some(line.major), version_value, loader_value); let heading = metadata .as_ref() @@ -233,7 +243,7 @@ fn detail_sidebar( .border(border_all_color(1., colors::component_border())) .overflow(Overflow::Clip) .child(rect().width(Size::fill()).max_height(Size::px(140.)).child( - ClusterLandscapeArt::for_version(major, version_value, loader_value, false), + ClusterLandscapeArt::for_version(line.major, version_value, loader_value, false), )) .child( rect() @@ -263,7 +273,7 @@ fn detail_sidebar( .font_size(12.) .color(colors::fg_secondary()), ) - .children(version_rows(major, &keys, version_value, selected_version)) + .children(version_rows(line, &keys, version_value, selected_version)) .children(loader_rows(&loaders, loader_value, selected_loader)), ) .child( @@ -312,8 +322,10 @@ fn tags_row(tags: &[String]) -> Option { ) } +/// Only shown when the line holds more than one version — a single-version line +/// already names it on the card and in the heading. fn version_rows( - major: u32, + line: ReleaseLine, keys: &[VersionKey], selected: Option, mut selected_version: State>, @@ -322,14 +334,14 @@ fn version_rows( return None; } - let options: Vec = keys.iter().map(|k| version_label(major, *k)).collect(); + let options: Vec = keys.iter().map(|k| version_label(line.major, *k)).collect(); let current = selected .and_then(|s| keys.iter().position(|k| *k == s)) .unwrap_or(0); let keys = keys.to_vec(); Some(info_row( - "Minor Version", + "Version", Dropdown::new(options[current].clone(), options) .on_select(move |idx: usize| { if let Some(key) = keys.get(idx).copied() { @@ -458,16 +470,16 @@ fn sidebar_error() -> Element { ) .child( label() - .text("Try selecting a different minor version or mod loader.") + .text("Try selecting a different version or mod loader.") .font_size(14.) .color(colors::fg_secondary()), ) .into_element() } -fn chunk_majors(majors: &[u32], columns: usize) -> Vec> { +fn chunk_lines(lines: &[ReleaseLine], columns: usize) -> Vec> { let columns = columns.max(1); - majors.chunks(columns).map(|chunk| chunk.to_vec()).collect() + lines.chunks(columns).map(|chunk| chunk.to_vec()).collect() } fn grid_columns_for_width(available_width_px: f32) -> usize {