feat(ui): show all individual versions

This commit is contained in:
LynithDev 2026-07-25 20:18:12 +02:00
parent 76c4a03942
commit 57408aa710
6 changed files with 267 additions and 114 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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;

View file

@ -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<VersionKey>,
pub tags: Vec<String>,
pub title: String,
pub selected: bool,
pub on_press: EventHandler<Event<PressEventData>>,
}
impl MajorVersionCard {
impl VersionCard {
pub fn new(
major: u32,
line: ReleaseLine,
clusters: &[Cluster],
selected: bool,
on_press: impl Into<EventHandler<Event<PressEventData>>>,
) -> 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()

View file

@ -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<u32, Vec<Cluster>>;
pub type ClusterGroups = BTreeMap<ReleaseLine, Vec<Cluster>>;
/// 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<u32>,
}
impl ReleaseLine {
fn from_parsed(parsed: &ParsedMcVersion) -> Option<Self> {
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> {
Self::from_parsed(&parse_mc_version(version)?)
}
pub fn for_cluster(cluster: &Cluster) -> Option<Self> {
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<VersionKey> {
self.minor.map(|minor| (minor, None))
}
}
fn cluster_key(cluster: &Cluster) -> Option<VersionKey> {
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<VersionKey> {
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<VersionKey> {
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<String> {
let mut tags = Vec::new();
for cluster in clusters {
@ -67,7 +125,7 @@ pub fn version_keys(clusters: &[Cluster]) -> Vec<VersionKey> {
keys
}
pub fn loaders_for_major(clusters: &[Cluster]) -> Vec<GameLoader> {
pub fn loaders_for_line(clusters: &[Cluster]) -> Vec<GameLoader> {
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<Cluster>) -> Option<u32> {
/// The line the page opens on: the active cluster's line, else the newest.
pub fn default_line(groups: &ClusterGroups, active: Option<Cluster>) -> Option<ReleaseLine> {
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<GameLoader>) -> Option<GameLoader> {
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<u32>) -> ReleaseLine {
ReleaseLine { major, minor }
}
fn lines_of(clusters: &[Cluster]) -> Vec<ReleaseLine> {
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");
}
}

View file

@ -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::<u32>);
let mut selected_line = use_state(|| None::<ReleaseLine>);
let mut selected_version = use_state(|| None::<VersionKey>);
let mut selected_loader = use_state(|| None::<GameLoader>);
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<u32> = groups.keys().copied().collect();
let groups = group_clusters_by_release(&clusters);
// Newest version first.
let lines: Vec<ReleaseLine> = 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<Option<VersionKey>>,
selected_loader: State<Option<GameLoader>>,
) -> 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<Element> {
)
}
/// 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<VersionKey>,
mut selected_version: State<Option<VersionKey>>,
@ -322,14 +334,14 @@ fn version_rows(
return None;
}
let options: Vec<String> = keys.iter().map(|k| version_label(major, *k)).collect();
let options: Vec<String> = 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<Vec<u32>> {
fn chunk_lines(lines: &[ReleaseLine], columns: usize) -> Vec<Vec<ReleaseLine>> {
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 {