feat: better versions UI (#890)

This commit is contained in:
LynithDev 2026-09-29 20:58:57 +02:00 • committed by GitHub
parent b2a62f8e17
commit c9c4c9536b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1414 additions and 1285 deletions

View file

@ -1,45 +0,0 @@
use freya::prelude::*;
use oneclient_common::VersionKey;
use oneclient_common::domain::GameLoader;
use crate::components::{ART_PREVIEW_EDGE, DynamicArt};
use crate::theme::colors;
use crate::ui::border_all_color;
#[derive(PartialEq, Clone)]
pub struct ClusterLandscapeArt {
art: DynamicArt,
selected: bool,
}
impl ClusterLandscapeArt {
pub fn for_version(
major: u32,
key: Option<VersionKey>,
loader: Option<GameLoader>,
selected: bool,
) -> Self {
Self {
art: DynamicArt::for_version(major, key, loader).max_edge(ART_PREVIEW_EDGE),
selected,
}
}
}
impl Component for ClusterLandscapeArt {
fn render(&self) -> impl IntoElement {
let border = if self.selected {
border_all_color(2., colors::brand())
} else {
border_all_color(2., colors::component_border())
};
rect()
.width(Size::fill())
.height(Size::fill())
.corner_radius(CornerRadius::new_all(12.))
.overflow(Overflow::Clip)
.border(border.alignment(BorderAlignment::Inner))
.child(self.art.clone())
}
}

View file

@ -18,6 +18,7 @@ pub struct Dropdown {
width: Size,
height: Size,
outlined: bool,
checked: Option<Vec<bool>>,
key: DiffKey,
}
@ -31,6 +32,7 @@ impl Dropdown {
width: Size::px(72.),
height: Size::px(24.),
outlined: false,
checked: None,
key: DiffKey::None,
}
}
@ -52,6 +54,11 @@ impl Dropdown {
self
}
pub fn checked(mut self, checked: Vec<bool>) -> Self {
self.checked = Some(checked);
self
}
pub fn leading(mut self, leading: impl IntoElement) -> Self {
self.leading = Some(leading.into_element());
self
@ -78,19 +85,21 @@ impl Component for Dropdown {
let focus = use_focus(a11y_id);
let mut button_area = use_state(|| None::<Area>);
let mut list_size = use_state(|| None::<Size2D>);
let mut list_area = use_state(|| None::<Area>);
let selected = self.selected.clone();
let leading = self.leading.clone();
let options = self.options.clone();
let on_select = self.on_select.clone();
let outlined = self.outlined;
let checked = self.checked.clone();
let multi = checked.is_some();
let is_open = open();
use_overlay_claim_when(is_open);
if !is_open && list_size().is_some() {
let _ = list_size.take();
if !is_open && list_area().is_some() {
let _ = list_area.take();
}
let trigger_bg = if hovering() {
@ -103,13 +112,13 @@ impl Component for Dropdown {
let list_h =
visible as f32 * OPTION_HEIGHT + visible.saturating_sub(1) as f32 * OPTION_SPACING;
let offset_y = match (button_area(), list_size()) {
let offset_y = match (button_area(), list_area()) {
(Some(button), Some(list)) => {
let root_height = Platform::get().root_size.peek().height;
let space_below = root_height - button.max_y();
let space_above = button.min_y();
if list.height > space_below && list.height <= space_above {
-(button.height() + list.height + 8.)
if list.height() > space_below && list.height() <= space_above {
-(button.height() + list.height() + 8.)
} else {
0.
}
@ -121,8 +130,15 @@ impl Component for Dropdown {
.map(|b| Size::px(b.width()))
.unwrap_or_else(|| self.width.clone());
let on_global_pointer_up = move |_: Event<PointerEventData>| {
open.set_if_modified(false);
let on_global_pointer_up = move |e: Event<PointerEventData>| {
let loc = e.global_location();
let inside = list_area().is_some_and(|area| {
area.translate((0., offset_y).into())
.contains((loc.x as f32, loc.y as f32).into())
});
if !(multi && inside) {
open.set_if_modified(false);
}
};
rect()
@ -185,7 +201,7 @@ impl Component for Dropdown {
.offset_y(offset_y)
.content(Content::Fit)
.on_sized(move |e: Event<SizedEventData>| {
list_size.set_if_modified(Some(e.area.size));
list_area.set_if_modified(Some(e.area));
})
.child(
rect()
@ -202,11 +218,16 @@ impl Component for Dropdown {
.children(options.into_iter().enumerate().map(
|(idx, option)| {
let on_select = on_select.clone();
DropdownOption::new(option, move |_| {
let checked = checked
.as_ref()
.map(|c| c.get(idx).copied().unwrap_or(false));
DropdownOption::new(option, checked, move |_| {
if let Some(handler) = &on_select {
handler.call(idx);
}
open.set(false);
if !multi {
open.set(false);
}
})
.into_element()
},
@ -224,16 +245,19 @@ impl Component for Dropdown {
struct DropdownOption {
text: String,
checked: Option<bool>,
on_press: EventHandler<Event<PressEventData>>,
}
impl DropdownOption {
pub fn new(
text: impl Into<String>,
checked: Option<bool>,
on_press: impl Into<EventHandler<Event<PressEventData>>>,
) -> Self {
Self {
text: text.into(),
checked,
on_press: on_press.into(),
}
}
@ -241,7 +265,7 @@ impl DropdownOption {
impl PartialEq for DropdownOption {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
self.text == other.text && self.checked == other.checked
}
}
@ -254,7 +278,8 @@ impl Component for DropdownOption {
rect()
.width(Size::fill())
.padding(Gaps::new_symmetric(6., 8.))
.height(Size::px(OPTION_HEIGHT))
.padding(Gaps::new_symmetric(0., 8.))
.corner_radius(CornerRadius::new_all(6.))
.a11y_id(a11y_id)
.a11y_focusable(true)
@ -267,11 +292,27 @@ impl Component for DropdownOption {
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_press(self.on_press.clone())
.horizontal()
.content(Content::Flex)
.cross_align(Alignment::Center)
.child(
label()
.text(self.text.clone())
.width(Size::flex(1.))
.font_size(12.)
.color(colors::fg_primary()),
)
.maybe_child(self.checked.map(|checked| {
rect()
.width(Size::px(14.))
.height(Size::px(14.))
.maybe_child(checked.then(|| {
Icon::new(IconType::Check)
.size(14.)
.color(colors::brand())
.into_element()
}))
.into_element()
}))
}
}

View file

@ -1,4 +1,5 @@
use std::path::PathBuf;
use std::sync::LazyLock;
use bytes::Bytes;
use freya::animation::{
@ -115,23 +116,14 @@ impl DynamicArt {
pub fn use_art_bytes(art: &DynamicArt) -> (String, Bytes) {
let max_edge = art.max_edge;
let fallback = use_bundled_art(false);
use_resolved_art(art).unwrap_or_else(|| {
(
format!("{max_edge}|{HOME_BACKGROUND_ASSET}"),
fallback.read().clone(),
)
})
use_resolved_art(art)
.unwrap_or_else(|| (format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), bundled_art()))
}
fn use_bundled_art(skip: bool) -> Memo<Bytes> {
use_memo(move || {
if skip {
Bytes::new()
} else {
AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default()
}
})
fn bundled_art() -> Bytes {
static ART: LazyLock<Bytes> =
LazyLock::new(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default());
ART.clone()
}
fn use_resolved_art(art: &DynamicArt) -> Option<(String, Bytes)> {
@ -180,7 +172,6 @@ fn use_resolved_art(art: &DynamicArt) -> Option<(String, Bytes)> {
impl Component for DynamicArt {
fn render(&self) -> impl IntoElement {
let fallback = use_bundled_art(self.skeleton);
let resolved = use_resolved_art(self);
let show_skeleton = self.skeleton && resolved.is_none();
@ -209,12 +200,8 @@ impl Component for DynamicArt {
}
let max_edge = self.max_edge;
let (key, bytes) = resolved.unwrap_or_else(|| {
(
format!("{max_edge}|{HOME_BACKGROUND_ASSET}"),
fallback.read().clone(),
)
});
let (key, bytes) = resolved
.unwrap_or_else(|| (format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), bundled_art()));
ImageViewer::new((key, bytes))
.width(Size::fill())

View file

@ -0,0 +1,245 @@
use freya::animation::{AnimNum, Ease, OnCreation, use_animation};
use freya::prelude::*;
use crate::components::{Button, Icon, IconType};
use crate::hooks::use_overlay_claim;
use crate::theme::colors;
const PANEL_W: f32 = 172.;
const BUTTON_W: f32 = 34.;
#[derive(Clone, PartialEq)]
pub struct FilterOption {
text: String,
selected: bool,
on_press: EventHandler<()>,
}
impl FilterOption {
pub fn new(
text: impl Into<String>,
selected: bool,
on_press: impl Into<EventHandler<()>>,
) -> Self {
Self {
text: text.into(),
selected,
on_press: on_press.into(),
}
}
}
#[derive(Clone, PartialEq)]
struct FilterSection {
title: &'static str,
options: Vec<FilterOption>,
}
#[derive(PartialEq)]
pub struct FilterMenu {
active: bool,
sections: Vec<FilterSection>,
}
impl FilterMenu {
pub fn new(active: bool) -> Self {
Self {
active,
sections: Vec::new(),
}
}
pub fn section(
mut self,
title: &'static str,
options: impl IntoIterator<Item = FilterOption>,
) -> Self {
self.sections.push(FilterSection {
title,
options: options.into_iter().collect(),
});
self
}
}
impl Component for FilterMenu {
fn render(&self) -> impl IntoElement {
let mut open = use_state(|| false);
let icon_color = if self.active {
colors::brand()
} else {
colors::fg_secondary()
};
rect()
.width(Size::px(BUTTON_W))
.child(
Button::new()
.secondary()
.icon()
.tooltip("Sort and filter")
.width(Size::px(BUTTON_W))
.height(Size::px(34.))
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
open.toggle();
})
.child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)),
)
.maybe_child(open().then(|| {
FilterPanel {
sections: self.sections.clone(),
on_close: (move |()| open.set(false)).into(),
}
.into_element()
}))
}
}
#[derive(PartialEq)]
struct FilterPanel {
sections: Vec<FilterSection>,
on_close: EventHandler<()>,
}
impl Component for FilterPanel {
fn render(&self) -> impl IntoElement {
use_overlay_claim();
let backdrop_close = self.on_close.clone();
let key_close = self.on_close.clone();
let a11y_id = use_a11y();
let fade = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.).time(160).ease(Ease::Out)
});
let progress = fade.read().value();
let mut panel = rect()
.vertical()
.width(Size::fill())
.spacing(4.)
.padding(Gaps::new_all(8.))
.opacity(progress)
.offset_y((progress - 1.0) * 6.)
.corner_radius(CornerRadius::new_all(10.))
.background(colors::page_elevated().with_a(230))
.backdrop_blur(12.)
.border(crate::ui::border_all_color(1., colors::component_border()))
.shadow(Shadow::from((
0.,
8.,
32.,
0.,
Color::from_argb(120, 0, 0, 0),
)))
.a11y_id(a11y_id)
.a11y_role(AccessibilityRole::Dialog)
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape) {
key_close.call(());
}
});
for section in &self.sections {
panel = panel.child(section_label(section.title));
for option in &section.options {
panel = panel.child(ChoiceRow {
option: option.clone(),
});
}
}
rect()
.height(Size::px(0.))
.width(Size::px(PANEL_W))
.layer(Layer::Overlay)
.child(
rect()
.layer(Layer::OverlayLevel(10))
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.on_press(move |_| backdrop_close.call(())),
)
.child(
rect()
.width(Size::fill())
.layer(Layer::OverlayLevel(12))
.margin(Gaps::new(6., 0., 0., -(PANEL_W - BUTTON_W)))
.child(panel),
)
}
}
fn section_label(text: &'static str) -> impl IntoElement {
label()
.text(text)
.font_size(10.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_secondary())
}
#[derive(PartialEq)]
struct ChoiceRow {
option: FilterOption,
}
impl Component for ChoiceRow {
fn render(&self) -> impl IntoElement {
let selected = self.option.selected;
let on_press = self.option.on_press.clone();
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let color = if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
};
let bg = if selected {
colors::component_bg()
} else if focused {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
};
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.padding(Gaps::new_symmetric(5., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(bg)
.content(Content::Flex)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::MenuItemRadio)
.maybe(focused, |el| {
el.border(crate::ui::border_all_color(1., colors::brand()))
})
.cursor(CursorIcon::Pointer)
.on_press(move |_| on_press.call(()))
.child(
label()
.text(self.option.text.clone())
.font_size(12.)
.width(Size::flex(1.0))
.color(color),
)
.maybe_child(selected.then(|| {
Icon::new(IconType::Check)
.size(14.)
.color(colors::brand())
.into_element()
}))
}
}

View file

@ -1,152 +0,0 @@
use freya::prelude::*;
use oneclient_core::clusters::Cluster;
use crate::components::{ART_PREVIEW_EDGE, DynamicArt};
use crate::theme::colors;
use crate::ui::border_all_color;
const CARD_HEIGHT_PX: f32 = 240.;
pub struct InstanceCard {
id: i64,
title: String,
tags: Vec<String>,
art: DynamicArt,
selected: bool,
on_press: EventHandler<Event<PressEventData>>,
}
impl InstanceCard {
pub fn new(
cluster: &Cluster,
selected: bool,
on_press: impl Into<EventHandler<Event<PressEventData>>>,
) -> Self {
Self {
id: cluster.id,
title: cluster.name.clone(),
tags: cluster.tags.clone(),
art: DynamicArt::for_cluster(cluster).max_edge(ART_PREVIEW_EDGE),
selected,
on_press: on_press.into(),
}
}
}
impl PartialEq for InstanceCard {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.title == other.title
&& self.tags == other.tags
&& self.art == other.art
&& self.selected == other.selected
}
}
impl Component for InstanceCard {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let selected = self.selected;
let hovered = *hovering.read();
let focused = focus().is_focused();
let on_press = self.on_press.clone();
let opacity = if selected {
1.0
} else if hovered || focused {
0.85
} else {
0.6
};
let border_color = if selected || focused {
colors::brand()
} else if hovered {
colors::component_border_hover()
} else {
colors::component_border()
};
rect()
.key(self.id)
.width(Size::flex(1.0))
.height(Size::px(CARD_HEIGHT_PX))
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_press(move |e| on_press.call(e))
.on_pointer_enter(move |_| {
*hovering.write() = true;
})
.on_pointer_leave(move |_| {
*hovering.write() = false;
})
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.overflow(Overflow::Clip)
.corner_radius(CornerRadius::new_all(12.))
.opacity(opacity)
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.position(Position::new_absolute())
.child(self.art.clone()),
)
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.padding(Gaps::new_symmetric(12., 16.))
.main_align(Alignment::SpaceBetween)
.corner_radius(CornerRadius::new_all(12.))
.cross_align(Alignment::Start)
.border(
border_all_color(1., border_color)
.alignment(BorderAlignment::Inner),
)
.layer(Layer::Relative(3))
.background(
LinearGradient::new()
.angle(0.)
.stop((Color::from_af32rgb(0.8, 0, 0, 0), 0.))
.stop((Color::from_af32rgb(0.3, 0, 0, 0), 20.))
.stop((Color::from_af32rgb(0.3, 0, 0, 0), 60.))
.stop((Color::from_af32rgb(0.8, 0, 0, 0), 100.)),
)
.child(
rect()
.horizontal()
.spacing(8.)
.children(self.tags.iter().map(|tag| {
rect()
.padding(Gaps::new_symmetric(4., 8.))
.corner_radius(CornerRadius::new_all(999.))
.background(colors::fg_primary())
.child(
label()
.text(tag.clone())
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.color(colors::brand()),
)
.into_element()
})),
)
.child(
label()
.text(self.title.clone())
.font_size(28.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
),
),
)
}
}

View file

@ -0,0 +1,135 @@
use freya::prelude::*;
use oneclient_core::clusters::Cluster;
use crate::components::{ART_PREVIEW_EDGE, DynamicArt};
use crate::theme::colors;
use crate::ui::{border_all_color, last_played_label};
const ROW_HEIGHT_PX: f32 = 56.;
const THUMB_PX: f32 = 40.;
pub struct InstanceRow {
id: i64,
title: String,
subtitle: String,
played: String,
art: DynamicArt,
selected: bool,
on_press: EventHandler<Event<PressEventData>>,
}
impl InstanceRow {
pub fn new(
cluster: &Cluster,
selected: bool,
on_press: impl Into<EventHandler<Event<PressEventData>>>,
) -> Self {
Self {
id: cluster.id,
title: cluster.name.clone(),
subtitle: format!("{} \u{b7} {}", cluster.mc_version, cluster.mc_loader),
played: last_played_label(cluster.last_played),
art: DynamicArt::for_cluster(cluster).max_edge(ART_PREVIEW_EDGE),
selected,
on_press: on_press.into(),
}
}
}
impl PartialEq for InstanceRow {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.title == other.title
&& self.subtitle == other.subtitle
&& self.played == other.played
&& self.art == other.art
&& self.selected == other.selected
}
}
impl Component for InstanceRow {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let selected = self.selected;
let hovered = *hovering.read();
let focused = focus().is_focused();
let on_press = self.on_press.clone();
let background = if selected {
colors::brand().with_a(36)
} else if hovered {
colors::component_bg_hover()
} else {
colors::component_bg()
};
let border_color = if selected || focused {
colors::brand()
} else if hovered {
colors::component_border_hover()
} else {
colors::component_border()
};
rect()
.key(self.id)
.horizontal()
.width(Size::fill())
.height(Size::px(ROW_HEIGHT_PX))
.padding(Gaps::new(0., 14., 0., 8.))
.spacing(14.)
.cross_align(Alignment::Center)
.content(Content::Flex)
.corner_radius(CornerRadius::new_all(12.))
.background(background)
.border(border_all_color(1., border_color).alignment(BorderAlignment::Inner))
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_press(move |e| on_press.call(e))
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.child(
rect()
.width(Size::px(THUMB_PX))
.height(Size::px(THUMB_PX))
.corner_radius(CornerRadius::new_all(8.))
.overflow(Overflow::Clip)
.child(self.art.clone()),
)
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(3.)
.child(
label()
.text(self.title.clone())
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.color(colors::fg_primary()),
)
.child(
label()
.text(self.subtitle.clone())
.font_size(12.)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.color(colors::fg_secondary()),
),
)
.child(
label()
.text(self.played.clone())
.font_size(12.)
.max_lines(1)
.color(colors::fg_secondary()),
)
}
}

View file

@ -5,7 +5,6 @@ mod avatar;
mod button;
mod charts;
mod checkbox;
mod cluster_landscape_art;
mod cluster_update_popup;
mod context_menu;
mod control_center;
@ -14,10 +13,11 @@ mod dropdown;
mod dynamic_art;
mod essential_confirm;
mod file_drop;
mod filter_menu;
mod flagged_install_popup;
mod generic_prompt;
mod icons;
mod instance_card;
mod instance_row;
mod java_install_manager;
mod java_prompt;
mod link_confirm;
@ -62,7 +62,6 @@ pub use avatar::Avatar;
pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button};
pub use charts::{BarChart, PieChart, ValueUnit, slice_color};
pub use checkbox::checkbox_labeled;
pub use cluster_landscape_art::ClusterLandscapeArt;
pub use cluster_update_popup::ClusterUpdatePopup;
pub use context_menu::ContextMenu;
pub use control_center::ControlCenter;
@ -71,10 +70,11 @@ pub use dropdown::Dropdown;
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
pub use essential_confirm::EssentialConfirmOverlay;
pub use file_drop::FileDropOverlay;
pub use filter_menu::{FilterMenu, FilterOption};
pub use flagged_install_popup::FlaggedInstallPopup;
pub use generic_prompt::GenericPromptOverlay;
pub use icons::{Icon, IconTint, IconType};
pub use instance_card::InstanceCard;
pub use instance_row::InstanceRow;
pub use java_install_manager::JavaInstallManager;
pub use java_prompt::JavaPromptOverlay;
pub use link_confirm::ConfirmLinkOverlay;

View file

@ -2,18 +2,19 @@ use freya::prelude::*;
use oneclient_common::VersionKey;
use oneclient_core::clusters::Cluster;
use crate::components::ClusterLandscapeArt;
use crate::components::{ART_PREVIEW_EDGE, DynamicArt};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::{ReleaseLine, line_art_key, line_title, loader_tags};
use crate::utils::{ReleaseLine, line_art_key, line_title};
const CARD_HEIGHT_PX: f32 = 240.;
const CARD_HEIGHT_PX: f32 = 150.;
pub struct VersionCard {
pub line: ReleaseLine,
pub art_key: Option<VersionKey>,
pub tags: Vec<String>,
pub title: String,
pub caption: String,
pub count: usize,
pub selected: bool,
pub on_press: EventHandler<Event<PressEventData>>,
}
@ -22,14 +23,16 @@ impl VersionCard {
pub fn new(
line: ReleaseLine,
clusters: &[Cluster],
caption: String,
selected: bool,
on_press: impl Into<EventHandler<Event<PressEventData>>>,
) -> Self {
Self {
line,
art_key: line_art_key(line, clusters),
tags: loader_tags(clusters),
title: line_title(line, clusters),
caption,
count: clusters.len(),
selected,
on_press: on_press.into(),
}
@ -40,8 +43,9 @@ impl PartialEq for VersionCard {
fn eq(&self, other: &Self) -> bool {
self.line == other.line
&& self.art_key == other.art_key
&& self.tags == other.tags
&& self.title == other.title
&& self.caption == other.caption
&& self.count == other.count
&& self.selected == other.selected
}
}
@ -58,101 +62,94 @@ impl Component for VersionCard {
let focused = focus().is_focused();
let on_press = self.on_press.clone();
let opacity = if selected || hovered || focused {
if selected { 1.0 } else { 0.85 }
let art_opacity = if selected {
1.0
} else if hovered || focused {
0.85
} else {
0.6
};
let border_color = if selected || focused {
colors::brand()
let border = if selected || focused {
border_all_color(2., colors::brand())
} else if hovered {
colors::component_border_hover()
border_all_color(1., colors::component_border_hover())
} else {
colors::component_border()
border_all_color(1., colors::component_border())
};
rect()
.key(self.line)
.width(Size::flex(1.0))
.width(Size::fill())
.height(Size::px(CARD_HEIGHT_PX))
.corner_radius(CornerRadius::new_all(16.))
.overflow(Overflow::Clip)
.background(colors::page_elevated())
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_press(move |e| on_press.call(e))
.on_pointer_enter(move |_| {
*hovering.write() = true;
})
.on_pointer_leave(move |_| {
*hovering.write() = false;
})
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.overflow(Overflow::Clip)
.corner_radius(CornerRadius::new_all(12.))
.opacity(opacity)
.position(Position::new_absolute())
.opacity(art_opacity)
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.position(Position::new_absolute())
.child(ClusterLandscapeArt::for_version(
self.line.major,
self.art_key,
None,
false,
)),
)
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.padding(Gaps::new_symmetric(12., 16.))
.main_align(Alignment::SpaceBetween)
.corner_radius(CornerRadius::new_all(12.))
.cross_align(Alignment::Start)
.border(
border_all_color(1., border_color)
.alignment(BorderAlignment::Inner),
)
.layer(Layer::Relative(3))
.background(
LinearGradient::new()
.angle(0.)
.stop((Color::from_af32rgb(0.8, 0, 0, 0), 0.))
.stop((Color::from_af32rgb(0.3, 0, 0, 0), 20.))
.stop((Color::from_af32rgb(0.3, 0, 0, 0), 60.))
.stop((Color::from_af32rgb(0.8, 0, 0, 0), 100.)),
)
.child(
rect()
.horizontal()
.spacing(8.)
.children(self.tags.iter().map(|tag| {
rect()
.padding(Gaps::new_symmetric(4., 8.))
.corner_radius(CornerRadius::new_all(999.))
.background(colors::fg_primary())
.child(
label()
.text(tag.clone())
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.color(colors::brand()),
)
.into_element()
})),
)
.child(
label()
.text(format!("Version {}", self.title))
.font_size(32.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
),
DynamicArt::for_version(self.line.major, self.art_key, None)
.max_edge(ART_PREVIEW_EDGE),
),
)
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.padding(12.)
.spacing(5.)
.main_align(Alignment::End)
.corner_radius(CornerRadius::new_all(16.))
.border(border.alignment(BorderAlignment::Inner))
.layer(Layer::Relative(3))
.background(
LinearGradient::new()
.angle(0.)
.stop((Color::from_af32rgb(0.1, 11, 16, 19), 0.))
.stop((Color::from_af32rgb(0.6, 11, 16, 19), 55.))
.stop((Color::from_af32rgb(0.95, 11, 16, 19), 100.)),
)
.child(
label()
.text(self.title.clone())
.font_size(22.)
.font_weight(FontWeight::SEMI_BOLD)
.color(Color::WHITE),
)
.child(
label()
.text(self.caption.clone())
.font_size(11.)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.color(colors::fg_secondary()),
),
)
.maybe_child((self.count > 1).then(|| {
rect()
.position(Position::new_absolute().top(8.).right(8.))
.layer(Layer::Relative(4))
.padding(Gaps::new_symmetric(4., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(Color::from_af32rgb(0.66, 11, 16, 19))
.child(
label()
.text(format!("{} instances", self.count))
.font_size(11.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_primary()),
)
.into_element()
}))
}
}

View file

@ -31,7 +31,7 @@ pub use queries::{
CachedImageQuery, CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction,
ClusterBundles, ClusterLogsQuery, ClusterMutation, DisableWarningsQuery, DiscardLeftoversKeys,
DiscardLeftoversMutation, FinishMicrosoftLoginMutation, GameVersion, JavaMajorsQuery,
LeftoversQuery, LoaderVersionSet, LogAction, LogContentQuery, MigrationQuery,
LeftoversQuery, LogAction, LogContentQuery, MigrationQuery,
OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction,
SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery,
UploadLogKeys, UploadLogMutation, UseDiscardLeftovers, UseLogAction, UseRefreshAccount,
@ -43,7 +43,7 @@ pub use queries::{
invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries,
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
invalidate_storage_queries, java_majors, java_runtimes, latest_changelog_version, loaded_image,
loader_game_versions, loader_versions, login_code_already_handled, migration_detection,
loader_versions, login_code_already_handled, migration_detection,
mutation_error, mutation_is_pending, mutation_is_running, mutation_ok,
onboarding_bundles_items, package_meta_batch, package_updates, pick_version_metadata,
project_detail, provider_versions, query_error, query_is_busy, query_is_loading,
@ -59,7 +59,7 @@ pub use queries::{
use_cluster_profile, use_cluster_screenshots, use_cluster_settings, use_clusters,
use_current_account, use_default_account, use_disable_warnings, use_discard_leftovers,
use_finish_microsoft_login, use_game_profile, use_game_versions, use_global_analytics,
use_java_majors, use_java_runtimes, use_leftovers, use_loader_game_versions,
use_java_majors, use_java_runtimes, use_leftovers,
use_loader_versions, use_local_image, use_log_action, use_log_content, 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,
@ -67,7 +67,7 @@ pub use queries::{
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, version_loaders, versions_metadata,
use_version_metadata, use_versions, version_list, versions_metadata,
versions_total,
};
pub use queries::{

View file

@ -107,10 +107,10 @@ pub use version_metadata::{
use_version_art_gallery, use_version_metadata,
};
pub use versions::{
GameVersion, JavaMajorsQuery, LoaderVersionSet, game_versions, java_majors,
loader_game_versions, loader_versions, use_game_versions, use_java_majors,
use_loader_game_versions, use_loader_versions, use_version_loaders, use_versions,
version_loaders, versions_metadata,
GameVersion, JavaMajorsQuery, game_versions, java_majors,
loader_versions, use_game_versions, use_java_majors,
use_loader_versions, use_version_loaders, use_versions,
versions_metadata,
};
pub use worlds::{
add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents,

View file

@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::Arc;
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
@ -216,72 +216,6 @@ pub fn use_version_loaders(mc_version: String) -> UseQuery<VersionLoadersQuery>
))
}
pub fn version_loaders(query: &UseQuery<VersionLoadersQuery>) -> Vec<GameLoader> {
super::state::settled_or_loading(query).unwrap_or_default()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct LoaderGameVersionsQuery;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct LoaderGameVersionsKeys {
pub loader: GameLoader,
pub with_legacy: bool,
}
pub type LoaderVersionSet = Option<Arc<HashSet<String>>>;
impl QueryCapability for LoaderGameVersionsQuery {
type Ok = LoaderVersionSet;
type Err = LauncherError;
type Keys = LoaderGameVersionsKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = crate::launcher::state()?;
let keys = *keys;
tokio::spawn(async move {
let mc = state.services.mc();
let mut metadata = state.metadata.lock().await;
let Some(mut ids) =
oneclient_core::get_versions_for_loader(&mut metadata, &mc, keys.loader).await?
else {
return Ok(None);
};
if keys.with_legacy
&& let Some(legacy) =
oneclient_core::get_versions_for_loader(&mut metadata, &mc, GameLoader::Ornithe)
.await?
{
ids.extend(legacy);
}
Ok(Some(Arc::new(ids.into_iter().collect::<HashSet<String>>())))
})
.await
.map_err(|err| LauncherError::Minecraft(err.to_string()))?
}
}
pub fn use_loader_game_versions(
loader: GameLoader,
with_legacy: bool,
) -> UseQuery<LoaderGameVersionsQuery> {
use_query(Query::new(
LoaderGameVersionsKeys {
loader,
with_legacy,
},
LoaderGameVersionsQuery,
))
}
pub fn loader_game_versions(query: &UseQuery<LoaderGameVersionsQuery>) -> Option<LoaderVersionSet> {
super::state::settled_or_loading(query)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct JavaMajorsQuery;

View file

@ -92,6 +92,21 @@ pub fn relative_time(created_at: Instant) -> String {
}
}
pub fn last_played_label(ts: Option<chrono::DateTime<chrono::Utc>>) -> String {
let Some(ts) = ts else {
return "Never".to_string();
};
match (chrono::Utc::now() - ts).num_days() {
..=0 => "Today".to_string(),
1 => "Yesterday".to_string(),
d @ 2..=6 => format!("{d} days ago"),
d @ 7..=13 => format!("{} week ago", d / 7),
d @ 14..=29 => format!("{} weeks ago", d / 7),
d @ 30..=59 => format!("{} month ago", d / 30),
d => format!("{} months ago", d / 30),
}
}
/// Gap the clamped menu keeps from the window edges
pub const EDGE_MARGIN: f32 = 8.;

View file

@ -113,20 +113,6 @@ pub fn group_clusters_by_release<'a>(
groups
}
pub fn loader_tags(clusters: &[Cluster]) -> Vec<String> {
let mut tags = Vec::new();
for cluster in clusters {
if cluster.mc_loader.is_modded() {
let label = cluster.mc_loader.to_string();
if !tags.iter().any(|t| t == &label) {
tags.push(label);
}
}
}
tags.sort();
tags
}
pub fn version_keys(clusters: &[Cluster]) -> Vec<VersionKey> {
let mut keys = Vec::new();
for cluster in clusters {

View file

@ -67,26 +67,21 @@ impl Component for ClusterSettings {
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 instance_row = cluster.user_created.then(|| {
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()
});
let mod_loader = !cluster.lacks_mod_loader();
let loader_section: Vec<Element> = if mod_loader {
@ -97,7 +92,6 @@ impl Component for ClusterSettings {
loader,
selected: cluster.mc_loader_version.clone(),
versions,
locked: cluster.user_created,
}
.into_element(),
]
@ -117,7 +111,16 @@ impl Component for ClusterSettings {
.height(Size::fill())
.scrollbar_gutter(true)
.spacing(4.)
.children(instance_section)
.child(section_header("INSTANCE"))
.append_children(instance_row)
.child(
DedicatedDirRow {
cluster_id,
dedicated: cluster.uses_dedicated_dir(),
locked: cluster.is_isolated(),
}
.into_element(),
)
.child(section_header("GAME"))
.child(
ToggleRow {
@ -146,15 +149,6 @@ impl Component for ClusterSettings {
.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"))
@ -955,7 +949,6 @@ struct LoaderRow {
loader: GameLoader,
selected: Option<String>,
versions: Arc<[String]>,
locked: bool,
}
impl Component for LoaderRow {
@ -968,19 +961,6 @@ impl Component for LoaderRow {
.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")

View file

@ -1,15 +1,15 @@
use super::*;
use freya::animation::{AnimNum, Ease, OnCreation, use_animation};
use freya::router::RouterContext;
use oneclient_content::packages::ContentType;
use oneclient_core::settings::ViewLayout;
use crate::components::{
Button, CardLayout, ChevronToggle, Icon, IconType, LazySection, PackageEntry, PackageRow,
ScrollArea, Segment, SegmentedControl, TextInput, package_context_menu, use_shared_delete,
Button, CardLayout, ChevronToggle, FilterMenu, FilterOption, Icon, IconType, LazySection,
PackageEntry, PackageRow, ScrollArea, Segment, SegmentedControl, TextInput,
package_context_menu, use_shared_delete,
};
use crate::hooks::{ClusterAction, use_cluster_mutation, use_dispatch, use_overlay_claim};
use crate::hooks::{ClusterAction, use_cluster_mutation, use_dispatch};
use crate::routes::Route;
use crate::theme::colors;
use crate::{Actions, utils};
@ -120,8 +120,6 @@ impl HiddenFilter {
}
}
const FILTER_PANEL_W: f32 = 172.;
const FILTER_BTN_W: f32 = 34.;
const TOOLBAR_STACK_W: f32 = 640.;
const TABS_ROW_H: f32 = 34.;
@ -199,13 +197,13 @@ pub(super) fn toolbar_bar(
.into_element(),
)
.into_element(),
FilterButton {
filter_button(
sort,
current_sort,
enabled_filter,
hidden_filter,
uses_bundles,
}
)
.into_element(),
];
@ -405,260 +403,50 @@ pub(crate) fn notice_bar(text: String) -> Element {
.into_element()
}
#[derive(PartialEq)]
struct FilterButton {
sort: State<Option<String>>,
fn filter_button(
mut sort: State<Option<String>>,
current_sort: SortMode,
enabled_filter: State<EnabledFilter>,
hidden_filter: State<HiddenFilter>,
mut enabled_filter: State<EnabledFilter>,
mut hidden_filter: State<HiddenFilter>,
uses_bundles: bool,
}
) -> FilterMenu {
let show = *enabled_filter.read();
let hidden = *hidden_filter.read();
impl Component for FilterButton {
fn render(&self) -> impl IntoElement {
let mut open = use_state(|| false);
// Hiding hidden packages is the default so it does not count as the filters being touched
let active = current_sort != SortMode::NameAsc
|| show != EnabledFilter::All
|| hidden != HiddenFilter::Hide;
let sort = self.sort;
let current_sort = self.current_sort;
let enabled_filter = self.enabled_filter;
let hidden_filter = self.hidden_filter;
let uses_bundles = self.uses_bundles;
// Hiding hidden packages is the default so it does not count as the filters being touched
let is_open = open();
let active = current_sort != SortMode::NameAsc
|| *enabled_filter.read() != EnabledFilter::All
|| *hidden_filter.read() != HiddenFilter::Hide;
let icon_color = if active {
colors::brand()
} else {
colors::fg_secondary()
};
rect()
.width(Size::px(FILTER_BTN_W))
.child(
Button::new()
.secondary()
.icon()
.tooltip("Sort and filter")
.width(Size::px(FILTER_BTN_W))
.height(Size::px(34.))
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
open.toggle();
})
.child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)),
)
.maybe_child(is_open.then(|| {
let on_close: EventHandler<()> = (move |()| open.set(false)).into();
FilterPopover {
sort,
current_sort,
enabled_filter,
hidden_filter,
uses_bundles,
on_close,
}
.into_element()
}))
}
}
#[derive(PartialEq)]
struct FilterPopover {
sort: State<Option<String>>,
current_sort: SortMode,
enabled_filter: State<EnabledFilter>,
hidden_filter: State<HiddenFilter>,
uses_bundles: bool,
on_close: EventHandler<()>,
}
impl Component for FilterPopover {
fn render(&self) -> impl IntoElement {
use_overlay_claim();
let mut sort = self.sort;
let current_sort = self.current_sort;
let mut enabled_filter = self.enabled_filter;
let mut hidden_filter = self.hidden_filter;
let backdrop_close = self.on_close.clone();
let key_close = self.on_close.clone();
let a11y_id = use_a11y();
let fade = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.).time(160).ease(Ease::Out)
});
let progress = fade.read().value();
let show = *enabled_filter.read();
let hidden = *hidden_filter.read();
let mut panel = rect()
.vertical()
.width(Size::fill())
.spacing(4.)
.padding(Gaps::new_all(8.))
.opacity(progress)
.offset_y((progress - 1.0) * 6.)
.corner_radius(CornerRadius::new_all(10.))
.background(colors::page_elevated().with_a(230))
.backdrop_blur(12.)
.border(crate::ui::border_all_color(1., colors::component_border()))
.shadow(Shadow::from((
0.,
8.,
32.,
0.,
Color::from_argb(120, 0, 0, 0),
)))
.a11y_id(a11y_id)
.a11y_role(AccessibilityRole::Dialog)
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape) {
key_close.call(());
}
})
.child(section_label("Sort by"));
for mode in SortMode::ALL {
let selected = mode == current_sort;
let on_press: EventHandler<Event<PressEventData>> = (move |_| {
sort.set(Some(mode.key().to_string()));
})
.into();
panel = panel.child(ChoiceRow {
text: mode.label(),
selected,
on_press,
});
}
panel = panel.child(section_label("Show"));
for filter in EnabledFilter::ALL {
let selected = filter == show;
let on_press: EventHandler<Event<PressEventData>> = (move |_| {
enabled_filter.set(filter);
})
.into();
panel = panel.child(ChoiceRow {
text: filter.label(),
selected,
on_press,
});
}
if self.uses_bundles {
panel = panel.child(section_label("Hidden packages"));
for filter in HiddenFilter::ALL {
let selected = filter == hidden;
let on_press: EventHandler<Event<PressEventData>> = (move |_| {
hidden_filter.set(filter);
let menu = FilterMenu::new(active)
.section(
"Sort by",
SortMode::ALL.map(|mode| {
FilterOption::new(mode.label(), mode == current_sort, move |()| {
sort.set(Some(mode.key().to_string()));
})
.into();
panel = panel.child(ChoiceRow {
text: filter.label(),
selected,
on_press,
});
}
}
}),
)
.section(
"Show",
EnabledFilter::ALL.map(|filter| {
FilterOption::new(filter.label(), filter == show, move |()| {
enabled_filter.set(filter);
})
}),
);
rect()
.height(Size::px(0.))
.width(Size::px(FILTER_PANEL_W))
.layer(Layer::Overlay)
.child(
rect()
.layer(Layer::OverlayLevel(10))
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.on_press(move |_| backdrop_close.call(())),
)
.child(
rect()
.width(Size::fill())
.layer(Layer::OverlayLevel(12))
.margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W)))
.child(panel),
)
if !uses_bundles {
return menu;
}
}
fn section_label(text: &'static str) -> impl IntoElement {
label()
.text(text)
.font_size(10.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_secondary())
}
#[derive(PartialEq)]
struct ChoiceRow {
text: &'static str,
selected: bool,
on_press: EventHandler<Event<PressEventData>>,
}
impl Component for ChoiceRow {
fn render(&self) -> impl IntoElement {
let text = self.text;
let selected = self.selected;
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let color = if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
};
let bg = if selected {
colors::component_bg()
} else if focused {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
};
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.padding(Gaps::new_symmetric(5., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(bg)
.content(Content::Flex)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::MenuItemRadio)
.maybe(focused, |el| {
el.border(crate::ui::border_all_color(1., colors::brand()))
menu.section(
"Hidden packages",
HiddenFilter::ALL.map(|filter| {
FilterOption::new(filter.label(), filter == hidden, move |()| {
hidden_filter.set(filter);
})
.cursor(CursorIcon::Pointer)
.on_press(self.on_press.clone())
.child(
label()
.text(text)
.font_size(12.)
.width(Size::flex(1.0))
.color(color),
)
.maybe_child(selected.then(|| {
Icon::new(IconType::Check)
.size(14.)
.color(colors::brand())
.into_element()
}))
}
}),
)
}
fn add_from_file_button(

View file

@ -11,8 +11,12 @@ pub fn heading(picks: &Picks) -> (&'static str, String) {
),
Step::Loader => (
"Choose a mod loader",
"Loaders are what packages install into. Pick one, then the Minecraft version it runs on."
.to_string(),
match &picks.versions.chosen {
Some(version) => format!(
"Loaders are what packages install into. Only the ones with a build for {version} can be picked."
),
None => "Loaders are what packages install into.".to_string(),
},
),
Step::Version => (
"Choose a version",
@ -20,10 +24,10 @@ pub fn heading(picks: &Picks) -> (&'static str, String) {
TypeChoice::OneClient => {
"Only versions OneClient ships a build for are listed.".to_string()
}
TypeChoice::Scratch => format!(
"Versions {} can run. Switch the release type to reach snapshots, betas and alphas.",
picks.loader_label()
),
TypeChoice::Scratch => {
"Every Minecraft version. Switch the release type to reach snapshots, betas and alphas."
.to_string()
}
},
),
Step::Bundles => (
@ -47,15 +51,13 @@ pub fn footer_note(picks: &Picks) -> String {
}
TypeChoice::Scratch => "Keeps its own game folder, worlds and packs.".to_string(),
},
Step::Loader => picks.loader_label(),
Step::Version => match (&picks.versions.chosen, picks.loader.chosen) {
(Some(version), Some(_)) => {
format!("{version} downloads the first time you launch it.")
}
(Some(version), None) => {
format!("{} has no build for {version}.", picks.loader_label())
}
(None, _) => "Pick a version to continue.".to_string(),
Step::Loader => match (&picks.versions.chosen, picks.loader.chosen) {
(Some(version), None) => format!("Pick a loader with a build for {version}."),
_ => picks.loader_label(),
},
Step::Version => match &picks.versions.chosen {
Some(version) => format!("{version} downloads the first time you launch it."),
None => "Pick a version to continue.".to_string(),
},
Step::Bundles => {
let taken = picks.bundles.taken_count();
@ -100,8 +102,12 @@ pub fn step_value(wizard: Wizard, picks: &Picks, step: Step) -> String {
pub fn is_ready(picks: &Picks) -> bool {
match picks.step {
Step::Type | Step::Loader | Step::Bundles => true,
Step::Version => picks.versions.chosen.is_some() && picks.loader.chosen.is_some(),
Step::Type | Step::Bundles => true,
Step::Loader => picks.loader.chosen.is_some(),
Step::Version => {
picks.versions.chosen.is_some()
&& (picks.choice == TypeChoice::Scratch || picks.loader.chosen.is_some())
}
Step::Customize => validate_instance_name(&picks.name).is_ok(),
}
}

View file

@ -1,6 +1,7 @@
use std::collections::HashSet;
use freya::prelude::*;
use oneclient_core::GameVersionKind;
use oneclient_core::clusters::ClusterKind;
use super::copy::{footer_note, heading, is_ready, step_value};
@ -112,7 +113,7 @@ impl Component for CreateInstanceModal {
step: use_state(|| 0usize),
choice: use_state(|| TypeChoice::OneClient),
version: use_state(|| None::<String>),
filter: use_state(|| 0usize),
filter: use_state(|| kind_bit(GameVersionKind::Release)),
query: use_state(String::new),
loader: use_state(|| LoaderChoice::Fabric),
loader_version: use_state(|| None::<String>),

View file

@ -10,10 +10,10 @@ use oneclient_core::{BundleArchive, GameVersionKind};
use super::model::*;
use super::package_names;
use crate::hooks::{
GameVersion, LoaderVersionSet, available_bundles, game_versions, java_majors,
loader_game_versions, loader_versions, package_meta_batch, query_error, use_available_bundles,
use_game_versions, use_java_majors, use_loader_game_versions, use_loader_versions,
use_package_meta_batch, use_version_loaders, use_versions, version_loaders, versions_metadata,
GameVersion, available_bundles, game_versions, java_majors, loader_versions,
package_meta_batch, query_error, settled_or_loading, use_available_bundles, use_game_versions,
use_java_majors, use_loader_versions, use_package_meta_batch, use_version_loaders,
use_versions, versions_metadata,
};
use crate::utils::version_sort_key;
@ -22,11 +22,12 @@ pub struct VersionPicks {
pub list: VersionList,
pub loaded: bool,
pub error: Option<String>,
pub filter: usize,
pub filter: u8,
}
pub struct LoaderPicks {
pub chosen: Option<GameLoader>,
pub available: Option<Vec<GameLoader>>,
pub version: Option<String>,
pub versions: Arc<[String]>,
}
@ -82,14 +83,8 @@ impl Picks {
}
}
fn matches_filter(kind: GameVersionKind, filter: usize) -> bool {
match filter {
0 => kind == GameVersionKind::Release,
1 => kind == GameVersionKind::Snapshot,
2 => kind == GameVersionKind::Beta,
3 => kind == GameVersionKind::Alpha,
_ => true,
}
fn matches_filter(kind: GameVersionKind, filter: u8) -> bool {
filter & kind_bit(kind) != 0
}
struct OneClientVersion {
@ -161,35 +156,6 @@ fn use_catalogue(choice: TypeChoice) -> Catalogue {
}
}
struct LoaderScope {
choice: LoaderChoice,
allowed: Option<LoaderVersionSet>,
error: Option<String>,
}
impl LoaderScope {
fn settled(&self) -> bool {
self.allowed.is_some()
}
fn permits(&self, id: &str) -> bool {
match &self.allowed {
Some(Some(ids)) => ids.contains(id),
_ => true,
}
}
}
fn use_loader_scope(choice: LoaderChoice) -> LoaderScope {
let query = use_loader_game_versions(choice.primary(), choice == LoaderChoice::Fabric);
LoaderScope {
choice,
allowed: loader_game_versions(&query),
error: query_error(&query),
}
}
struct BundleContext {
archives: Arc<[BundleArchive]>,
settled: bool,
@ -242,8 +208,7 @@ fn use_bundle_context(
fn build_version_list(
choice: TypeChoice,
catalogue: &Catalogue,
scope: &LoaderScope,
filter: usize,
filter: u8,
needle: &str,
) -> VersionList {
let matches_needle = |id: &str| needle.is_empty() || id.to_lowercase().contains(needle);
@ -271,7 +236,7 @@ fn build_version_list(
let visible = all
.iter()
.enumerate()
.filter(|(_, entry)| in_scope(entry, scope, filter))
.filter(|(_, entry)| matches_filter(entry.kind, filter))
.filter(|(_, entry)| matches_needle(&entry.id))
.map(|(index, _)| index as u32)
.collect();
@ -279,22 +244,15 @@ fn build_version_list(
VersionList::Catalogue { all, visible }
}
fn in_scope(entry: &GameVersion, scope: &LoaderScope, filter: usize) -> bool {
matches_filter(entry.kind, filter) && scope.permits(&entry.id)
}
fn default_version(
choice: TypeChoice,
catalogue: &Catalogue,
scope: &LoaderScope,
filter: usize,
) -> Option<String> {
fn default_version(choice: TypeChoice, catalogue: &Catalogue, filter: u8) -> Option<String> {
if choice == TypeChoice::OneClient {
return catalogue.oneclient.first().map(|entry| entry.id.clone());
}
let all = catalogue.vanilla.as_deref().unwrap_or(&[]);
let mut scoped = all.iter().filter(|entry| in_scope(entry, scope, filter));
let mut scoped = all
.iter()
.filter(|entry| matches_filter(entry.kind, filter));
let first = scoped.clone().next();
scoped
.find(|entry| entry.kind.is_release())
@ -345,19 +303,18 @@ pub fn resolve(w: Wizard) -> Picks {
let step = steps[index];
let catalogue = use_catalogue(choice);
let scope = use_loader_scope(*w.loader.read());
let filter = *w.filter.read();
let needle = w.query.read().trim().to_lowercase();
let versions = build_version_list(choice, &catalogue, &scope, filter, &needle);
let versions = build_version_list(choice, &catalogue, filter, &needle);
let oneclient = choice == TypeChoice::OneClient;
let versions_settled = if oneclient {
catalogue.oneclient_settled
} else {
catalogue.vanilla.is_some() && scope.settled()
catalogue.vanilla.is_some()
};
let versions_error = catalogue.error.clone().or_else(|| scope.error.clone());
let versions_error = catalogue.error.clone();
let still_offered = |chosen: &String| {
if oneclient {
@ -367,17 +324,20 @@ pub fn resolve(w: Wizard) -> Picks {
.vanilla
.iter()
.flat_map(|all| all.iter())
.any(|entry: &GameVersion| &entry.id == chosen && in_scope(entry, &scope, filter))
.any(|entry: &GameVersion| {
&entry.id == chosen && matches_filter(entry.kind, filter)
})
}
};
let version = (w.version.read().clone())
.filter(still_offered)
.or_else(|| default_version(choice, &catalogue, &scope, filter));
.or_else(|| default_version(choice, &catalogue, filter));
let available = version_loaders(&use_version_loaders(version.clone().unwrap_or_default()));
let available_query = use_version_loaders(version.clone().unwrap_or_default());
let available = settled_or_loading(&available_query);
let loader = match choice {
TypeChoice::OneClient => version.as_ref().and_then(|id| catalogue.loader_for(id)),
TypeChoice::Scratch => scope.choice.resolve(&available),
TypeChoice::Scratch => w.loader.read().resolve(available.as_deref().unwrap_or(&[])),
};
let loader_versions = loader_versions(&use_loader_versions(
@ -423,6 +383,7 @@ pub fn resolve(w: Wizard) -> Picks {
},
loader: LoaderPicks {
chosen: loader,
available,
version: loader_version,
versions: loader_versions,
},

View file

@ -6,8 +6,8 @@ use oneclient_cluster::naming::{
};
use crate::components::{
ART_PREVIEW_EDGE, Button, GALLERY_COVER_EDGE, Icon, IconType, LocalImage, TextInput,
VersionArtGallery,
ART_PREVIEW_EDGE, Button, DynamicArt, GALLERY_COVER_EDGE, Icon, IconType, LocalImage,
TextInput, VersionArtGallery,
};
use crate::theme::colors;
use crate::ui::border_all_color;
@ -107,6 +107,7 @@ pub fn details_body(
state: DetailsState,
placeholder: String,
existing_cover: Option<PathBuf>,
version_art: DynamicArt,
name_problem: Option<NameProblem>,
) -> Element {
let mut touched = state.name_touched;
@ -158,7 +159,10 @@ pub fn details_body(
.into_element(),
))
.child(field("Tags", tag_field(state, &tags, tag_open)))
.child(field("Background image", cover_field(state, preview)))
.child(field(
"Background image",
cover_field(state, preview, version_art),
))
.into_element()
}
@ -405,7 +409,11 @@ fn chosen_tags(state: DetailsState, tags: &[String], open: bool) -> Element {
.into_element()
}
fn cover_field(state: DetailsState, preview: Option<(PathBuf, bool)>) -> Element {
fn cover_field(
state: DetailsState,
preview: Option<(PathBuf, bool)>,
version_art: DynamicArt,
) -> Element {
let mut cover = state.cover;
let mut cleared = state.cover_cleared;
@ -504,11 +512,7 @@ fn cover_field(state: DetailsState, preview: Option<(PathBuf, bool)>) -> Element
.picked(picked)
.skeleton(true)
.into_element(),
None => label()
.text("Version art")
.font_size(11.)
.color(colors::fg_secondary())
.into_element(),
None => version_art.max_edge(ART_PREVIEW_EDGE).into_element(),
}),
)
.child(

View file

@ -116,7 +116,13 @@ impl Component for EditInstanceModal {
title: "Edit the instance".to_string(),
subtitle: "Only how it is presented changes here. The version, loader and installed content stay as they are."
.to_string(),
body: details_body(state, self.facts.name.clone(), existing, name_problem),
body: details_body(
state,
self.facts.name.clone(),
existing,
version_art(Some(&self.facts.mc_version), Some(self.facts.mc_loader)),
name_problem,
),
scrolls_itself: false,
note: "Changes apply straight away.".to_string(),
secondary_label: "Cancel".to_string(),

View file

@ -3,11 +3,25 @@ use std::sync::Arc;
use freya::prelude::*;
use oneclient_common::domain::GameLoader;
use oneclient_core::GameVersionKind;
use super::details::DetailsState;
use crate::hooks::GameVersion;
pub const FILTERS: [&str; 5] = ["Releases", "Snapshots", "Beta", "Alpha", "All versions"];
pub const VERSION_KINDS: [(&str, GameVersionKind); 4] = [
("Releases", GameVersionKind::Release),
("Snapshots", GameVersionKind::Snapshot),
("Beta", GameVersionKind::Beta),
("Alpha", GameVersionKind::Alpha),
];
pub fn kind_bit(kind: GameVersionKind) -> u8 {
let index = VERSION_KINDS
.iter()
.position(|(_, k)| *k == kind)
.unwrap_or(0);
1 << index
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TypeChoice {
@ -111,7 +125,7 @@ impl Step {
}
const ONECLIENT_STEPS: [Step; 4] = [Step::Type, Step::Version, Step::Bundles, Step::Customize];
const SCRATCH_STEPS: [Step; 4] = [Step::Type, Step::Loader, Step::Version, Step::Customize];
const SCRATCH_STEPS: [Step; 4] = [Step::Type, Step::Version, Step::Loader, Step::Customize];
pub fn step_order(choice: TypeChoice) -> &'static [Step] {
match choice {
@ -170,7 +184,7 @@ pub struct Wizard {
pub step: State<usize>,
pub choice: State<TypeChoice>,
pub version: State<Option<String>>,
pub filter: State<usize>,
pub filter: State<u8>,
pub query: State<String>,
pub loader: State<LoaderChoice>,
pub loader_version: State<Option<String>>,

View file

@ -200,7 +200,7 @@ pub fn rail(parts: Rail) -> Element {
.spacing(18.)
.background(
LinearGradient::new()
.angle(180.)
.angle(0.)
.stop((Color::from_argb(77, 11, 16, 19), 0.))
.stop((Color::from_argb(26, 11, 16, 19), 20.))
.stop((Color::from_argb(204, 11, 16, 19), 56.))

View file

@ -11,8 +11,9 @@ use super::cards::{
};
use super::data::Picks;
use super::details::details_body;
use super::model::FILTERS;
use super::model::{LoaderChoice, Step, TypeChoice, Wizard};
use super::model::{VERSION_KINDS, kind_bit};
use super::rail::version_art;
use crate::components::{
Dropdown, GRID_GAP, Icon, IconType, ScrollArea, TextInput, centered_spinner,
};
@ -32,6 +33,7 @@ pub fn body(wizard: Wizard, picks: &Picks) -> Element {
wizard.details,
picks.suggested.clone(),
None,
version_art(picks.versions.chosen.as_deref(), picks.loader.chosen),
validate_instance_name(&picks.name).err(),
),
),
@ -99,7 +101,6 @@ fn type_step(mut wizard: Wizard, picks: &Picks) -> Element {
fn select_loader(mut wizard: Wizard, choice: LoaderChoice) {
wizard.loader.set(choice);
wizard.loader_version.set(None);
wizard.version.set(None);
}
fn loader_step(wizard: Wizard, picks: &Picks) -> Element {
@ -108,15 +109,28 @@ fn loader_step(wizard: Wizard, picks: &Picks) -> Element {
let cards: Vec<Element> = LoaderChoice::ALL
.into_iter()
.map(|choice| {
let offered = picks
.loader
.available
.as_ref()
.is_none_or(|available| choice.resolve(available).is_some());
cell_card(CellCard {
id: choice.name(),
title: choice.name(),
blurb: choice.blurb().to_string(),
meta: None,
meta: (!offered).then(|| {
let version = picks.versions.chosen.as_deref();
format!("No build for {}", version.unwrap_or("this version"))
}),
corner: loader_mark(choice, chosen == choice, LOADER_MARK_SIZE),
selected: chosen == choice,
selected: offered && chosen == choice,
checkbox: false,
on_press: (move |()| select_loader(wizard, choice)).into(),
on_press: (move |()| {
if offered {
select_loader(wizard, choice);
}
})
.into(),
})
})
.collect();
@ -243,14 +257,26 @@ fn version_controls(mut wizard: Wizard, picks: &Picks) -> Element {
)
.child(
Dropdown::new(
FILTERS[filter.min(FILTERS.len() - 1)],
FILTERS.iter().map(|f| (*f).to_string()).collect(),
filter_summary(filter),
VERSION_KINDS
.iter()
.map(|(name, _)| (*name).to_string())
.collect(),
)
.checked(
VERSION_KINDS
.iter()
.map(|(_, kind)| filter & kind_bit(*kind) != 0)
.collect(),
)
.width(Size::px(168.))
.height(Size::px(32.))
.on_select(move |index: usize| {
wizard.filter.set(index);
wizard.version.set(None);
if let Some((_, kind)) = VERSION_KINDS.get(index) {
let mask = *wizard.filter.peek();
wizard.filter.set(mask ^ kind_bit(*kind));
wizard.version.set(None);
}
}),
)
.into_element()
@ -258,6 +284,20 @@ fn version_controls(mut wizard: Wizard, picks: &Picks) -> Element {
.into_element()
}
fn filter_summary(filter: u8) -> String {
let picked: Vec<&str> = VERSION_KINDS
.iter()
.filter(|(_, kind)| filter & kind_bit(*kind) != 0)
.map(|(name, _)| *name)
.collect();
match picked.as_slice() {
[] => "None".to_string(),
[only] => (*only).to_string(),
all if all.len() == VERSION_KINDS.len() => "All versions".to_string(),
[first, rest @ ..] => format!("{first} +{}", rest.len()),
}
}
fn version_list(mut wizard: Wizard, picks: &Picks) -> Element {
if let Some(error) = &picks.versions.error {
return centered_failure(error);

File diff suppressed because it is too large Load diff

View file

@ -73,9 +73,6 @@ pub enum ClusterError {
#[error("invalid name: {0}")]
InvalidName(crate::naming::NameProblem),
#[error("the loader of a custom instance cannot be changed after it is created")]
LoaderLocked,
#[error("unknown loader id {0} in database")]
InvalidLoader(i64),

View file

@ -185,9 +185,6 @@ impl ClusterManager {
}
if existing.user_created {
if matches!(update.mc_loader_version, Patch::Set(_)) {
return Err(ClusterError::LoaderLocked);
}
if let Some(raw) = update.name.as_deref()
&& raw.trim() != existing.name
{