From c9c4c9536bb4c83b85e81635d4426219c8d4d292 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:58:57 +0200 Subject: [PATCH] feat: better versions UI (#890) --- .../src/components/cluster_landscape_art.rs | 45 - .../oneclient_app/src/components/dropdown.rs | 67 +- .../src/components/dynamic_art.rs | 31 +- .../src/components/filter_menu.rs | 245 ++++ .../src/components/instance_card.rs | 152 --- .../src/components/instance_row.rs | 135 ++ packages/oneclient_app/src/components/mod.rs | 8 +- .../src/components/version_card.rs | 157 ++- packages/oneclient_app/src/hooks/mod.rs | 8 +- .../oneclient_app/src/hooks/queries/mod.rs | 8 +- .../src/hooks/queries/versions.rs | 68 +- packages/oneclient_app/src/ui.rs | 15 + packages/oneclient_app/src/utils.rs | 14 - .../src/view/app/cluster/cluster_settings.rs | 70 +- .../view/app/cluster/package_manager/views.rs | 296 +---- .../src/view/app/clusters/instance/copy.rs | 40 +- .../src/view/app/clusters/instance/create.rs | 3 +- .../src/view/app/clusters/instance/data.rs | 89 +- .../src/view/app/clusters/instance/details.rs | 22 +- .../src/view/app/clusters/instance/edit.rs | 8 +- .../src/view/app/clusters/instance/model.rs | 20 +- .../src/view/app/clusters/instance/rail.rs | 2 +- .../src/view/app/clusters/instance/steps.rs | 58 +- .../src/view/app/clusters/page.rs | 1132 ++++++++++------- packages/oneclient_cluster/src/error.rs | 3 - packages/oneclient_cluster/src/manager.rs | 3 - 26 files changed, 1414 insertions(+), 1285 deletions(-) delete mode 100644 packages/oneclient_app/src/components/cluster_landscape_art.rs create mode 100644 packages/oneclient_app/src/components/filter_menu.rs delete mode 100644 packages/oneclient_app/src/components/instance_card.rs create mode 100644 packages/oneclient_app/src/components/instance_row.rs diff --git a/packages/oneclient_app/src/components/cluster_landscape_art.rs b/packages/oneclient_app/src/components/cluster_landscape_art.rs deleted file mode 100644 index cde8c815..00000000 --- a/packages/oneclient_app/src/components/cluster_landscape_art.rs +++ /dev/null @@ -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, - loader: Option, - 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()) - } -} diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs index 19770a05..9bc0e0f2 100644 --- a/packages/oneclient_app/src/components/dropdown.rs +++ b/packages/oneclient_app/src/components/dropdown.rs @@ -18,6 +18,7 @@ pub struct Dropdown { width: Size, height: Size, outlined: bool, + checked: Option>, 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) -> 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::); - let mut list_size = use_state(|| None::); + let mut list_area = use_state(|| None::); 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| { - open.set_if_modified(false); + let on_global_pointer_up = move |e: Event| { + 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| { - 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, on_press: EventHandler>, } impl DropdownOption { pub fn new( text: impl Into, + checked: Option, on_press: impl Into>>, ) -> 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() + })) } } diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index a1c2f6c0..eef27a0d 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -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 { - use_memo(move || { - if skip { - Bytes::new() - } else { - AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default() - } - }) +fn bundled_art() -> Bytes { + static ART: LazyLock = + 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()) diff --git a/packages/oneclient_app/src/components/filter_menu.rs b/packages/oneclient_app/src/components/filter_menu.rs new file mode 100644 index 00000000..8fb765e1 --- /dev/null +++ b/packages/oneclient_app/src/components/filter_menu.rs @@ -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, + selected: bool, + on_press: impl Into>, + ) -> Self { + Self { + text: text.into(), + selected, + on_press: on_press.into(), + } + } +} + +#[derive(Clone, PartialEq)] +struct FilterSection { + title: &'static str, + options: Vec, +} + +#[derive(PartialEq)] +pub struct FilterMenu { + active: bool, + sections: Vec, +} + +impl FilterMenu { + pub fn new(active: bool) -> Self { + Self { + active, + sections: Vec::new(), + } + } + + pub fn section( + mut self, + title: &'static str, + options: impl IntoIterator, + ) -> 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| { + 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, + 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| { + 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 §ion.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() + })) + } +} diff --git a/packages/oneclient_app/src/components/instance_card.rs b/packages/oneclient_app/src/components/instance_card.rs deleted file mode 100644 index 86f33e5c..00000000 --- a/packages/oneclient_app/src/components/instance_card.rs +++ /dev/null @@ -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, - art: DynamicArt, - selected: bool, - on_press: EventHandler>, -} - -impl InstanceCard { - pub fn new( - cluster: &Cluster, - selected: bool, - on_press: impl Into>>, - ) -> 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()), - ), - ), - ) - } -} diff --git a/packages/oneclient_app/src/components/instance_row.rs b/packages/oneclient_app/src/components/instance_row.rs new file mode 100644 index 00000000..2709d084 --- /dev/null +++ b/packages/oneclient_app/src/components/instance_row.rs @@ -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>, +} + +impl InstanceRow { + pub fn new( + cluster: &Cluster, + selected: bool, + on_press: impl Into>>, + ) -> 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()), + ) + } +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 99c3b687..0b2dd81a 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -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; diff --git a/packages/oneclient_app/src/components/version_card.rs b/packages/oneclient_app/src/components/version_card.rs index b8c806e7..96c7b0d0 100644 --- a/packages/oneclient_app/src/components/version_card.rs +++ b/packages/oneclient_app/src/components/version_card.rs @@ -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, - pub tags: Vec, pub title: String, + pub caption: String, + pub count: usize, pub selected: bool, pub on_press: EventHandler>, } @@ -22,14 +23,16 @@ impl VersionCard { pub fn new( line: ReleaseLine, clusters: &[Cluster], + caption: String, selected: bool, on_press: impl Into>>, ) -> 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() + })) } } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 01888a1b..267138a0 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -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::{ diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index c404e429..a6962295 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -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, diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs index df8d9252..b714eedd 100644 --- a/packages/oneclient_app/src/hooks/queries/versions.rs +++ b/packages/oneclient_app/src/hooks/queries/versions.rs @@ -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 )) } -pub fn version_loaders(query: &UseQuery) -> Vec { - 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>>; - -impl QueryCapability for LoaderGameVersionsQuery { - type Ok = LoaderVersionSet; - type Err = LauncherError; - type Keys = LoaderGameVersionsKeys; - - async fn run(&self, keys: &Self::Keys) -> Result { - 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::>()))) - }) - .await - .map_err(|err| LauncherError::Minecraft(err.to_string()))? - } -} - -pub fn use_loader_game_versions( - loader: GameLoader, - with_legacy: bool, -) -> UseQuery { - use_query(Query::new( - LoaderGameVersionsKeys { - loader, - with_legacy, - }, - LoaderGameVersionsQuery, - )) -} - -pub fn loader_game_versions(query: &UseQuery) -> Option { - super::state::settled_or_loading(query) -} - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct JavaMajorsQuery; diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index 8dbea1e0..e49634d8 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -92,6 +92,21 @@ pub fn relative_time(created_at: Instant) -> String { } } +pub fn last_played_label(ts: Option>) -> 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.; diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index ba4ca686..1b9ebbae 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -113,20 +113,6 @@ pub fn group_clusters_by_release<'a>( groups } -pub fn loader_tags(clusters: &[Cluster]) -> Vec { - 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 { let mut keys = Vec::new(); for cluster in clusters { diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index d6b691c3..b88f5c64 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -67,26 +67,21 @@ impl Component for ClusterSettings { let versions = loader_versions(&versions_query); let runtimes = java_runtimes(&runtimes_query); - let instance_section: Vec = 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 = 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, 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") diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 3f7f39bf..139d5f9a 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -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>, +fn filter_button( + mut sort: State>, current_sort: SortMode, - enabled_filter: State, - hidden_filter: State, + mut enabled_filter: State, + mut hidden_filter: State, 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| { - 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>, - current_sort: SortMode, - enabled_filter: State, - hidden_filter: State, - 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| { - 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> = (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> = (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> = (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>, -} - -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( diff --git a/packages/oneclient_app/src/view/app/clusters/instance/copy.rs b/packages/oneclient_app/src/view/app/clusters/instance/copy.rs index 9e2c6b55..a5aa7234 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/copy.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/copy.rs @@ -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(), } } diff --git a/packages/oneclient_app/src/view/app/clusters/instance/create.rs b/packages/oneclient_app/src/view/app/clusters/instance/create.rs index 6914d9b0..2bc67655 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/create.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/create.rs @@ -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::), - 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::), diff --git a/packages/oneclient_app/src/view/app/clusters/instance/data.rs b/packages/oneclient_app/src/view/app/clusters/instance/data.rs index 044f681a..ae7fd44e 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/data.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/data.rs @@ -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, - pub filter: usize, + pub filter: u8, } pub struct LoaderPicks { pub chosen: Option, + pub available: Option>, pub version: Option, 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, - error: Option, -} - -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 { +fn default_version(choice: TypeChoice, catalogue: &Catalogue, filter: u8) -> Option { 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, }, diff --git a/packages/oneclient_app/src/view/app/clusters/instance/details.rs b/packages/oneclient_app/src/view/app/clusters/instance/details.rs index cf91500c..c300220a 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/details.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/details.rs @@ -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, + version_art: DynamicArt, name_problem: Option, ) -> 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( diff --git a/packages/oneclient_app/src/view/app/clusters/instance/edit.rs b/packages/oneclient_app/src/view/app/clusters/instance/edit.rs index e98082ef..b1f6dd47 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/edit.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/edit.rs @@ -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(), diff --git a/packages/oneclient_app/src/view/app/clusters/instance/model.rs b/packages/oneclient_app/src/view/app/clusters/instance/model.rs index f732bcc8..dbbbdcd7 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/model.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/model.rs @@ -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, pub choice: State, pub version: State>, - pub filter: State, + pub filter: State, pub query: State, pub loader: State, pub loader_version: State>, diff --git a/packages/oneclient_app/src/view/app/clusters/instance/rail.rs b/packages/oneclient_app/src/view/app/clusters/instance/rail.rs index 348c8787..b7b28ec3 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/rail.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/rail.rs @@ -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.)) diff --git a/packages/oneclient_app/src/view/app/clusters/instance/steps.rs b/packages/oneclient_app/src/view/app/clusters/instance/steps.rs index 277c01b2..6685548c 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/steps.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/steps.rs @@ -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 = 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); diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index 883a4d07..d03b5c7e 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -1,12 +1,14 @@ +use std::path::PathBuf; + use freya::prelude::*; use freya::router::RouterContext; -use oneclient_common::VersionKey; use oneclient_common::domain::GameLoader; +use oneclient_common::{VersionKey, parse_mc_version}; use oneclient_core::clusters::Cluster; use crate::components::{ - ART_PREVIEW_EDGE, Button, ClusterLandscapeArt, Dropdown, DynamicArt, Icon, IconType, - InstanceCard, ScrollArea, VersionCard, + ART_PREVIEW_EDGE, Button, Dropdown, DynamicArt, FilterMenu, FilterOption, Icon, IconType, + InstanceRow, ScrollArea, TabBar, TabItem, TextInput, VersionCard, open_folder_button, }; use crate::hooks::{ settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, @@ -14,21 +16,72 @@ use crate::hooks::{ }; use crate::routes::Route; use crate::theme::colors; -use crate::ui::border_all_color; +use crate::ui::{ + border_all_color, fixed_grid, grid_columns_for_width, last_played_label, window_logical_size, +}; use crate::utils::{ - GridSelection, ReleaseLine, default_line, default_loader, default_version_key, line_title, - loaders_for_line, resolve_cluster, split_clusters, version_keys, version_label, + GridSelection, ReleaseLine, default_line, default_loader, default_version_key, format_duration, + line_title, resolve_cluster, split_clusters, }; use crate::view::app::clusters::CreateInstanceModal; use crate::view::app::{launch_button_state, launch_syncing}; -const GRID_GAP_PX: f32 = 12.; -const MIN_CARD_WIDTH_PX: f32 = 280.; -const SIDEBAR_WIDTH_PX: f32 = 300.; -const CARD_HEIGHT_PX: f32 = 146.; -const PLACEHOLDER_VERSION_INFO: &str = "Placeholder version info"; -const ART_MAX_HEIGHT_PX: f32 = 140.; -const ART_MIN_SIDEBAR_PX: f32 = 260.; +const PAGE_PADDING: Gaps = Gaps::new(12., 28., 28., 28.); +const COLUMN_GAP_PX: f32 = 24.; +const CARD_GAP_PX: f32 = 10.; +const ROW_GAP_PX: f32 = 8.; +const CARD_HEIGHT_PX: f32 = 150.; +const ROW_HEIGHT_PX: f32 = 56.; +const MAX_CARD_WIDTH_PX: f32 = 300.; +const MAX_ROW_WIDTH_PX: f32 = 480.; +const SIDEBAR_WIDTH_PX: f32 = 360.; +const COMPACT_SIDEBAR_WIDTH_PX: f32 = 300.; +const COMPACT_BELOW_PX: f32 = 1000.; +const INLINE_SEARCH_MIN_PX: f32 = 700.; +const SEARCH_WIDTH_PX: f32 = 220.; +const TOOLBAR_HEIGHT_PX: f32 = 44.; +const ART_MAX_HEIGHT_PX: f32 = 200.; +const ART_MIN_SIDEBAR_PX: f32 = 420.; + +#[derive(Clone, Copy, PartialEq)] +enum Filter { + All, + OneClient, + Custom, + Modpacks, +} + +impl Filter { + fn shows(self, section: Filter) -> bool { + self == Filter::All || self == section + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Sort { + RecentFirst, + OldestFirst, + NameAsc, + NameDesc, +} + +impl Sort { + const ALL: [Sort; 4] = [ + Sort::RecentFirst, + Sort::OldestFirst, + Sort::NameAsc, + Sort::NameDesc, + ]; + + fn label(self) -> &'static str { + match self { + Sort::RecentFirst => "Recently played", + Sort::OldestFirst => "Least recently played", + Sort::NameAsc => "Name (A-Z)", + Sort::NameDesc => "Name (Z-A)", + } + } +} #[derive(PartialEq)] pub struct Clusters; @@ -37,11 +90,16 @@ impl Component for Clusters { fn render(&self) -> impl IntoElement { let clusters_query = use_clusters(); let active_id = use_active_cluster_id(); - let mut show_create = use_state(|| false); + let show_create = use_state(|| false); let mut selected = 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); + let mut filter = use_state(|| Filter::All); + let query = use_state(String::new); + let loaders = use_state(Vec::::new); + let sort = use_state(|| Sort::RecentFirst); + let mut body_width = + use_state(|| window_logical_size().width - PAGE_PADDING.left() - PAGE_PADDING.right()); let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); @@ -55,7 +113,7 @@ impl Component for Clusters { .width(Size::fill()) .height(Size::fill()) .overflow(Overflow::Clip) - .padding(40.) + .padding(PAGE_PADDING) .spacing(24.) .child(page_header(show_create)) .child( @@ -70,9 +128,7 @@ impl Component for Clusters { .color(colors::fg_secondary()), ), ) - .maybe_child(show_create.read().then(|| { - CreateInstanceModal::new(move |()| show_create.set(false)).into_element() - })); + .maybe_child(create_modal(show_create)); } let active_cluster = active_id @@ -119,7 +175,7 @@ impl Component for Clusters { if line.is_some() && selected_version.read().is_none() { let preferred = active_cluster .as_ref() - .and_then(|c| oneclient_common::parse_mc_version(&c.mc_version)) + .and_then(|c| parse_mc_version(&c.mc_version)) .and_then(|p| p.key()); *selected_version.write() = default_version_key(&clusters_for_line, preferred); } @@ -141,35 +197,135 @@ impl Component for Clusters { _ => None, }; - let columns = *grid_columns.read(); - let items: Vec = lines + let (packs, custom): (Vec<&Cluster>, Vec<&Cluster>) = instances + .iter() + .partition(|c| c.linked_modpack_hash.is_some()); + + let body_w = *body_width.read(); + let sidebar_w = if body_w < COMPACT_BELOW_PX { + COMPACT_SIDEBAR_WIDTH_PX + } else { + SIDEBAR_WIDTH_PX + }; + let main_w = body_w - sidebar_w - COLUMN_GAP_PX; + let card_columns = grid_columns_for_width(main_w, MAX_CARD_WIDTH_PX, CARD_GAP_PX); + let row_columns = grid_columns_for_width(main_w, MAX_ROW_WIDTH_PX, ROW_GAP_PX); + let inline_search = main_w >= INLINE_SEARCH_MIN_PX; + + let active_filter = *filter.read(); + let needle = query.read().trim().to_lowercase(); + let picked_loaders = loaders.read().clone(); + let matches = |c: &Cluster| { + (picked_loaders.is_empty() || picked_loaders.contains(&c.mc_loader.to_string())) + && (needle.is_empty() + || format!("{} {} {}", c.name, c.mc_version, c.mc_loader) + .to_lowercase() + .contains(&needle)) + }; + let sort_by = *sort.read(); + + let shown_lines: Vec = lines .iter() .copied() - .map(GridSelection::Line) - .chain(instances.iter().map(|c| GridSelection::Instance(c.id))) + .filter(|l| active_filter.shows(Filter::OneClient) && groups[l].iter().any(matches)) .collect(); - let grid_rows = chunk_items(&items, columns); + let shown_custom = shown(&custom, sort_by, |c| { + active_filter.shows(Filter::Custom) && matches(c) + }); + let shown_packs = shown(&packs, sort_by, |c| { + active_filter.shows(Filter::Modpacks) && matches(c) + }); + + let mut available_loaders: Vec = + clusters.iter().map(|c| c.mc_loader.to_string()).collect(); + available_loaders.sort(); + available_loaders.dedup(); + let filters = filter_menu(&available_loaders, loaders, sort); + let nothing_shown = + shown_lines.is_empty() && shown_custom.is_empty() && shown_packs.is_empty(); + + let tabs = [ + (Filter::All, "All", lines.len() + instances.len()), + (Filter::OneClient, "OneClient", lines.len()), + (Filter::Custom, "Custom", custom.len()), + (Filter::Modpacks, "Modpacks", packs.len()), + ] + .map(|(value, name, count)| { + TabItem::new(name, active_filter == value) + .count_text(count.to_string()) + .on_press(move |_| filter.set(value)) + }); + + let line_cards: Vec = shown_lines + .iter() + .map(|&line| { + let list = &groups[&line]; + let item = GridSelection::Line(line); + let is_selected = current == Some(item); + let caption = match (is_selected, &cluster, list.as_slice()) { + (true, Some(c), _) | (_, _, [c]) => cluster_caption(c), + _ => format!("{} instances", list.len()), + }; + VersionCard::new(line, list, caption, is_selected, move |_| { + selected.set(Some(item)); + selected_version.set(None); + selected_loader.set(None); + }) + .into_element() + }) + .collect(); + + let instance_rows = |list: &[&Cluster]| -> Vec { + list.iter() + .map(|c| { + let item = GridSelection::Instance(c.id); + InstanceRow::new(c, current == Some(item), move |_| selected.set(Some(item))) + .into_element() + }) + .collect() + }; + + let sidebar = match (instance, line, cluster) { + (Some(instance), _, _) => Sidebar::for_instance(&instance, sidebar_w).into_element(), + (None, Some(line), Some(cluster)) => { + let parsed = parse_mc_version(&cluster.mc_version); + LineSidebar { + major: line.major, + key: parsed.and_then(|p| p.key()), + loader: cluster.mc_loader, + base: Sidebar { + picker: line_picker( + line, + &cluster, + &clusters_for_line, + selected_version, + selected_loader, + ), + ..Sidebar::base(&cluster, sidebar_w) + }, + } + .into_element() + } + _ => sidebar_error(sidebar_w), + }; rect() .vertical() .width(Size::fill()) .height(Size::fill()) .overflow(Overflow::Clip) - .padding(Gaps::new(0., 40., 40., 40.)) - .spacing(16.) - .child(page_header(show_create)) + .padding(PAGE_PADDING) .child( rect() .horizontal() .width(Size::fill()) - .height(Size::flex(1.0)) + .height(Size::fill()) .content(Content::Flex) - .spacing(GRID_GAP_PX) + .spacing(COLUMN_GAP_PX) .on_sized(move |event: Event| { let width = event.data().area.width(); - let next = grid_columns_for_width(width); - if next != *grid_columns.peek() { - *grid_columns.write() = next; + if (width - *body_width.peek()).abs() > 0.5 { + *body_width.write() = width; } }) .child( @@ -177,268 +333,371 @@ impl Component for Clusters { .vertical() .width(Size::flex(1.0)) .height(Size::fill()) - .overflow(Overflow::Clip) - .child( - ScrollArea::new() - .width(Size::fill()) - .height(Size::fill()) - .spacing(GRID_GAP_PX) - .children(grid_rows.into_iter().map(|row| { - let row_len = row.len(); - rect() - .horizontal() - .width(Size::fill()) - .content(Content::Flex) - .spacing(GRID_GAP_PX) - .children(row.into_iter().map(|item| { - let is_selected = current == Some(item); - let mut selected = selected; - let mut selected_version = selected_version; - let mut selected_loader = selected_loader; - - match item { - GridSelection::Line(line) => { - let list = groups - .get(&line) - .cloned() - .unwrap_or_default(); - VersionCard::new( - line, - &list, - is_selected, - move |_| { - *selected.write() = Some(item); - *selected_version.write() = None; - *selected_loader.write() = None; - }, - ) - .into_element() - } - GridSelection::Instance(id) => { - let Some(cluster) = - instances.iter().find(|c| c.id == id) - else { - return rect().into_element(); - }; - InstanceCard::new( - cluster, - is_selected, - move |_| { - *selected.write() = Some(item); - }, - ) - .into_element() - } - } - })) - .children((row_len..columns).map(|_| { - rect() - .width(Size::flex(1.0)) - .height(Size::px(CARD_HEIGHT_PX)) - .into_element() - })) - .into_element() - })), - ), - ) - .child(match (instance, line, cluster) { - (Some(instance), _, _) => InstanceSidebar { - cluster_id: instance.id, - name: instance.name.clone(), - description: instance.description.clone(), - tags: instance.tags.clone(), - cover: instance.cover_file(), - mc_version: instance.mc_version.clone(), - mc_loader: instance.mc_loader, - uses_bundles: instance.uses_bundles(), - } - .into_element(), - (None, Some(line), Some(cluster)) => DetailSidebar { - line, - cluster_id: cluster.id, - clusters_for_line, - selected_version, - selected_loader, - } - .into_element(), - _ => sidebar_error(), - }), - ) - .maybe_child( - show_create.read().then(|| { - CreateInstanceModal::new(move |()| show_create.set(false)).into_element() - }), - ) - } -} - -#[derive(PartialEq)] -struct DetailSidebar { - line: ReleaseLine, - cluster_id: i64, - clusters_for_line: Vec, - selected_version: State>, - selected_loader: State>, -} - -impl Component for DetailSidebar { - fn render(&self) -> impl IntoElement { - let mut sidebar_height = use_state(|| 0f32); - let active_id = use_active_cluster_id(); - let dispatch = use_dispatch(); - let game = use_game_snapshot(); - let launcher = use_launcher(); - - let clusters_for_line = self.clusters_for_line.as_slice(); - let syncing = launcher.fetching || launcher.syncing_bundles; - let keys = version_keys(clusters_for_line); - let loaders = loaders_for_line(clusters_for_line); - let version_title = line_title(self.line, clusters_for_line); - let version_value = (self.selected_version)(); - let loader_value = (self.selected_loader)(); - - let metadata = use_version_metadata(Some(self.line.major), version_value, loader_value); - - let heading = metadata - .as_ref() - .map(|m| m.name.clone()) - .unwrap_or_else(|| format!("Version {version_title}")); - let description = metadata - .as_ref() - .and_then(|m| m.long_description.clone()) - .unwrap_or_else(|| PLACEHOLDER_VERSION_INFO.to_string()); - let tags = metadata - .as_ref() - .map(|m| m.tags.clone()) - .unwrap_or_default(); - - let art_height = art_height_for(*sidebar_height.read()); - - rect() - .width(Size::px(SIDEBAR_WIDTH_PX)) - .min_width(Size::px(SIDEBAR_WIDTH_PX)) - .height(Size::fill()) - .vertical() - .spacing(8.) - .padding(8.) - .content(Content::Flex) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .overflow(Overflow::Clip) - .on_sized(move |event: Event| { - let height = event.data().area.height(); - if (*sidebar_height.peek() - height).abs() > 0.5 { - *sidebar_height.write() = height; - } - }) - .maybe_child(art_height.map(|max| { - rect() - .width(Size::fill()) - .max_height(Size::px(max)) - .child(ClusterLandscapeArt::for_version( - self.line.major, - version_value, - loader_value, - false, - )) - .into_element() - })) - .child( - rect() - .vertical() - .width(Size::fill()) - .height(Size::flex(1.0)) - .content(Content::Flex) - .padding(Gaps::new_all(8.)) - .spacing(8.) - .child( - rect() - .vertical() - .width(Size::fill()) - .height(Size::flex(1.0)) .content(Content::Flex) - .spacing(4.) + .spacing(18.) + .child(page_header(show_create)) + .child(toolbar(tabs, query, inline_search.then(|| filters.clone()))) + .maybe_child((!inline_search).then(|| { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(8.) + .child(search_input(query, Size::flex(1.))) + .child(filters) + .into_element() + })) .child( ScrollArea::new() .width(Size::fill()) .height(Size::flex(1.0)) - .spacing(4.) - .child( - label() - .text(heading) - .font_size(24.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), + .scrollbar_gutter(true) + .spacing(22.) + .append_children((!shown_lines.is_empty()).then(|| { + section( + "OneClient", + "Grouped by Minecraft version", + fixed_grid( + line_cards, + card_columns, + CARD_HEIGHT_PX, + CARD_GAP_PX, + ), + ) + })) + .append_children( + [ + ( + "Custom", + "Your own instances, modded or vanilla", + &shown_custom, + ), + ("Modpacks", "Installed from Browse", &shown_packs), + ] + .into_iter() + .filter(|(_, _, list)| !list.is_empty()) + .map( + |(title, caption, list)| { + section( + title, + caption, + fixed_grid( + instance_rows(list), + row_columns, + ROW_HEIGHT_PX, + ROW_GAP_PX, + ), + ) + }, + ), ) - .children(tags_row(&tags)) - .child( + .append_children(nothing_shown.then(|| { label() - .text(description) - .font_size(12.) - .color(colors::fg_secondary()), - ), - ) - .children(version_rows( - self.line, - &keys, - version_value, - self.selected_version, - )) - .children(loader_rows(&loaders, loader_value, self.selected_loader)), + .text("No instances match your search or filters.") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element() + })), + ), ) - .child( - rect() - .horizontal() - .width(Size::fill()) - .content(Content::Flex) - .main_align(Alignment::Center) - .spacing(8.) - .child(play_button( - self.cluster_id, - dispatch, - launch_button_state(&game, self.cluster_id, syncing), - )) - .child(view_button(self.cluster_id, active_id)), - ), + .child(sidebar), ) - .into_element() + .maybe_child(create_modal(show_create)) + } +} + +fn create_modal(mut show_create: State) -> Option { + show_create + .read() + .then(|| CreateInstanceModal::new(move |()| show_create.set(false)).into_element()) +} + +fn shown<'a>( + list: &[&'a Cluster], + sort: Sort, + keep: impl Fn(&Cluster) -> bool, +) -> Vec<&'a Cluster> { + let mut list: Vec<&Cluster> = list.iter().copied().filter(|c| keep(c)).collect(); + match sort { + Sort::RecentFirst => {} + Sort::OldestFirst => list.reverse(), + Sort::NameAsc => list.sort_by_key(|c| c.name.to_lowercase()), + Sort::NameDesc => list.sort_by_key(|c| std::cmp::Reverse(c.name.to_lowercase())), + } + list +} + +fn cluster_caption(cluster: &Cluster) -> String { + format!("{} \u{b7} {}", cluster.mc_version, cluster.mc_loader) +} + +fn bottom_border() -> Border { + Border::new() + .fill(colors::component_border()) + .width(BorderWidth { + top: 0., + right: 0., + bottom: 1., + left: 0., + }) +} + +fn section(title: &str, caption: &str, grid: Element) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .cross_align(Alignment::End) + .spacing(10.) + .child( + label() + .text(title.to_string()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(caption.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child(grid) + .into_element() +} + +fn toolbar( + tabs: impl IntoIterator, + query: State, + inline_filters: Option, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(TOOLBAR_HEIGHT_PX)) + .cross_align(Alignment::Center) + .content(Content::Flex) + .border(bottom_border()) + .child( + TabBar::new() + .width(Size::auto()) + .height(Size::fill()) + .spacing(24.) + .font_size(14.) + .tabs(tabs), + ) + .child(rect().width(Size::flex(1.0))) + .maybe_child(inline_filters.map(|filters| { + rect() + .horizontal() + .spacing(8.) + .child(search_input(query, Size::px(SEARCH_WIDTH_PX))) + .child(filters) + .into_element() + })) +} + +fn filter_menu( + available: &[String], + mut loaders: State>, + mut sort: State, +) -> Element { + let picked = loaders.read().clone(); + let sort_by = *sort.read(); + + FilterMenu::new(!picked.is_empty() || sort_by != Sort::RecentFirst) + .section( + "Sort by", + Sort::ALL.map(|option| { + FilterOption::new(option.label(), sort_by == option, move |()| { + sort.set(option) + }) + }), + ) + .section( + "Loader", + available.iter().map(|loader| { + let name = loader.clone(); + FilterOption::new(loader.clone(), picked.contains(loader), move |()| { + let mut next = loaders.peek().clone(); + if next.contains(&name) { + next.retain(|l| *l != name); + } else { + next.push(name.clone()); + } + loaders.set(next); + }) + }), + ) + .into_element() +} + +fn search_input(query: State, width: Size) -> Element { + TextInput::new(query) + .width(width) + .placeholder("Search instances") + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ) + .into_element() +} + +fn line_picker( + line: ReleaseLine, + cluster: &Cluster, + siblings: &[Cluster], + mut selected_version: State>, + mut selected_loader: State>, +) -> Option { + if siblings.len() <= 1 { + return None; + } + + let options: Vec = siblings.iter().map(cluster_caption).collect(); + let current = siblings + .iter() + .position(|c| c.id == cluster.id) + .unwrap_or(0); + let picks: Vec<(Option, GameLoader)> = siblings + .iter() + .map(|c| { + let key = parse_mc_version(&c.mc_version).and_then(|p| p.key()); + (key, c.mc_loader) + }) + .collect(); + + Some( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text(format!( + "{} instances on {}", + siblings.len(), + line_title(line, siblings) + )) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(options[current].clone(), options) + .outlined() + .width(Size::fill()) + .height(Size::px(40.)) + .on_select(move |idx: usize| { + if let Some((key, loader)) = picks.get(idx).copied() { + selected_version.set(key); + selected_loader.set(Some(loader)); + } + }), + ) + .into_element(), + ) +} + +#[derive(Clone, PartialEq)] +struct Sidebar { + width: f32, + cluster_id: i64, + uses_bundles: bool, + art: DynamicArt, + type_label: &'static str, + title: String, + subtitle: String, + description: Option, + meta: Vec<(&'static str, String)>, + folder: Option, + picker: Option, +} + +impl Sidebar { + fn base(cluster: &Cluster, width: f32) -> Self { + Self { + width, + cluster_id: cluster.id, + uses_bundles: cluster.uses_bundles(), + art: DynamicArt::for_cluster(cluster).max_edge(ART_PREVIEW_EDGE), + type_label: "OneClient", + title: cluster.name.clone(), + subtitle: cluster_caption(cluster), + description: None, + meta: vec![ + ("Minecraft", cluster.mc_version.clone()), + ("Loader", cluster.mc_loader.to_string()), + ("Last played", last_played_label(cluster.last_played)), + ], + folder: cluster.dir().ok(), + picker: None, + } + } + + fn for_instance(cluster: &Cluster, width: f32) -> Self { + let mut sidebar = Self::base(cluster, width); + sidebar.type_label = if cluster.linked_modpack_hash.is_some() { + "Modpack" + } else { + "Custom" + }; + sidebar.description = cluster.description.clone(); + sidebar.meta.push(( + "Playtime", + format_duration(i64::try_from(cluster.overall_played.as_secs()).unwrap_or(i64::MAX)), + )); + sidebar } } #[derive(PartialEq)] -struct InstanceSidebar { - cluster_id: i64, - name: String, - description: Option, - tags: Vec, - cover: Option, - mc_version: String, - mc_loader: GameLoader, - uses_bundles: bool, +struct LineSidebar { + major: u32, + key: Option, + loader: GameLoader, + base: Sidebar, } -impl Component for InstanceSidebar { +impl Component for LineSidebar { + fn render(&self) -> impl IntoElement { + let metadata = use_version_metadata(Some(self.major), self.key, Some(self.loader)); + + let mut sidebar = self.base.clone(); + if let Some(metadata) = metadata { + sidebar.title = metadata.name; + sidebar.description = metadata.long_description; + } + sidebar + } +} + +impl Component for Sidebar { fn render(&self) -> impl IntoElement { let mut sidebar_height = use_state(|| 0f32); - let active_id = use_active_cluster_id(); + let mut active_id = use_active_cluster_id(); let dispatch = use_dispatch(); let game = use_game_snapshot(); let launcher = use_launcher(); + let cluster_id = self.cluster_id; let syncing = launch_syncing(&launcher, self.uses_bundles); let art_height = art_height_for(*sidebar_height.read()); - let cover = self.cover.clone(); + let top_padding = if art_height.is_some() { 4. } else { 22. }; rect() - .width(Size::px(SIDEBAR_WIDTH_PX)) - .min_width(Size::px(SIDEBAR_WIDTH_PX)) + .width(Size::px(self.width)) + .min_width(Size::px(self.width)) .height(Size::fill()) .vertical() - .spacing(8.) - .padding(8.) .content(Content::Flex) - .corner_radius(CornerRadius::new_all(12.)) + .corner_radius(CornerRadius::new_all(20.)) .background(colors::page_elevated()) .border(border_all_color(1., colors::component_border())) .overflow(Overflow::Clip) @@ -448,75 +707,152 @@ impl Component for InstanceSidebar { *sidebar_height.write() = height; } }) - .maybe_child(art_height.map(|max| { - let parsed = oneclient_common::parse_mc_version(&self.mc_version); - rect() - .width(Size::fill()) - .max_height(Size::px(max)) - .corner_radius(CornerRadius::new_all(12.)) - .overflow(Overflow::Clip) - .child( - DynamicArt::for_version( - parsed.as_ref().map_or(0, |p| p.major), - parsed.and_then(|p| p.key()), - Some(self.mc_loader), - ) - .cover(cover.clone()) - .max_edge(ART_PREVIEW_EDGE), - ) - .into_element() - })) + .maybe_child( + art_height.map(|height| sidebar_art(self.art.clone(), self.type_label, height)), + ) .child( rect() .vertical() .width(Size::fill()) .height(Size::flex(1.0)) .content(Content::Flex) - .padding(Gaps::new_all(8.)) - .spacing(8.) + .padding(Gaps::new(top_padding, 22., 22., 22.)) + .spacing(18.) .child( ScrollArea::new() .width(Size::fill()) .height(Size::flex(1.0)) - .spacing(4.) + .scrollbar_gutter(true) + .spacing(18.) .child( - label() - .text(self.name.clone()) - .font_size(24.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .children(tags_row(&self.tags)) - .child( - label() - .text( - self.description - .clone() - .unwrap_or_else(|| "No description yet.".to_string()), + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .child( + label() + .text(self.title.clone()) + .font_size(24.) + .font_weight(FontWeight::SEMI_BOLD) + .color(Color::WHITE), ) + .child( + label() + .text(self.subtitle.clone()) + .font_size(13.) + .color(colors::fg_secondary()), + ), + ) + .append_children(self.picker.clone()) + .child(meta_list(&self.meta)) + .append_children(self.description.clone().map(|text| { + label() + .text(text) .font_size(12.) - .color(colors::fg_secondary()), - ), + .color(colors::fg_secondary()) + .into_element() + })), ) .child( rect() .horizontal() .width(Size::fill()) .content(Content::Flex) - .main_align(Alignment::Center) .spacing(8.) .child(play_button( - self.cluster_id, + cluster_id, dispatch, - launch_button_state(&game, self.cluster_id, syncing), + launch_button_state(&game, cluster_id, syncing), )) - .child(view_button(self.cluster_id, active_id)), + .child( + Button::new() + .secondary() + .icon() + .tooltip("Instance settings") + .on_press(move |_| { + *active_id.write() = Some(cluster_id); + let _ = RouterContext::get() + .push(Route::ClusterSettings { cluster_id }); + }) + .child(Icon::new(IconType::Settings01).size(16.)), + ) + .maybe_child(self.folder.clone().map(open_folder_button)), ), ) - .into_element() } } +fn sidebar_art(art: DynamicArt, type_label: &str, height: f32) -> Element { + rect() + .width(Size::fill()) + .height(Size::px(height)) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .child(art), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .layer(Layer::Relative(3)) + .background( + LinearGradient::new() + .angle(0.) + .stop((colors::page_elevated().with_a(0), 40.)) + .stop((colors::page_elevated().with_a(242), 100.)), + ), + ) + .child( + rect() + .position(Position::new_absolute().top(14.).left(14.)) + .layer(Layer::Relative(4)) + .padding(Gaps::new_symmetric(5., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(Color::from_af32rgb(0.66, 11, 16, 19)) + .child( + label() + .text(type_label.to_string()) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ), + ) + .into_element() +} + +fn meta_list(meta: &[(&'static str, String)]) -> Element { + rect() + .vertical() + .width(Size::fill()) + .children(meta.iter().map(|(key, value)| { + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::SpaceBetween) + .padding(Gaps::new_symmetric(9., 0.)) + .border(bottom_border()) + .child( + label() + .text(key.to_string()) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(value.clone()) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .into_element() + })) + .into_element() +} + fn art_height_for(sidebar_height: f32) -> Option { if sidebar_height <= 0. { return Some(ART_MAX_HEIGHT_PX); @@ -527,110 +863,6 @@ fn art_height_for(sidebar_height: f32) -> Option { Some((sidebar_height * 0.32).min(ART_MAX_HEIGHT_PX)) } -fn tags_row(tags: &[String]) -> Option { - if tags.is_empty() { - return None; - } - - Some( - rect() - .horizontal() - .width(Size::fill()) - .spacing(6.) - .children(tags.iter().map(|tag| { - rect() - .padding(Gaps::new_symmetric(3., 8.)) - .corner_radius(CornerRadius::new_all(999.)) - .background(colors::component_bg()) - .child( - label() - .text(tag.clone()) - .font_size(11.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_secondary()), - ) - .into_element() - })) - .into_element(), - ) -} - -/// A single-version line already names it on the card and in the heading -fn version_rows( - line: ReleaseLine, - keys: &[VersionKey], - selected: Option, - mut selected_version: State>, -) -> Option { - if keys.len() <= 1 { - return None; - } - - 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( - "Version", - Dropdown::new(options[current].clone(), options) - .on_select(move |idx: usize| { - if let Some(key) = keys.get(idx).copied() { - *selected_version.write() = Some(key); - } - }) - .into_element(), - )) -} - -fn loader_rows( - loaders: &[GameLoader], - selected: Option, - mut selected_loader: State>, -) -> Option { - if loaders.len() <= 1 { - return None; - } - - let options: Vec = loaders.iter().map(|l| l.to_string()).collect(); - let current = selected - .and_then(|s| loaders.iter().position(|l| *l == s)) - .unwrap_or(0); - let loaders = loaders.to_vec(); - - Some(info_row( - "Mod Loader", - Dropdown::new(options[current].clone(), options) - .width(Size::px(120.)) - .on_select(move |idx: usize| { - if let Some(loader) = loaders.get(idx).copied() { - *selected_loader.write() = Some(loader); - } - }) - .into_element(), - )) -} - -fn info_row(label_text: impl Into, control: impl IntoElement) -> Element { - let label_text = label_text.into(); - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .main_align(Alignment::SpaceBetween) - .padding(Gaps::new_symmetric(4.0, 0.0)) - .child( - label() - .text(label_text) - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_primary()), - ) - .child(control) - .into_element() -} - fn play_button( cluster_id: i64, dispatch: crate::Actions, @@ -649,30 +881,31 @@ fn play_button( .text(label) } -fn view_button(cluster_id: i64, mut active_id: State>) -> impl IntoElement { - Button::new() - .secondary() - .width(Size::flex(1.)) - .on_press(move |_| { - *active_id.write() = Some(cluster_id); - let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); - }) - .text("View") - .child(Icon::new(IconType::ArrowRight).size(14.)) -} - fn page_header(mut show_create: State) -> impl IntoElement { rect() .horizontal() .width(Size::fill()) - .cross_align(Alignment::Center) + .cross_align(Alignment::End) .content(Content::Flex) + .spacing(16.) .child( rect() .vertical() .width(Size::flex(1.)) .spacing(6.) - .child(header_text()), + .child( + label() + .text("Versions") + .font_size(32.) + .font_weight(FontWeight::SEMI_BOLD) + .color(Color::WHITE), + ) + .child( + label() + .text("Pick a OneClient version, or launch one of your own instances.") + .font_size(13.) + .color(colors::fg_secondary()), + ), ) .child( Button::new() @@ -683,38 +916,14 @@ fn page_header(mut show_create: State) -> impl IntoElement { ) } -fn header_text() -> impl IntoElement { +fn sidebar_error(width: f32) -> Element { rect() - .vertical() - .spacing(6.) - .child( - label() - .text("Versions") - .font_size(36.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), - ) - .child( - label() - .text( - "Something something in corporate style fashion about picking your \ - preferred gamemodes and versions and optionally loader so that oneclient \ - can pick something for them", - ) - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_secondary()), - ) -} - -fn sidebar_error() -> Element { - rect() - .width(Size::px(SIDEBAR_WIDTH_PX)) + .width(Size::px(width)) .height(Size::fill()) .vertical() .padding(16.) .spacing(8.) - .corner_radius(CornerRadius::new_all(16.)) + .corner_radius(CornerRadius::new_all(20.)) .border(border_all_color(1., colors::component_border())) .background(colors::component_bg()) .child( @@ -731,20 +940,3 @@ fn sidebar_error() -> Element { ) .into_element() } - -fn chunk_items(items: &[GridSelection], columns: usize) -> Vec> { - let columns = columns.max(1); - items.chunks(columns).map(|chunk| chunk.to_vec()).collect() -} - -fn grid_columns_for_width(available_width_px: f32) -> usize { - const GAP: f32 = 16.; - - let grid_width = available_width_px - SIDEBAR_WIDTH_PX - GAP; - if grid_width < MIN_CARD_WIDTH_PX { - return 1; - } - - let cols = (grid_width / (MIN_CARD_WIDTH_PX + GRID_GAP_PX)).floor() as usize; - cols.clamp(1, 3) -} diff --git a/packages/oneclient_cluster/src/error.rs b/packages/oneclient_cluster/src/error.rs index 3ec330fe..89f68879 100644 --- a/packages/oneclient_cluster/src/error.rs +++ b/packages/oneclient_cluster/src/error.rs @@ -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), diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs index 92252a3a..e7c9f062 100644 --- a/packages/oneclient_cluster/src/manager.rs +++ b/packages/oneclient_cluster/src/manager.rs @@ -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 {