From 2a2d992649db6d1729fa350a404ca8e92b9cab4d Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:19:37 +0200 Subject: [PATCH] feat: More compact cluster package manager view + package manager filter popover --- .../oneclient_app/src/components/tab_bar.rs | 35 +- packages/oneclient_app/src/utils.rs | 20 + .../view/app/cluster/package_manager/mod.rs | 90 +-- .../view/app/cluster/package_manager/views.rs | 595 ++++++++++++------ packages/oneclient_core/src/game/launch.rs | 5 +- 5 files changed, 497 insertions(+), 248 deletions(-) diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs index 6e34bf95..910f98c4 100644 --- a/packages/oneclient_app/src/components/tab_bar.rs +++ b/packages/oneclient_app/src/components/tab_bar.rs @@ -19,6 +19,7 @@ impl TabItem { } } + #[allow(dead_code)] pub fn count_text(mut self, text: impl Into) -> Self { self.count = Some(text.into()); self @@ -36,7 +37,6 @@ pub struct TabBar { height: Size, spacing: f32, font_size: f32, - bold_active: bool, } impl Default for TabBar { @@ -53,7 +53,6 @@ impl TabBar { height: Size::fill(), spacing: 24., font_size: 12., - bold_active: false, } } @@ -81,19 +80,11 @@ impl TabBar { self.font_size = font_size; self } - - pub fn bold_active(mut self, bold_active: bool) -> Self { - self.bold_active = bold_active; - self - } } -fn tab_label( - text: &str, - font_size: f32, - active: bool, - active_weight: FontWeight, -) -> impl IntoElement { +fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement { + const ACTIVE_WEIGHT: FontWeight = FontWeight::MEDIUM; + rect() .center() .content(Content::Fit) @@ -101,8 +92,9 @@ fn tab_label( label() .text(text.to_string()) .font_size(font_size) - .font_weight(active_weight) - .color(Color::TRANSPARENT), + .font_weight(ACTIVE_WEIGHT) + .color(Color::TRANSPARENT) + .text_align(TextAlign::Center), ) .child( rect() @@ -119,11 +111,12 @@ fn tab_label( .text(text.to_string()) .font_size(font_size) .font_weight(if active { - active_weight + ACTIVE_WEIGHT } else { FontWeight::NORMAL }) - .color(colors::fg_primary()), + .color(colors::fg_primary()) + .text_align(TextAlign::Center), ), ) } @@ -145,7 +138,6 @@ fn count_pill(count: &str, font_size: f32) -> impl IntoElement { impl IntoElement for TabBar { fn into_element(self) -> Element { let font_size = self.font_size; - let bold_active = self.bold_active; rect() .horizontal() @@ -156,11 +148,6 @@ impl IntoElement for TabBar { .content(Content::Fit) .children(self.tabs.into_iter().map(|tab| { let active = tab.active; - let active_weight = if bold_active { - FontWeight::BOLD - } else { - FontWeight::MEDIUM - }; let mut el = rect() .vertical() @@ -182,7 +169,7 @@ impl IntoElement for TabBar { rect() .vertical() .content(Content::Fit) - .child(tab_label(&tab.label, font_size, active, active_weight)) + .child(tab_label(&tab.label, font_size, active)) .child( rect() .height(Size::px(1.5)) diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index ff8b9b2a..daab86b8 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -1,5 +1,6 @@ use std::cmp::Ordering; use std::collections::BTreeMap; +use std::sync::OnceLock; use chrono::{Datelike, NaiveDate}; use oneclient_core::clusters::Cluster; @@ -239,3 +240,22 @@ fn version_sort_key(cluster: &Cluster) -> (u32, u32) { .map(|v| (v.major, v.minor.unwrap_or(0))) .unwrap_or((0, 0)) } + + +#[cfg(not(target_os = "linux"))] +pub fn is_wayland() -> bool { + false +} + +#[cfg(target_os = "linux")] +pub fn is_wayland() -> bool { + static IS_WAYLAND: OnceLock = OnceLock::new(); + + *IS_WAYLAND.get_or_init(|| { + if cfg!(target_os = "linux") { + std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false) + } else { + false + } + }) +} \ No newline at end of file diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 326f4eb6..20fdcbcb 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -6,12 +6,11 @@ use oneclient_core::packages::{CachedPackageMeta, ContentType, ProviderId}; use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; use crate::components::{CardLayout, PackageEntry}; -use crate::hooks::{use_dispatch, use_view_state}; +use crate::hooks::use_view_state; mod views; -use views::{SortFilter, header, list, tab_bar, toolbar}; +use views::{ContentBox, ContentKind, EnabledFilter, SortMode, toolbar_bar}; -const PANEL_BG: Color = Color::from_rgb(21, 28, 34); const CARD_H: f32 = 84.; const CARD_SPACING: f32 = 8.; const CARD_GRID_H: f32 = 148.; @@ -185,29 +184,27 @@ fn make_row( } #[derive(Clone)] -enum Tab { - All, +pub(super) enum Tab { Category(String), - Remote, + External, Local, } impl Tab { - fn label(&self) -> String { + pub(super) fn label(&self) -> String { match self { - Tab::All => "All".to_string(), Tab::Category(c) => c.clone(), - Tab::Remote => "Remote".to_string(), + Tab::External => "External".to_string(), Tab::Local => "Local".to_string(), } } - fn matches(&self, p: &PackageEntry) -> bool { + pub(super) fn matches(&self, p: &PackageEntry) -> bool { match self { - Tab::All => true, Tab::Category(c) => p.categories.iter().any(|pc| pc == c), - Tab::Remote => !p.in_bundle() && p.is_remote(), - Tab::Local => !p.in_bundle() && !p.is_remote(), + // External = remote provider content that is NOT provided by a bundle. + Tab::External => p.is_remote() && !p.in_bundle(), + Tab::Local => !p.is_remote(), } } } @@ -232,9 +229,15 @@ fn build_tabs(categories: &[String], items: &[PackageEntry]) -> Vec { } } } - let mut tabs = vec![Tab::All]; - tabs.extend(cats.into_iter().map(Tab::Category)); - tabs.push(Tab::Remote); + + // Category tabs are hidden when empty; External + Local are always shown. + let mut tabs: Vec = cats + .into_iter() + .map(Tab::Category) + .filter(|t| items.iter().any(|p| t.matches(p))) + .collect(); + + tabs.push(Tab::External); tabs.push(Tab::Local); tabs } @@ -279,65 +282,68 @@ impl Component for PackageManager { let package_type = self.package_type; let cluster_id = self.cluster_id; let content_type = self.content_type; - let dispatch = use_dispatch(); - - let folder = super::load_cluster(cluster_id) - .and_then(|c| c.game_dir().ok()) - .map(|dir| dir.join(content_type.folder_name())); - - let total = items.len(); - let enabled = items.iter().filter(|i| i.enabled).count(); let tabs = build_tabs(&self.categories, &items); let active = use_state(|| 0usize); let active_idx = (*active.read()).min(tabs.len().saturating_sub(1)); let search = use_state(String::new); + let enabled_filter = use_state(|| EnabledFilter::All); let view = use_view_state("cluster.packages"); let sort = view.sort; let layout = view.layout; let query = search.read().to_lowercase(); - let sort_filter = sort + let sort_mode = sort .read() .as_deref() - .and_then(SortFilter::from_key) - .unwrap_or(SortFilter::NameAsc); + .and_then(SortMode::from_key) + .unwrap_or(SortMode::NameAsc); + + let show = *enabled_filter.read(); let card_layout = CardLayout::from(*layout.read()); + let tab_filter = tabs.get(active_idx); + let content_kind = match tab_filter { + Some(Tab::External) => ContentKind::External, + Some(Tab::Local) => ContentKind::Local, + _ => ContentKind::Other, + }; + let mut filtered: Vec = items .iter() - .filter(|p| tabs[active_idx].matches(p)) + .filter(|p| tab_filter.is_none_or(|t| t.matches(p))) .filter(|p| { query.is_empty() || p.name.to_lowercase().contains(query.as_str()) || p.file_name.to_lowercase().contains(query.as_str()) }) - .filter(|p| sort_filter.keep(p)) + .filter(|p| show.keep(p)) .cloned() .collect(); - sort_filter.sort(&mut filtered); + + sort_mode.sort(&mut filtered); rect() .vertical() .width(Size::fill()) .height(Size::fill()) - .spacing(16.) - .child(header( - self.title, - format!("{enabled} / {total} enabled"), - package_type, - content_type, - cluster_id, - dispatch, - folder, + .child(toolbar_bar( + &tabs, + active_idx, + active, + search, + sort, + sort_mode, + enabled_filter, + layout, )) - .child(toolbar(search, sort, sort_filter, layout)) - .child(tab_bar(&tabs, &items, active_idx, active)) - .child(list( + .child(ContentBox::new( filtered, noun_plural, package_type, + content_type, cluster_id, + content_kind, card_layout, )) } 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 2c283fcc..97d3953d 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 @@ -4,64 +4,50 @@ use freya::router::RouterContext; use oneclient_core::packages::ContentType; use oneclient_core::settings::ViewLayout; -use crate::BridgeDispatch; use crate::components::{ - Button, CardLayout, Dropdown, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx, Segment, SegmentedControl, TabBar, TabItem, TextInput + Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx, + Segment, SegmentedControl, TabBar, TabItem, TextInput, }; +use crate::hooks::use_dispatch; use crate::routes::Route; use crate::theme::colors; +use crate::{BridgeDispatch, utils}; #[derive(Clone, Copy, PartialEq)] -pub(super) enum SortFilter { +pub(super) enum SortMode { NameAsc, NameDesc, SizeAsc, SizeDesc, - Enabled, - Disabled, } -impl SortFilter { - const ALL: [SortFilter; 6] = [ - SortFilter::NameAsc, - SortFilter::NameDesc, - SortFilter::SizeAsc, - SortFilter::SizeDesc, - SortFilter::Enabled, - SortFilter::Disabled, +impl SortMode { + const ALL: [SortMode; 4] = [ + SortMode::NameAsc, + SortMode::NameDesc, + SortMode::SizeAsc, + SortMode::SizeDesc, ]; fn key(self) -> &'static str { match self { - SortFilter::NameAsc => "name_asc", - SortFilter::NameDesc => "name_desc", - SortFilter::SizeAsc => "size_asc", - SortFilter::SizeDesc => "size_desc", - SortFilter::Enabled => "enabled", - SortFilter::Disabled => "disabled", + SortMode::NameAsc => "name_asc", + SortMode::NameDesc => "name_desc", + SortMode::SizeAsc => "size_asc", + SortMode::SizeDesc => "size_desc", } } pub(super) fn from_key(key: &str) -> Option { - SortFilter::ALL.into_iter().find(|s| s.key() == key) + SortMode::ALL.into_iter().find(|s| s.key() == key) } fn label(self) -> &'static str { match self { - SortFilter::NameAsc => "Name (A-Z)", - SortFilter::NameDesc => "Name (Z-A)", - SortFilter::SizeAsc => "Size (Smallest)", - SortFilter::SizeDesc => "Size (Largest)", - SortFilter::Enabled => "Enabled only", - SortFilter::Disabled => "Disabled only", - } - } - - pub(super) fn keep(self, p: &PackageEntry) -> bool { - match self { - SortFilter::Enabled => p.enabled, - SortFilter::Disabled => !p.enabled, - _ => true, + SortMode::NameAsc => "Name (A-Z)", + SortMode::NameDesc => "Name (Z-A)", + SortMode::SizeAsc => "Size (Smallest)", + SortMode::SizeDesc => "Size (Largest)", } } @@ -75,30 +61,96 @@ impl SortFilter { }; match self { - SortFilter::NameDesc => rows.sort_by_key(|p| std::cmp::Reverse(title(p))), - SortFilter::SizeAsc => rows.sort_by_key(|p| p.size), - SortFilter::SizeDesc => rows.sort_by_key(|p| std::cmp::Reverse(p.size)), + SortMode::NameDesc => rows.sort_by_key(|p| std::cmp::Reverse(title(p))), + SortMode::SizeAsc => rows.sort_by_key(|p| p.size), + SortMode::SizeDesc => rows.sort_by_key(|p| std::cmp::Reverse(p.size)), _ => rows.sort_by_key(title), } } } -pub(super) fn toolbar( +#[derive(Clone, Copy, PartialEq)] +pub(super) enum EnabledFilter { + All, + Enabled, + Disabled, +} + +impl EnabledFilter { + const ALL: [EnabledFilter; 3] = [ + EnabledFilter::All, + EnabledFilter::Enabled, + EnabledFilter::Disabled, + ]; + + fn label(self) -> &'static str { + match self { + EnabledFilter::All => "All packages", + EnabledFilter::Enabled => "Enabled only", + EnabledFilter::Disabled => "Disabled only", + } + } + + pub(super) fn keep(self, p: &PackageEntry) -> bool { + match self { + EnabledFilter::All => true, + EnabledFilter::Enabled => p.enabled, + EnabledFilter::Disabled => !p.enabled, + } + } +} + +const FILTER_PANEL_W: f32 = 172.; +const FILTER_BTN_W: f32 = 34.; + +#[allow(clippy::too_many_arguments)] +pub(super) fn toolbar_bar( + tabs: &[Tab], + active_idx: usize, + active: State, search: State, - mut sort: State>, - current: SortFilter, + sort: State>, + current_sort: SortMode, + enabled_filter: State, layout: State, ) -> impl IntoElement { + let tab_items = tabs.iter().enumerate().map(|(i, tab)| { + let mut active = active; + TabItem::new(tab.label(), i == active_idx).on_press(move |_| *active.write() = i) + }); + + let mut top_corners = CornerRadius::new_all(0.); + top_corners.fill_top(12.); + rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(8.) .content(Content::Flex) + .overflow(Overflow::Clip) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(top_corners) + .background(colors::page_elevated()) + .child( + ScrollView::new() + .direction(Direction::Horizontal) + .show_scrollbar(false) + .width(Size::flex(1.0)) + .height(Size::auto()) + .child( + TabBar::new() + .width(Size::auto()) + .height(Size::auto()) + .spacing(20.) + .font_size(12.) + .tabs(tab_items), + ), + ) .child( TextInput::new(search) - .placeholder("Search packages...") - .width(Size::flex(1.0)) + .placeholder("Search...") + .width(Size::px(180.)) .leading( Icon::new(IconType::SearchMd) .size(14.) @@ -106,19 +158,11 @@ pub(super) fn toolbar( .into_element(), ), ) - .child( - Dropdown::new( - current.label(), - SortFilter::ALL.iter().map(|s| s.label().to_string()).collect(), - ) - .width(Size::px(120.)) - .height(Size::px(34.)) - .on_select(move |idx: usize| { - if let Some(option) = SortFilter::ALL.get(idx) { - sort.set(Some(option.key().to_string())); - } - }), - ) + .child(FilterButton { + sort, + current_sort, + enabled_filter, + }) .child( SegmentedControl::new(layout) .height(34.) @@ -130,76 +174,172 @@ pub(super) fn toolbar( .into_element() } -pub(super) fn header( - title: &'static str, - count_label: String, - package_type: &'static str, - content_type: ContentType, - cluster_id: i64, - dispatch: BridgeDispatch, - folder: Option, +#[derive(PartialEq)] +struct FilterButton { + sort: State>, + current_sort: SortMode, + enabled_filter: State, +} + +impl Component for FilterButton { + fn render(&self) -> impl IntoElement { + let mut open = use_state(|| false); + let mut hovering = use_state(|| false); + + let sort = self.sort; + let current_sort = self.current_sort; + let enabled_filter = self.enabled_filter; + + let is_open = open(); + let active = + current_sort != SortMode::NameAsc || *enabled_filter.read() != EnabledFilter::All; + + let bg = if is_open || hovering() { + colors::component_bg_hover() + } else { + colors::component_bg() + }; + let icon_color = if active { + colors::brand() + } else { + colors::fg_secondary() + }; + + rect() + .width(Size::px(FILTER_BTN_W)) + .child( + rect() + .width(Size::px(FILTER_BTN_W)) + .height(Size::px(34.)) + .center() + .corner_radius(CornerRadius::new_all(8.)) + .background(bg) + .border(crate::ui::border_all_color(1., colors::component_border())) + .on_pointer_enter(move |_| { + hovering.set(true); + Cursor::set(CursorIcon::Pointer); + }) + .on_pointer_leave(move |_| { + hovering.set(false); + Cursor::set(CursorIcon::default()); + }) + .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(|| { + filter_popover(sort, current_sort, enabled_filter, move || open.set(false)) + .into_element() + })) + } +} + +fn filter_popover( + mut sort: State>, + current_sort: SortMode, + mut enabled_filter: State, + on_close: impl FnMut() + Clone + 'static, ) -> impl IntoElement { - rect() - .horizontal() + let mut close_backdrop = on_close.clone(); + + let show = *enabled_filter.read(); + + let mut panel = rect() + .vertical() .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(12.) - .content(Content::Flex) - .child( - label() - .text(title) - .font_size(20.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child(pill(count_label)) + .spacing(4.) + .padding(Gaps::new_all(8.)) + .corner_radius(CornerRadius::new_all(10.)) + .border(crate::ui::border_all_color(1., colors::component_border())) + .background(colors::page_elevated()) + .child(section_label("Sort by")); + + for mode in SortMode::ALL { + let selected = mode == current_sort; + panel = panel.child(choice_row(mode.label(), selected, move |_| { + sort.set(Some(mode.key().to_string())); + })); + } + + panel = panel.child(section_label("Show")); + for filter in EnabledFilter::ALL { + let selected = filter == show; + panel = panel.child(choice_row(filter.label(), selected, move |_| { + enabled_filter.set(filter); + })); + } + + rect() + .height(Size::px(0.)) + .width(Size::px(FILTER_PANEL_W)) + .layer(Layer::Overlay) .child( rect() - .horizontal() - .width(Size::flex(1.0)) - .main_align(Alignment::End) - .cross_align(Alignment::Center) - .spacing(8.) - .maybe_child(folder.map(crate::components::open_folder_button)) - .child(add_from_file_button(cluster_id, content_type, dispatch)) - .child(browse_button(cluster_id, package_type)), + .layer(Layer::RelativeOverlay(10)) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .on_press(move |_| close_backdrop()), + ) + .child( + rect() + .width(Size::fill()) + .layer(Layer::RelativeOverlay(12)) + .margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W))) + .child(panel), ) .into_element() } -pub(super) fn tab_bar( - tabs: &[Tab], - items: &[PackageEntry], - active_idx: usize, - active: State, -) -> impl IntoElement { - let tab_items = tabs.iter().enumerate().map(|(i, tab)| { - let total = items.iter().filter(|p| tab.matches(p)).count(); - let enabled = items.iter().filter(|p| tab.matches(p) && p.enabled).count(); - let mut active = active; - TabItem::new(tab.label(), i == active_idx) - .count_text(format!("{enabled}/{total}")) - .on_press(move |_| *active.write() = i) - }); +fn section_label(text: &'static str) -> impl IntoElement { + label() + .text(text) + .font_size(10.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()) +} +fn choice_row( + text: &'static str, + selected: bool, + on_press: impl FnMut(Event) + 'static, +) -> impl IntoElement { + let color = if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) - .overflow(Overflow::Clip) - .padding(Gaps::new_symmetric(10., 16.)) - .corner_radius(CornerRadius::new_all(12.)) - .background(PANEL_BG) + .spacing(8.) + .padding(Gaps::new_symmetric(5., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if selected { + colors::component_bg() + } else { + Color::TRANSPARENT + }) + .content(Content::Flex) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_press(on_press) .child( - TabBar::new() - .width(Size::fill()) - .height(Size::auto()) - .spacing(24.) + label() + .text(text) .font_size(12.) - .bold_active(true) - .tabs(tab_items), + .width(Size::flex(1.0)) + .color(color), ) - .into_element() + .maybe_child(selected.then(|| { + Icon::new(IconType::Check) + .size(14.) + .color(colors::brand()) + .into_element() + })) } fn add_from_file_button( @@ -208,7 +348,7 @@ fn add_from_file_button( dispatch: BridgeDispatch, ) -> impl IntoElement { Button::new() - .secondary() + .primary() .on_press(move |_| { let dispatch = dispatch.clone(); spawn(async move { @@ -242,63 +382,125 @@ fn browse_button(cluster_id: i64, package_type: &'static str) -> impl IntoElemen .text("Browse Content") } -fn pill(text: String) -> impl IntoElement { - rect() - .center() - .padding(Gaps::new_symmetric(2., 8.)) - .corner_radius(CornerRadius::new_all(10.)) - .background(colors::component_bg()) - .child( - label() - .text(text) - .font_size(9.) - .color(colors::fg_secondary()), - ) +#[derive(Clone, Copy, PartialEq)] +pub(super) enum ContentKind { + External, + Local, + Other, } -pub(super) fn list( +#[derive(PartialEq)] +pub(super) struct ContentBox { items: Vec, noun_plural: &'static str, package_type: &'static str, + content_type: ContentType, cluster_id: i64, + kind: ContentKind, layout: CardLayout, -) -> impl IntoElement { - let count = items.len(); - let scroll = (count > 0).then(|| match layout { - CardLayout::List => { - let items = items.clone(); - ScrollArea::new() +} + +impl ContentBox { + #[allow(clippy::too_many_arguments)] + pub(super) fn new( + items: Vec, + noun_plural: &'static str, + package_type: &'static str, + content_type: ContentType, + cluster_id: i64, + kind: ContentKind, + layout: CardLayout, + ) -> Self { + Self { + items, + noun_plural, + package_type, + content_type, + cluster_id, + kind, + layout, + } + } +} + +impl Component for ContentBox { + fn render(&self) -> impl IntoElement { + let items = self.items.clone(); + let package_type = self.package_type; + let content_type = self.content_type; + let cluster_id = self.cluster_id; + let noun_plural = self.noun_plural; + let kind = self.kind; + let layout = self.layout; + + let dispatch = use_dispatch(); + let mut dropping = use_state(|| false); + + let count = items.len(); + let scroll = (count > 0).then(|| match layout { + CardLayout::List => { + let items = items.clone(); + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .lazy(count, CARD_H, CARD_SPACING, move |i| { + let item = items[i].clone(); + let key = item.package_id.clone(); + PackageRow::new(item, cluster_id, package_type) + .layout(CardLayout::List) + .key(key) + .into_element() + }) + .into_element() + } + CardLayout::Grid => ScrollArea::new() .width(Size::fill()) .height(Size::fill()) - .lazy(count, CARD_H, CARD_SPACING, move |i| { - let item = items[i].clone(); - let key = item.package_id.clone(); - PackageRow::new(item, cluster_id, package_type) - .layout(CardLayout::List) - .key(key) - .into_element() + .content(move |ctx: ScrollAreaCtx| { + grid_content(&items, package_type, cluster_id, ctx).into_element() }) - .into_element() - } - CardLayout::Grid => ScrollArea::new() - .width(Size::fill()) - .height(Size::fill()) - .content(move |ctx: ScrollAreaCtx| { - grid_content(&items, package_type, cluster_id, ctx).into_element() - }) - .into_element(), - }); + .into_element(), + }); - rect() - .width(Size::fill()) - .height(Size::flex(1.0)) - .padding(Gaps::new_all(8.)) - .corner_radius(CornerRadius::new_all(12.)) - .background(PANEL_BG) - .overflow(Overflow::Clip) - .maybe_child(scroll) - .maybe_child((count == 0).then(|| empty_state(noun_plural))) - .into_element() + let empty = (count == 0).then(|| match kind { + ContentKind::External => external_empty(cluster_id, package_type).into_element(), + ContentKind::Local => { + local_empty(cluster_id, content_type, dispatch.clone(), noun_plural).into_element() + } + ContentKind::Other => empty_state(noun_plural).into_element(), + }); + + let mut bottom_corners = CornerRadius::new_all(0.); + bottom_corners.fill_bottom(12.); + + let drop_dispatch = dispatch.clone(); + + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new_all(8.)) + .corner_radius(bottom_corners) + .background(colors::page_elevated()) + .overflow(Overflow::Clip) + .maybe(*dropping.read(), |el| { + el.border( + Border::new() + .fill(colors::brand()) + .width(2.) + .alignment(BorderAlignment::Inner), + ) + }) + .on_global_file_hover(move |_| dropping.set(true)) + .on_global_file_hover_cancelled(move |_| dropping.set(false)) + .on_file_drop(move |e: Event| { + dropping.set(false); + if let Some(path) = &e.file_path { + drop_dispatch.import_local_file(cluster_id, content_type, path.clone()); + } + }) + .maybe_child(scroll) + .maybe_child(empty) + } } fn grid_content( @@ -308,8 +510,8 @@ fn grid_content( ctx: ScrollAreaCtx, ) -> impl IntoElement { let count = items.len(); - let cols = (((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize) - .clamp(1, 3); + let cols = + (((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 3); let rows_total = count.div_ceil(cols); let slot = CARD_GRID_H + GRID_GAP; @@ -334,9 +536,7 @@ fn grid_content( .content(Content::Flex); for c in 0..cols { let idx = r * cols + c; - let cell = rect() - .width(Size::flex(1.0)) - .height(Size::px(CARD_GRID_H)); + let cell = rect().width(Size::flex(1.0)).height(Size::px(CARD_GRID_H)); row = row.child(if idx < count { let item = items[idx].clone(); let key = item.package_id.clone(); @@ -358,7 +558,7 @@ fn grid_content( container.into_element() } -fn empty_state(noun_plural: &'static str) -> impl IntoElement { +fn empty_shell(icon: IconType) -> Rect { rect() .vertical() .width(Size::fill()) @@ -366,22 +566,59 @@ fn empty_state(noun_plural: &'static str) -> impl IntoElement { .center() .padding(Gaps::new_all(48.)) .spacing(8.) - .child( - Icon::new(IconType::DotsGrid) - .size(28.) - .color(colors::fg_secondary()), - ) - .child( - label() - .text(format!("No {noun_plural} here yet.")) - .font_size(14.) - .color(colors::fg_secondary()), - ) - .child( - label() - .text("Add one from a file or browse provider content.") - .font_size(12.) - .color(colors::fg_secondary()), - ) + .child(Icon::new(icon).size(28.).color(colors::fg_secondary())) +} + +fn empty_title(text: impl Into) -> impl IntoElement { + label() + .text(text.into()) + .font_size(14.) + .color(colors::fg_secondary()) +} + +fn empty_hint(text: impl Into) -> impl IntoElement { + label() + .text(text.into()) + .font_size(12.) + .color(colors::fg_secondary()) +} + +fn empty_state(noun_plural: &'static str) -> impl IntoElement { + empty_shell(IconType::DotsGrid) + .child(empty_title(format!("No {noun_plural} here yet."))) + .child(empty_hint( + "Add one from a file or browse provider content.", + )) + .into_element() +} + +fn external_empty(cluster_id: i64, package_type: &'static str) -> impl IntoElement { + empty_shell(IconType::SearchMd) + .child(empty_title("No external content installed.")) + .child(empty_hint( + "Browse providers to add mods, resource packs and more.", + )) + .child(rect().height(Size::px(6.))) + .child(browse_button(cluster_id, package_type)) + .into_element() +} + +fn local_empty( + cluster_id: i64, + content_type: ContentType, + dispatch: BridgeDispatch, + noun_plural: &'static str, +) -> impl IntoElement { + let is_wayland = utils::is_wayland(); + + empty_shell(IconType::FilePlus02) + .child(empty_title(format!("No local {noun_plural} yet."))) + .maybe(!is_wayland, |e| { + e.child(empty_hint( + "Tip: drag files onto the window to install them.", + )) + }) + .child(rect().height(Size::px(6.))) + .child(add_from_file_button(cluster_id, content_type, dispatch)) .into_element() } diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index aa839c14..2ced4500 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -198,13 +198,12 @@ pub async fn launch_cluster( let cwd = game_dir; tokio::fs::create_dir_all(&cwd).await.ok(); - if !dedicated { - if let Err(err) = + if !dedicated + && let Err(err) = crate::game::sync_shared_content(&state.services, &cluster, &cwd).await { tracing::warn!(cluster_id, error = %err, "failed to sync shared content"); } - } let client_jar = paths::versions_dir()? .join(&version_name)