From 3a5f06e8ac51dbde8452fddfca7a97447c9f1ac3 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:39:11 +0200 Subject: [PATCH] chore: redesigned package grid (#867) --- packages/oneclient_app/src/components/mod.rs | 2 +- .../src/components/optional_mods_popup.rs | 6 +- .../src/components/package_row.rs | 475 +++++++++++++----- .../src/components/scrollview.rs | 55 ++ .../oneclient_app/src/components/tab_bar.rs | 10 +- packages/oneclient_app/src/hooks/mod.rs | 16 +- .../oneclient_app/src/hooks/queries/mod.rs | 2 +- .../oneclient_app/src/layout/cluster_shell.rs | 21 +- .../view/app/cluster/package_manager/mod.rs | 9 +- .../view/app/cluster/package_manager/views.rs | 203 ++++---- 10 files changed, 544 insertions(+), 255 deletions(-) diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index bdb89577..747edfd8 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -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; diff --git a/packages/oneclient_app/src/components/optional_mods_popup.rs b/packages/oneclient_app/src/components/optional_mods_popup.rs index 53ad7e73..acb26ebf 100644 --- a/packages/oneclient_app/src/components/optional_mods_popup.rs +++ b/packages/oneclient_app/src/components/optional_mods_popup.rs @@ -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, ) } } diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 9f40bae7..6ccfe897 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -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, pub description: String, pub icon_url: Option, 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>) -> 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, +) -> 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>) -> impl FnMut(Event) { + move |e: Event| { + 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, + on_context: Option>, ) -> 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>, + mut hovered: State, ) -> 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| { - 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 = 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| { + 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| { + 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, -) -> 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() -} diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index e2762600..3623b8f6 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -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; diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs index 39679069..c761e612 100644 --- a/packages/oneclient_app/src/components/tab_bar.rs +++ b/packages/oneclient_app/src/components/tab_bar.rs @@ -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 }), diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index b8e24060..09c379c0 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -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, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index d49c26c8..db5ec729 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -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}; diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 85e09b49..cf62ae64 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -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), ) } 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 d48db615..cb4ab4d0 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 @@ -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, 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 31c1ee92..d572e87b 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 @@ -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, ) -> 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>, +} + +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()