chore: redesigned package grid (#867)

This commit is contained in:
LynithDev 2026-09-23 13:39:11 +02:00 • committed by GitHub
parent 36a925da06
commit 3a5f06e8ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 544 additions and 255 deletions

View file

@ -79,7 +79,7 @@ pub use notifications::NotificationCenter;
pub use optional_mods_popup::OptionalModsPopup;
pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost};
pub(crate) use package_row::{CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon};
pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge};
pub use package_row::{CardLayout, PackageEntry, PackageRow, package_context_menu, provider_badge};
pub use package_update_popup::PackageUpdatePopup;
pub use pagination::Pagination;
pub use player_model::PlayerModel;

View file

@ -20,7 +20,7 @@ const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const DIALOG_W: f32 = 720.;
const DIALOG_PAD: f32 = 22.;
const LIST_MAX_H: f32 = 306.;
const ICON_SIZE: f32 = 52.;
const ICON_SIZE: f32 = 40.;
type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
@ -103,6 +103,7 @@ fn entry_from_item(
author: cached
.map(|cached| cached.author.clone())
.unwrap_or_default(),
version: None,
description: cached
.map(|cached| cached.summary.clone())
.unwrap_or_default(),
@ -350,6 +351,7 @@ impl Component for OptionalModCard {
fn render(&self) -> impl IntoElement {
let icon_query = use_cached_image(self.entry.icon_url.clone(), 256);
let icon = package_icon(&self.entry, &icon_query, ICON_SIZE);
let hovered = use_state(|| false);
grid_card(
&self.entry,
@ -358,6 +360,8 @@ impl Component for OptionalModCard {
icon,
self.on_toggle.clone(),
false,
None,
hovered,
)
}
}

View file

@ -1,10 +1,13 @@
use freya::prelude::*;
use freya::query::UseMutation;
use freya::router::RouterContext;
use oneclient_content::packages::ProviderId;
use oneclient_core::SeenStatus;
use crate::components::{Icon, IconType, toggle_controlled};
use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation};
use crate::components::{ContextMenu, Icon, IconType, toggle_controlled};
use crate::hooks::{
ClusterAction, ClusterMutation, loaded_image, use_cached_image, use_cluster_mutation,
};
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::{ImageFallbackExt, border_all_color};
@ -13,9 +16,9 @@ use crate::utils::format_size;
pub(crate) const CARD_BG: Color = Color::from_rgb(26, 34, 41);
pub(crate) const CARD_NAME: Color = Color::from_rgb(213, 219, 255);
pub(crate) const CARD_H: f32 = 84.;
pub(crate) const CARD_GRID_H: f32 = 148.;
pub(crate) const GRID_GAP: f32 = 10.;
pub(crate) const GRID_MIN_W: f32 = 260.;
pub(crate) const CARD_GRID_H: f32 = 112.;
pub(crate) const GRID_GAP: f32 = 12.;
pub(crate) const GRID_MIN_W: f32 = 290.;
#[derive(Clone, Copy, PartialEq)]
pub enum CardLayout {
@ -40,6 +43,7 @@ pub struct PackageEntry {
pub name: String,
pub file_name: String,
pub author: String,
pub version: Option<String>,
pub description: String,
pub icon_url: Option<String>,
pub size: u64,
@ -85,6 +89,7 @@ pub struct PackageRow {
cluster_id: i64,
package_type: &'static str,
layout: CardLayout,
on_context: EventHandler<(f32, f32)>,
key: DiffKey,
}
@ -95,6 +100,7 @@ impl PackageRow {
cluster_id,
package_type,
layout: CardLayout::List,
on_context: (|_| {}).into(),
key: DiffKey::None,
}
}
@ -103,6 +109,11 @@ impl PackageRow {
self.layout = layout;
self
}
pub fn on_context(mut self, on_context: impl Into<EventHandler<(f32, f32)>>) -> Self {
self.on_context = on_context.into();
self
}
}
impl KeyExt for PackageRow {
@ -118,11 +129,11 @@ impl Component for PackageRow {
let package_type = self.package_type;
let layout = self.layout;
let cluster = use_cluster_mutation();
let remove_hover = use_state(|| false);
let hovered = use_state(|| false);
let icon_size = match layout {
CardLayout::List => 44.,
CardLayout::Grid => 52.,
CardLayout::Grid => 40.,
};
let icon_query = use_cached_image(item.icon_url.clone(), 256);
let icon = package_icon(&item, &icon_query, icon_size);
@ -153,45 +164,92 @@ impl Component for PackageRow {
.into()
};
let on_context = has_menu(&item).then(|| self.on_context.clone());
match layout {
CardLayout::List => {
let removable = !item.in_bundle();
let can_remove = removable && item.installed;
let rm_hash = item.hash.clone();
let on_remove = move || {
if let Some(h) = &rm_hash {
cluster.mutate(ClusterAction::RemoveArtifact {
cluster_id,
hash: h.clone(),
});
}
};
list_card(
&item,
package_type,
cluster_id,
icon,
on_toggle,
can_remove,
on_remove,
remove_hover,
)
list_card(&item, package_type, cluster_id, icon, on_toggle, on_context)
}
CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle, true),
CardLayout::Grid => grid_card(
&item,
package_type,
cluster_id,
icon,
on_toggle,
true,
on_context,
hovered,
),
}
}
}
pub fn has_menu(item: &PackageEntry) -> bool {
item.is_remote() || item.hash.is_some()
}
pub fn package_context_menu(
x: f32,
y: f32,
item: &PackageEntry,
cluster_id: i64,
package_type: &'static str,
cluster: UseMutation<ClusterMutation>,
) -> ContextMenu {
let mut menu = ContextMenu::new(x, y).title(item.name.clone());
if item.is_remote() {
let provider = item.provider;
let package_id = item.package_id.clone();
let package_type = package_type.to_string();
menu = menu.action(IconType::LinkExternal01, "View in browser", move |()| {
let _ = RouterContext::get().push(Route::BrowserPackage {
cluster_id,
package_type: package_type.clone(),
package_id: format!("{}:{}", provider as u8, package_id),
});
});
}
if let Some(hash) = item.hash.clone() {
menu = menu.action(IconType::Folder, "View in folder", move |()| {
reveal_in_store(hash.clone());
});
}
if item.installed && !item.in_bundle() {
let hash = item.hash.clone();
menu = menu
.separator()
.danger_action(IconType::Trash01, "Delete", move |()| {
if let Some(hash) = &hash {
cluster.mutate(ClusterAction::RemoveArtifact {
cluster_id,
hash: hash.clone(),
});
}
});
}
menu
}
fn on_secondary(handler: Option<EventHandler<(f32, f32)>>) -> impl FnMut(Event<PressEventData>) {
move |e: Event<PressEventData>| {
if let (Some(handler), PressEventData::Mouse(m)) = (handler.as_ref(), e.data()) {
e.stop_propagation();
handler.call((m.global_location.x as f32, m.global_location.y as f32));
}
}
}
#[allow(clippy::too_many_arguments)]
fn list_card(
item: &PackageEntry,
package_type: &'static str,
cluster_id: i64,
icon: impl IntoElement,
on_toggle: EventHandler<()>,
can_remove: bool,
on_remove: impl FnMut() + 'static,
hovering: State<bool>,
on_context: Option<EventHandler<(f32, f32)>>,
) -> Element {
rect()
.horizontal()
@ -203,13 +261,15 @@ fn list_card(
.corner_radius(CornerRadius::new_all(8.))
.background(CARD_BG)
.content(Content::Flex)
.on_secondary_down(on_secondary(on_context.clone()))
.child(package_info(item, package_type, cluster_id, icon))
.child(meta_size(item.size))
.child(toggle_controlled(item.enabled, on_toggle))
.child(remove_button(can_remove, on_remove, hovering))
.maybe_child(on_context.map(kebab_button))
.into_element()
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn grid_card(
item: &PackageEntry,
package_type: &'static str,
@ -217,46 +277,42 @@ pub(crate) fn grid_card(
icon: impl IntoElement,
on_toggle: EventHandler<()>,
navigable: bool, // if true, it takes the user to the mod page
on_context: Option<EventHandler<(f32, f32)>>,
mut hovered: State<bool>,
) -> Element {
let remote = item.is_remote();
let enabled = item.enabled;
let title = item.name.clone();
let hovering = *hovered.read();
let (bg, border, content_alpha) = if enabled {
(colors::brand().with_a(38), colors::brand(), 255)
} else {
(CARD_BG, colors::component_border(), 140)
let bg = match (enabled, hovering) {
(_, true) => colors::component_bg_hover(),
(true, false) => colors::component_bg(),
(false, false) => colors::component_bg_disabled(),
};
let badge = if remote && navigable {
let provider = item.provider;
let package_id = item.package_id.clone();
let package_type_owned = package_type.to_string();
rect()
.cursor(CursorIcon::Pointer)
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
let _ = RouterContext::get().push(Route::BrowserPackage {
cluster_id,
package_type: package_type_owned.clone(),
package_id: format!("{}:{}", provider as u8, package_id),
});
})
.child(provider_badge(item.provider))
.into_element()
} else if remote {
provider_badge(item.provider)
let alpha = if enabled {
255u8
} else {
115u8
};
let border = if !hovering {
colors::component_border()
} else {
local_badge()
colors::component_border_hover()
};
let header = rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(10.)
// .cross_align(Alignment::Center)
.spacing(11.)
.content(Content::Flex)
.child(icon)
.child(
rect()
.opacity(alpha as f32 / 255.)
.child(icon)
)
.child(
rect()
.vertical()
@ -266,62 +322,59 @@ pub(crate) fn grid_card(
label()
.text(title)
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.width(Size::fill())
.color(CARD_NAME.with_a(content_alpha)),
.color(if enabled {
Color::WHITE
} else {
CARD_NAME.with_a(alpha)
}),
)
.maybe(!item.author.is_empty(), |el| {
el.child(
label()
.text(format!("by {}", item.author))
.font_size(10.)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.width(Size::fill())
.color(colors::fg_secondary().with_a(content_alpha)),
)
})
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(6.)
.content(Content::wrap_spacing(4.))
.child(badge)
.maybe_child(item.is_outdated().then(outdated_badge))
.maybe_child(item.recency_badge()),
),
.child(grid_meta(item, package_type, cluster_id, navigable, alpha)),
)
.maybe_child(on_context.clone().map(kebab_button))
.into_element();
let description = if item.description.is_empty() {
None
} else {
Some(
label()
.text(item.description.clone())
.font_size(11.)
.max_lines(3)
.width(Size::fill())
.color(colors::fg_secondary().with_a(content_alpha))
.into_element(),
)
};
let description = (!item.description.is_empty()).then(|| {
label()
.text(item.description.clone())
.font_size(11.)
.line_height(1.45)
.max_lines(2)
.text_overflow(TextOverflow::Ellipsis)
.width(Size::fill())
.color(colors::fg_primary().with_a(scale_a(alpha, 0.72)))
.into_element()
});
let floating = (item.is_outdated() || item.recency_badge().is_some()).then(|| {
rect()
.horizontal()
.position(Position::new_absolute().bottom(10.).right(10.))
.cross_align(Alignment::Center)
.spacing(4.)
.maybe_child(item.is_outdated().then(outdated_badge))
.maybe_child(item.recency_badge())
.into_element()
});
rect()
.vertical()
.width(Size::fill())
.height(Size::fill())
.spacing(8.)
.padding(Gaps::new_all(12.))
.corner_radius(CornerRadius::new_all(8.))
.background(bg)
.border(border_all_color(1.5, border))
.spacing(9.)
.padding(Gaps::new_all(14.))
.corner_radius(CornerRadius::new_all(6.))
.background(bg.with_a(alpha))
.border(border_all_color(1., border))
.overflow(Overflow::Clip)
.content(Content::Flex)
.cursor(CursorIcon::Pointer)
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| hovered.set(false))
.on_secondary_down(on_secondary(on_context))
.on_press(move |_| on_toggle.call(()))
.child(header)
.child(
@ -331,9 +384,191 @@ pub(crate) fn grid_card(
.height(Size::flex(1.0))
.maybe_child(description),
)
.maybe_child(floating)
.into_element()
}
fn scale_a(alpha: u8, factor: f32) -> u8 {
(alpha as f32 * factor) as u8
}
fn grid_meta(
item: &PackageEntry,
package_type: &'static str,
cluster_id: i64,
navigable: bool,
alpha: u8,
) -> Element {
let muted = CARD_NAME.with_a(scale_a(alpha, 0.5));
let source = if item.is_remote() && navigable {
SourceLink {
provider: item.provider,
package_id: item.package_id.clone(),
package_type,
cluster_id,
alpha,
}
.into_element()
} else if item.is_remote() {
meta_text(item.provider.to_string(), muted)
} else {
meta_text("Local file".to_string(), muted)
};
let mut parts: Vec<Element> = Vec::new();
if !item.author.is_empty() {
parts.push(meta_text(format!("by {}", item.author), muted));
}
if let Some(version) = &item.version {
parts.push(meta_text(version.clone(), muted));
}
parts.push(source);
let mut row = rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(5.)
.overflow(Overflow::Clip);
for (i, part) in parts.into_iter().enumerate() {
if i > 0 {
row = row.child(meta_text("\u{b7}".to_string(), muted));
}
row = row.child(part);
}
row.into_element()
}
#[derive(PartialEq)]
struct SourceLink {
provider: ProviderId,
package_id: String,
package_type: &'static str,
cluster_id: i64,
alpha: u8,
}
impl Component for SourceLink {
fn render(&self) -> impl IntoElement {
let mut hovered = use_state(|| false);
let mut pressed = use_state(|| false);
let color = if *pressed.read() {
colors::fg_primary_pressed()
} else if *hovered.read() {
colors::fg_primary_hover()
} else {
CARD_NAME.with_a(scale_a(self.alpha, 0.68))
};
let provider = self.provider;
let package_id = self.package_id.clone();
let package_type = self.package_type.to_string();
let cluster_id = self.cluster_id;
rect()
.cursor(CursorIcon::Pointer)
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| {
hovered.set(false);
pressed.set(false);
})
.on_pointer_down(move |_| pressed.set(true))
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
pressed.set(false);
let _ = RouterContext::get().push(Route::BrowserPackage {
cluster_id,
package_type: package_type.clone(),
package_id: format!("{}:{}", provider as u8, package_id),
});
})
.child(meta_text(provider.to_string(), color))
}
}
fn meta_text(text: String, color: Color) -> Element {
label()
.text(text)
.font_size(11.)
.max_lines(1)
.color(color)
.into_element()
}
fn kebab_button(on_context: EventHandler<(f32, f32)>) -> Element {
KebabButton { on_context }.into_element()
}
#[derive(PartialEq)]
struct KebabButton {
on_context: EventHandler<(f32, f32)>,
}
impl Component for KebabButton {
fn render(&self) -> impl IntoElement {
let mut hovered = use_state(|| false);
let on_context = self.on_context.clone();
rect()
.center()
.width(Size::px(26.))
.height(Size::px(26.))
.corner_radius(CornerRadius::new_all(6.))
.background(if *hovered.read() {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
})
.cursor(CursorIcon::Pointer)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| hovered.set(false))
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
if let PressEventData::Mouse(m) = e.data() {
on_context.call((m.global_location.x as f32, m.global_location.y as f32));
}
})
.child(
Icon::new(IconType::DotsVertical)
.size(16.)
.color(if *hovered.read() {
colors::fg_primary()
} else {
colors::fg_secondary()
}),
)
}
}
fn reveal_in_store(hash: String) {
spawn(async move {
let Ok(state) = crate::launcher::state() else {
return;
};
let row = match oneclient_db::dao::artifact::get_artifact_by_hash(&state.services.db, &hash)
.await
{
Ok(Some(row)) => row,
Ok(None) => return,
Err(err) => {
tracing::warn!(%err, "failed to look up artifact for reveal");
return;
}
};
let Ok(path) = oneclient_content::packages::store::artifact_absolute_path(&row.path) else {
return;
};
if let Some(dir) = path.parent() {
crate::platform::open_path(&dir.to_string_lossy());
}
});
}
fn package_info(
item: &PackageEntry,
package_type: &'static str,
@ -538,37 +773,3 @@ fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element
)
.into_element()
}
fn remove_button(
enabled: bool,
mut on_remove: impl FnMut() + 'static,
mut hovering: State<bool>,
) -> impl IntoElement {
let hot = enabled && *hovering.read();
let color = if !enabled {
colors::fg_secondary().with_a(90)
} else if hot {
colors::danger()
} else {
colors::fg_secondary()
};
rect()
.center()
.width(Size::px(30.))
.height(Size::px(30.))
.corner_radius(CornerRadius::new_all(7.))
.background(if hot {
colors::danger().with_a(30)
} else {
Color::TRANSPARENT
})
.maybe(enabled, |el| {
el.cursor(CursorIcon::Pointer)
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_press(move |_| on_remove())
})
.child(Icon::new(IconType::Trash01).size(14.).color(color))
.into_element()
}

View file

@ -246,6 +246,61 @@ impl ScrollArea {
self
}
pub fn lazy_grid(
mut self,
count: usize,
item_height: f32,
gap: f32,
min_width: f32,
max_cols: usize,
render: impl Fn(usize) -> Element + 'static,
) -> Self {
let slot = (item_height + gap).max(1.);
self.builder = Some(Box::new(move |ctx: ScrollAreaCtx| {
let cols = (((ctx.viewport_w + gap) / (min_width + gap)).floor() as usize)
.clamp(1, max_cols.max(1));
let rows_total = count.div_ceil(cols);
let first =
(((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize;
let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize;
let last = (first + span).min(rows_total);
let top_pad = first as f32 * slot;
let bottom_pad = rows_total.saturating_sub(last) as f32 * slot;
let mut container = rect().vertical().width(Size::fill());
if top_pad > 0. {
container = container.child(rect().width(Size::fill()).height(Size::px(top_pad)));
}
for r in first..last {
let mut row = rect()
.key(r)
.horizontal()
.width(Size::fill())
.height(Size::px(slot))
.spacing(gap)
.content(Content::Flex);
for c in 0..cols {
let idx = r * cols + c;
let cell = rect().width(Size::flex(1.0)).height(Size::px(item_height));
row = row.child(if idx < count {
cell.child(render(idx))
} else {
cell
});
}
container = container.child(row);
}
if bottom_pad > 0. {
container =
container.child(rect().width(Size::fill()).height(Size::px(bottom_pad)));
}
container.into_element()
}));
self
}
fn view(&self) -> impl IntoElement {
let horizontal = self.horizontal;
let content_w = self.content_width;

View file

@ -117,7 +117,11 @@ fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement {
FontWeight::NORMAL
})
.max_lines(1)
.color(colors::fg_primary())
.color(if active {
colors::fg_primary()
} else {
colors::fg_secondary()
})
.text_align(TextAlign::Center),
),
)
@ -221,12 +225,12 @@ impl Component for TabButton {
.child(tab_label(&self.label, font_size, active))
.child(
rect()
.height(Size::px(1.5))
.height(Size::px(2.))
.width(Size::fill_minimum())
.margin(Gaps::new_symmetric(0., 4.0))
.corner_radius(CornerRadius::new_all(2.))
.background(if underline_on {
colors::fg_primary()
colors::brand()
} else {
Color::TRANSPARENT
}),

View file

@ -27,14 +27,14 @@ pub use actions::{Actions, NotificationBuilder, PumpSignal};
pub use queries::{
AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery,
CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles,
ClusterLogsQuery, DiscardLeftoversKeys, DiscardLeftoversMutation, FinishMicrosoftLoginMutation,
LeftoversQuery, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery,
RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, StorageAction,
StorageActionMutation, StorageReportQuery, TermsQuery, UploadLogKeys, UploadLogMutation,
UseDiscardLeftovers, UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction,
UseSetDefaultAccount, UseStorageAction, UseUploadLog, VERSIONS_PAGE_SIZE,
accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list,
changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
ClusterLogsQuery, ClusterMutation, DiscardLeftoversKeys, DiscardLeftoversMutation,
FinishMicrosoftLoginMutation, LeftoversQuery, LogAction, LogContentQuery, MigrationQuery,
OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction,
SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery,
UploadLogKeys, UploadLogMutation, UseDiscardLeftovers, UseLogAction, UseRefreshAccount,
UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseStorageAction, UseUploadLog,
VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items,
category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
content_type_for_slug, has_migration_data, invalidate_cluster_content_queries,
invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries,
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,

View file

@ -69,7 +69,7 @@ pub use logs::{
};
pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration};
pub use mutations::{
ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries,
ClusterAction, ClusterMutation, invalidate_cluster_content_queries, invalidate_cluster_queries,
invalidate_profile_queries, use_cluster_mutation,
};
pub use package_updates::{package_updates, stale_hashes, use_package_updates};

View file

@ -13,7 +13,7 @@ use crate::ui::entrance_motion_layer;
use crate::view::app::launch_button_state;
const HEADER_HEIGHT: f32 = 64.;
const TABS_HEIGHT: f32 = 44.;
const TABS_HEIGHT: f32 = 38.;
const BAR_SPACING: f32 = 16.;
#[derive(PartialEq, Clone, Copy)]
@ -330,15 +330,22 @@ fn cluster_tabs(
.width(Size::fill())
.height(Size::px(TABS_HEIGHT))
.cross_align(Alignment::Center)
.padding(Gaps::new(0., 24., 0., 24.))
.background(colors::page_elevated())
.corner_radius(CornerRadius::new_all(12.))
.border(
Border::new()
.fill(colors::component_border())
.width(BorderWidth {
top: 0.,
right: 0.,
bottom: 1.,
left: 0.,
}),
)
.child(
TabBar::new()
.width(Size::fill())
.width(Size::auto())
.height(Size::fill())
.spacing(24.)
.font_size(12.)
.spacing(22.)
.font_size(13.)
.tabs(tab_items),
)
}

View file

@ -14,7 +14,7 @@ use views::{ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, tool
const CARD_H: f32 = 84.;
const CARD_SPACING: f32 = 8.;
const LAZY_OVERSCAN: i64 = 2;
const GRID_MAX_COLS: usize = 5;
pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
@ -227,14 +227,10 @@ fn make_row(
.map(|p| p.author.clone())
.filter(|s| !s.is_empty())
.unwrap_or_default();
let version = installed_info.and_then(|i| i.display_version.clone());
let description = m
.map(|p| p.summary.clone())
.filter(|s| !s.is_empty())
.or_else(|| {
installed_info
.and_then(|i| i.display_version.clone())
.map(|v| format!("Version {v}"))
})
.unwrap_or_default();
PackageEntry {
@ -244,6 +240,7 @@ fn make_row(
name,
file_name,
author,
version,
description,
icon_url: m.and_then(|p| p.icon_url.clone()),
size,

View file

@ -6,10 +6,10 @@ use oneclient_content::packages::ContentType;
use oneclient_core::settings::ViewLayout;
use crate::components::{
Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx,
Segment, SegmentedControl, TabBar, TabItem, TextInput,
Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, Segment,
SegmentedControl, TextInput, package_context_menu,
};
use crate::hooks::{use_dispatch, use_overlay_claim};
use crate::hooks::{use_cluster_mutation, use_dispatch, use_overlay_claim};
use crate::routes::Route;
use crate::theme::colors;
use crate::{Actions, utils};
@ -149,9 +149,14 @@ pub(super) fn toolbar_bar(
package_type: &'static str,
mut toolbar_width: State<f32>,
) -> impl IntoElement {
let tab_items = tabs.iter().enumerate().map(|(i, tab)| {
let chips = tabs.iter().enumerate().map(|(i, tab)| {
let mut active = active;
TabItem::new(tab.label(), i == active_idx).on_press(move |_| *active.write() = i)
CategoryChip {
label: tab.label(),
selected: i == active_idx,
on_press: (move |_| *active.write() = i).into(),
}
.into_element()
});
let mut top_corners = CornerRadius::new_all(0.);
@ -163,21 +168,22 @@ pub(super) fn toolbar_bar(
let filter_tabs = ScrollView::new()
.direction(Direction::Horizontal)
.invert_scroll_wheel(true)
.show_scrollbar(stacked)
.show_scrollbar(true)
.scrollbar_theme(tabs_scrollbar_theme())
.width(if stacked {
Size::fill()
} else {
Size::flex(1.0)
})
.height(if stacked { Size::fill() } else { Size::auto() })
.height(Size::px(TABS_ROW_H))
.child(
TabBar::new()
rect()
.horizontal()
.width(Size::auto())
.height(if stacked { Size::fill() } else { Size::auto() })
.spacing(20.)
.font_size(12.)
.tabs(tab_items),
.height(Size::fill())
.cross_align(Alignment::Center)
.spacing(6.)
.children(chips),
)
.into_element();
@ -273,6 +279,67 @@ pub(super) fn toolbar_bar(
.into_element()
}
#[derive(PartialEq)]
struct CategoryChip {
label: String,
selected: bool,
on_press: EventHandler<Event<PressEventData>>,
}
impl Component for CategoryChip {
fn render(&self) -> impl IntoElement {
let mut hovered = use_state(|| false);
let selected = self.selected;
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let background = if selected {
colors::brand()
} else if *hovered.read() {
colors::component_bg_hover()
} else {
colors::component_bg()
};
rect()
.horizontal()
.height(Size::px(32.))
.center()
.padding(Gaps::new_symmetric(0., 12.))
.corner_radius(CornerRadius::new_all(8.))
.background(background)
.border(crate::ui::border_all_color(
1.,
if selected || focused {
colors::brand()
} else {
colors::component_border()
},
))
.cursor(CursorIcon::Pointer)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Tab)
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| hovered.set(false))
.on_press(self.on_press.clone())
.child(
label()
.text(self.label.clone())
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.color(if selected {
Color::WHITE
} else {
colors::fg_primary()
}),
)
}
}
pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentType) -> Element {
let text = match content_type {
ContentType::ResourcePack => format!(
@ -673,33 +740,42 @@ impl Component for ContentBox {
let layout = self.layout;
let dispatch = use_dispatch();
let cluster = use_cluster_mutation();
let mut menu = use_state(|| None::<(f32, f32, PackageEntry)>);
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())
.scrollbar_gutter(true)
.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()
})
let row = {
let items = items.clone();
move |i: usize| {
let item: PackageEntry = items[i].clone();
let key = item.package_id.clone();
let for_menu = item.clone();
PackageRow::new(item, cluster_id, package_type)
.layout(layout)
.on_context(move |(x, y)| menu.set(Some((x, y, for_menu.clone()))))
.key(key)
.into_element()
}
CardLayout::Grid => ScrollArea::new()
};
let count = items.len();
let scroll = (count > 0).then(|| {
let area = ScrollArea::new()
.width(Size::fill())
.height(Size::fill())
.scrollbar_gutter(true)
.content(move |ctx: ScrollAreaCtx| {
grid_content(&items, package_type, cluster_id, ctx).into_element()
})
.into_element(),
.scrollbar_gutter(true);
match layout {
CardLayout::List => area.lazy(count, CARD_H, CARD_SPACING, row),
CardLayout::Grid => {
area.lazy_grid(count, CARD_GRID_H, GRID_GAP, GRID_MIN_W, GRID_MAX_COLS, row)
}
}
.into_element()
});
let menu_overlay = menu.read().clone().map(|(x, y, item)| {
package_context_menu(x, y, &item, cluster_id, package_type, cluster)
.on_close(move |_| menu.set(None))
.into_element()
});
let empty = (count == 0).then(|| match kind {
@ -737,14 +813,14 @@ impl Component for ContentBox {
.vertical()
.width(Size::fill())
.height(Size::flex(1.0))
.spacing(8.)
.padding(Gaps::new_all(8.))
.padding(Gaps::new(0., 12., 12., 12.))
.corner_radius(bottom_corners)
.background(colors::page_elevated())
.overflow(Overflow::Clip)
.maybe_child(header)
.maybe_child(scroll)
.maybe_child(empty)
.maybe_child(menu_overlay)
}
}
@ -758,61 +834,6 @@ fn action_header(button: impl IntoElement) -> impl IntoElement {
.child(button)
}
fn grid_content(
items: &[PackageEntry],
package_type: &'static str,
cluster_id: i64,
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, 5);
let rows_total = count.div_ceil(cols);
let slot = CARD_GRID_H + GRID_GAP;
let first_row = (((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize;
let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize;
let last_row = (first_row + span).min(rows_total);
let top_pad = first_row as f32 * slot;
let bottom_pad = rows_total.saturating_sub(last_row) as f32 * slot;
let mut container = rect().vertical().width(Size::fill());
if top_pad > 0. {
container = container.child(rect().width(Size::fill()).height(Size::px(top_pad)));
}
for r in first_row..last_row {
let mut row = rect()
.key(r)
.horizontal()
.width(Size::fill())
.height(Size::px(slot))
.spacing(GRID_GAP)
.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));
row = row.child(if idx < count {
let item = items[idx].clone();
let key = item.package_id.clone();
cell.child(
PackageRow::new(item, cluster_id, package_type)
.layout(CardLayout::Grid)
.key(key)
.into_element(),
)
} else {
cell
});
}
container = container.child(row);
}
if bottom_pad > 0. {
container = container.child(rect().width(Size::fill()).height(Size::px(bottom_pad)));
}
container.into_element()
}
fn empty_shell(icon: IconType) -> Rect {
rect()
.vertical()