From fb90eb7bb84ce77bf69d2e6d2bd9ed1cabb3429f Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:33:36 +0200 Subject: [PATCH] feat(ui): browser grid tags and install button on package cards --- .../oneclient_app/src/components/button.rs | 10 ++ .../src/view/app/browser/index/cards.rs | 113 ++++++++++-------- .../src/view/app/browser/index/mod.rs | 7 +- .../oneclient_app/src/view/app/browser/mod.rs | 25 +++- 4 files changed, 101 insertions(+), 54 deletions(-) diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index 3b74b866..cefb3681 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -53,6 +53,7 @@ pub struct Button { enabled: bool, focusable: bool, + alt: Option>, on_press: Option>>, elements: Vec, @@ -110,6 +111,7 @@ impl Button { variant, enabled: true, focusable: true, + alt: None, on_press: None, elements: Vec::new(), key: DiffKey::None, @@ -196,6 +198,13 @@ impl Button { self } + /// The name a button carrying only an icon goes by, since there is no label + /// for accessibility to read out. + pub fn alt(mut self, alt: impl Into>) -> Self { + self.alt = Some(alt.into()); + self + } + pub fn cursor_icon(mut self, cursor_icon: CursorIcon) -> Self { self.cursor_icon = cursor_icon; self @@ -284,6 +293,7 @@ impl Component for Button { .a11y_id(a11y_id) .a11y_focusable(enabled() && self.focusable) .a11y_role(AccessibilityRole::Button) + .map(self.alt.clone(), |rect, alt| rect.a11y_alt(alt)) .overflow(Overflow::Clip) .layout(self.layout.clone()) .text_style(self.text_style.clone()) diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index 76804707..7f6588ec 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -17,8 +17,16 @@ use crate::ui::border_all_color; type InstalledMap = HashMap<(ProviderId, String), Installed>; -fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option { - installed.get(&(item.provider, item.id.clone())).cloned() +/// How tall a card's footer row is kept, so the row sits at the same height +/// whether it holds the install button or only the download count. +const CARD_FOOTER_H: f32 = 30.; +/// Diameter of the round install button. +const INSTALL_BUTTON: f32 = 30.; + +fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option { + installed + .get(&(item.provider, item.id.clone())) + .map(|installed| installed.source) } pub(super) fn grid_row( @@ -29,7 +37,7 @@ pub(super) fn grid_row( ) -> impl IntoElement { let package_type = package_type.to_string(); let fill = GRID_COLUMNS - row.len(); - let installed: Vec> = row + let installed: Vec> = row .iter() .map(|item| installed_for(installed, item)) .collect(); @@ -70,7 +78,7 @@ struct PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, } impl PackageCard { @@ -78,7 +86,7 @@ impl PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, ) -> Self { Self { item, @@ -135,7 +143,17 @@ impl Component for PackageCard { ..Default::default() }) .overflow(Overflow::Clip) - .child(PackageBanner::new(icon_url, BANNER_H)), + .child(PackageBanner::new(icon_url, BANNER_H)) + // Sat in the banner's top-left corner rather than down in + // the footer: it is the first thing worth knowing about a + // result, and it leaves the footer to the install button. + .maybe_child(self.installed.map(|installed| { + rect() + .position(Position::new_absolute().top(8.).left(8.)) + .layer(Layer::Relative(7)) + .child(installed_badge_overlay(installed)) + .into_element() + })), ) .child( rect() @@ -184,6 +202,9 @@ impl Component for PackageCard { rect() .horizontal() .width(Size::fill()) + // Held at the button's height so the download count + // doesn't shift down on cards without one. + .height(Size::px(CARD_FOOTER_H)) .cross_align(Alignment::Center) .content(Content::Flex) .child(downloads_row(self.item.downloads)) @@ -193,16 +214,14 @@ impl Component for PackageCard { .width(Size::flex(1.0)) .cross_align(Alignment::Center) .main_align(Alignment::End) - .spacing(6.) - .maybe_child(self.installed.as_ref().map(|installed| { - installed_badge(installed.source, 10.).into_element() - })) - .child(InstallButton::new( - &self.item, - self.cluster_id, - &self.package_type, - self.installed.clone(), - )), + .maybe_child(self.installed.is_none().then(|| { + InstallButton::new( + &self.item, + self.cluster_id, + &self.package_type, + ) + .into_element() + })), ), ), ) @@ -228,7 +247,7 @@ struct ListRow { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, } impl Component for ListRow { @@ -311,44 +330,34 @@ impl Component for ListRow { ) .maybe_child( self.installed - .as_ref() - .map(|installed| installed_badge(installed.source, 11.).into_element()), + .map(|installed| installed_badge(installed, 11.).into_element()), ) .child(downloads_row(item.downloads)) - .child(InstallButton::new( - &self.item, - self.cluster_id, - &self.package_type, - self.installed.clone(), - )) + .maybe_child(self.installed.is_none().then(|| { + InstallButton::new(&self.item, self.cluster_id, &self.package_type).into_element() + })) } } /// Installs a listing entry's latest compatible version without making the user -/// open its page first. Same version pick, same dispatch and same in-flight -/// handling as the install button on the package page. +/// open its page first. Same version pick and same dispatch as the install +/// button on the package page. Only rendered for entries the cluster doesn't +/// already have — an installed or bundled one wears its badge instead. #[derive(PartialEq)] struct InstallButton { provider: ProviderId, project_id: String, cluster_id: i64, content_type: ContentType, - installed: Option, } impl InstallButton { - fn new( - item: &ProjectSummary, - cluster_id: i64, - package_type: &str, - installed: Option, - ) -> Self { + fn new(item: &ProjectSummary, cluster_id: i64, package_type: &str) -> Self { Self { provider: item.provider, project_id: item.id.clone(), cluster_id, content_type: content_type_for_slug(package_type), - installed, } } } @@ -382,13 +391,9 @@ impl Component for InstallButton { )); let latest = versions.first().map(|v| v.version_id.clone()); + // Nothing to start twice while the install that was just kicked off is + // still running, and nothing to start before the version list arrives. let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); - // Nothing to do when the cluster already has exactly this version, and - // nothing to do twice while the install that was just started is running. - let have_latest = match (&self.installed, &latest) { - (Some(installed), Some(latest)) => installed.is_version(latest), - _ => false, - }; rect() // The card behind this is one big press target. A press on the @@ -397,9 +402,13 @@ impl Component for InstallButton { .on_all_press(|e: Event| e.stop_propagation()) .child( Button::new() - .secondary() - .small() - .enabled(latest.is_some() && !have_latest && !installing) + .primary() + .width(Size::px(INSTALL_BUTTON)) + .height(Size::px(INSTALL_BUTTON)) + .padding(Gaps::new_all(0.)) + .corner_radius(CornerRadius::new_all(INSTALL_BUTTON / 2.)) + .alt(if installing { "Installing" } else { "Install" }) + .enabled(latest.is_some() && !installing) .on_press(move |_| { if let Some(version_id) = latest.clone() { dispatch.install_package( @@ -410,13 +419,15 @@ impl Component for InstallButton { ); } }) - .text(if installing { - "Installing..." - } else if have_latest { - "Installed" - } else { - "Install" - }), + .child( + Icon::new(if installing { + IconType::Loading02 + } else { + IconType::Download01 + }) + .size(15.) + .color(colors::fg_primary()), + ), ) } } diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index b626a141..7931b2d0 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -16,6 +16,7 @@ use oneclient_core::settings::ViewLayout; use crate::components::{ Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput, }; +use crate::hooks::use_cluster; use crate::hooks::{ BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list, cluster_content_items, content_type_for_slug, pick_version_metadata, search_items, @@ -25,9 +26,11 @@ use crate::hooks::{ }; use crate::routes::Route; use crate::theme::colors; -use crate::hooks::use_cluster; -use super::{Installed, PackageBanner, Thumbnail, installed_badge, installed_map}; +use super::{ + InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_badge_overlay, + installed_map, +}; use crate::utils::{abbreviate_number, sort_clusters_for_home}; mod cards; diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 0fe5b8a9..380ff905 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -13,6 +13,7 @@ use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo} use crate::components::{Icon, IconType}; use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; +use crate::ui::border_all_color; const BANNER_BG: Color = Color::from_rgb(21, 28, 34); @@ -105,6 +106,27 @@ pub(crate) fn installed_map( } pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement { + badge(installed, font_size, installed.color().with_a(38), None) +} + +/// The same badge sat on top of a card's artwork. The faint tint the in-card +/// badge uses has nothing to read against there, so this one brings its own +/// backdrop and outlines itself to stay legible over whatever the image is. +pub(crate) fn installed_badge_overlay(installed: InstallSource) -> impl IntoElement { + badge( + installed, + 10., + BANNER_BG.with_a(225), + Some(installed.color().with_a(110)), + ) +} + +fn badge( + installed: InstallSource, + font_size: f32, + background: Color, + border: Option, +) -> impl IntoElement { let color = installed.color(); rect() @@ -113,7 +135,8 @@ pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl .spacing(4.) .padding(Gaps::new_symmetric(2., 8.)) .corner_radius(CornerRadius::new_all(999.)) - .background(color.with_a(38)) + .background(background) + .map(border, |el, border| el.border(border_all_color(1., border))) .child( Icon::new(IconType::CheckCircle) .size(font_size)