feat: More compact cluster package manager view + package manager filter popover

This commit is contained in:
LynithDev 2026-07-08 17:19:37 +02:00
parent 0af7c7455b
commit 2a2d992649
5 changed files with 497 additions and 248 deletions

View file

@ -19,6 +19,7 @@ impl TabItem {
}
}
#[allow(dead_code)]
pub fn count_text(mut self, text: impl Into<String>) -> 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))

View file

@ -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<bool> = 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
}
})
}

View file

@ -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<Tab> {
}
}
}
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<Tab> = 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<PackageEntry> = 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,
))
}

View file

@ -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<Self> {
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<usize>,
search: State<String>,
mut sort: State<Option<String>>,
current: SortFilter,
sort: State<Option<String>>,
current_sort: SortMode,
enabled_filter: State<EnabledFilter>,
layout: State<ViewLayout>,
) -> 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<std::path::PathBuf>,
#[derive(PartialEq)]
struct FilterButton {
sort: State<Option<String>>,
current_sort: SortMode,
enabled_filter: State<EnabledFilter>,
}
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<PressEventData>| {
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<Option<String>>,
current_sort: SortMode,
mut enabled_filter: State<EnabledFilter>,
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<usize>,
) -> 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<PressEventData>) + '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<PackageEntry>,
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<PackageEntry>,
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<FileEventData>| {
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<String>) -> impl IntoElement {
label()
.text(text.into())
.font_size(14.)
.color(colors::fg_secondary())
}
fn empty_hint(text: impl Into<String>) -> 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()
}

View file

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