feat(ui): browser grid tags and install button on package cards

This commit is contained in:
LynithDev 2026-08-01 13:33:36 +02:00
parent 6e25ea2bfd
commit fb90eb7bb8
4 changed files with 101 additions and 54 deletions

View file

@ -53,6 +53,7 @@ pub struct Button {
enabled: bool,
focusable: bool,
alt: Option<Box<str>>,
on_press: Option<EventHandler<Event<PressEventData>>>,
elements: Vec<Element>,
@ -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<Box<str>>) -> 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())

View file

@ -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> {
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<InstallSource> {
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<Option<Installed>> = row
let installed: Vec<Option<InstallSource>> = 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>,
installed: Option<InstallSource>,
}
impl PackageCard {
@ -78,7 +86,7 @@ impl PackageCard {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<Installed>,
installed: Option<InstallSource>,
) -> 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>,
installed: Option<InstallSource>,
}
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<Installed>,
}
impl InstallButton {
fn new(
item: &ProjectSummary,
cluster_id: i64,
package_type: &str,
installed: Option<Installed>,
) -> 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<PressEventData>| 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()),
),
)
}
}

View file

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

View file

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