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, enabled: bool,
focusable: bool, focusable: bool,
alt: Option<Box<str>>,
on_press: Option<EventHandler<Event<PressEventData>>>, on_press: Option<EventHandler<Event<PressEventData>>>,
elements: Vec<Element>, elements: Vec<Element>,
@ -110,6 +111,7 @@ impl Button {
variant, variant,
enabled: true, enabled: true,
focusable: true, focusable: true,
alt: None,
on_press: None, on_press: None,
elements: Vec::new(), elements: Vec::new(),
key: DiffKey::None, key: DiffKey::None,
@ -196,6 +198,13 @@ impl Button {
self 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 { pub fn cursor_icon(mut self, cursor_icon: CursorIcon) -> Self {
self.cursor_icon = cursor_icon; self.cursor_icon = cursor_icon;
self self
@ -284,6 +293,7 @@ impl Component for Button {
.a11y_id(a11y_id) .a11y_id(a11y_id)
.a11y_focusable(enabled() && self.focusable) .a11y_focusable(enabled() && self.focusable)
.a11y_role(AccessibilityRole::Button) .a11y_role(AccessibilityRole::Button)
.map(self.alt.clone(), |rect, alt| rect.a11y_alt(alt))
.overflow(Overflow::Clip) .overflow(Overflow::Clip)
.layout(self.layout.clone()) .layout(self.layout.clone())
.text_style(self.text_style.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>; type InstalledMap = HashMap<(ProviderId, String), Installed>;
fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option<Installed> { /// How tall a card's footer row is kept, so the row sits at the same height
installed.get(&(item.provider, item.id.clone())).cloned() /// 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( pub(super) fn grid_row(
@ -29,7 +37,7 @@ pub(super) fn grid_row(
) -> impl IntoElement { ) -> impl IntoElement {
let package_type = package_type.to_string(); let package_type = package_type.to_string();
let fill = GRID_COLUMNS - row.len(); let fill = GRID_COLUMNS - row.len();
let installed: Vec<Option<Installed>> = row let installed: Vec<Option<InstallSource>> = row
.iter() .iter()
.map(|item| installed_for(installed, item)) .map(|item| installed_for(installed, item))
.collect(); .collect();
@ -70,7 +78,7 @@ struct PackageCard {
item: ProjectSummary, item: ProjectSummary,
cluster_id: i64, cluster_id: i64,
package_type: String, package_type: String,
installed: Option<Installed>, installed: Option<InstallSource>,
} }
impl PackageCard { impl PackageCard {
@ -78,7 +86,7 @@ impl PackageCard {
item: ProjectSummary, item: ProjectSummary,
cluster_id: i64, cluster_id: i64,
package_type: String, package_type: String,
installed: Option<Installed>, installed: Option<InstallSource>,
) -> Self { ) -> Self {
Self { Self {
item, item,
@ -135,7 +143,17 @@ impl Component for PackageCard {
..Default::default() ..Default::default()
}) })
.overflow(Overflow::Clip) .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( .child(
rect() rect()
@ -184,6 +202,9 @@ impl Component for PackageCard {
rect() rect()
.horizontal() .horizontal()
.width(Size::fill()) .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) .cross_align(Alignment::Center)
.content(Content::Flex) .content(Content::Flex)
.child(downloads_row(self.item.downloads)) .child(downloads_row(self.item.downloads))
@ -193,16 +214,14 @@ impl Component for PackageCard {
.width(Size::flex(1.0)) .width(Size::flex(1.0))
.cross_align(Alignment::Center) .cross_align(Alignment::Center)
.main_align(Alignment::End) .main_align(Alignment::End)
.spacing(6.) .maybe_child(self.installed.is_none().then(|| {
.maybe_child(self.installed.as_ref().map(|installed| { InstallButton::new(
installed_badge(installed.source, 10.).into_element() &self.item,
})) self.cluster_id,
.child(InstallButton::new( &self.package_type,
&self.item, )
self.cluster_id, .into_element()
&self.package_type, })),
self.installed.clone(),
)),
), ),
), ),
) )
@ -228,7 +247,7 @@ struct ListRow {
item: ProjectSummary, item: ProjectSummary,
cluster_id: i64, cluster_id: i64,
package_type: String, package_type: String,
installed: Option<Installed>, installed: Option<InstallSource>,
} }
impl Component for ListRow { impl Component for ListRow {
@ -311,44 +330,34 @@ impl Component for ListRow {
) )
.maybe_child( .maybe_child(
self.installed self.installed
.as_ref() .map(|installed| installed_badge(installed, 11.).into_element()),
.map(|installed| installed_badge(installed.source, 11.).into_element()),
) )
.child(downloads_row(item.downloads)) .child(downloads_row(item.downloads))
.child(InstallButton::new( .maybe_child(self.installed.is_none().then(|| {
&self.item, InstallButton::new(&self.item, self.cluster_id, &self.package_type).into_element()
self.cluster_id, }))
&self.package_type,
self.installed.clone(),
))
} }
} }
/// Installs a listing entry's latest compatible version without making the user /// 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 /// open its page first. Same version pick and same dispatch as the install
/// handling as the install button on the package page. /// 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)] #[derive(PartialEq)]
struct InstallButton { struct InstallButton {
provider: ProviderId, provider: ProviderId,
project_id: String, project_id: String,
cluster_id: i64, cluster_id: i64,
content_type: ContentType, content_type: ContentType,
installed: Option<Installed>,
} }
impl InstallButton { impl InstallButton {
fn new( fn new(item: &ProjectSummary, cluster_id: i64, package_type: &str) -> Self {
item: &ProjectSummary,
cluster_id: i64,
package_type: &str,
installed: Option<Installed>,
) -> Self {
Self { Self {
provider: item.provider, provider: item.provider,
project_id: item.id.clone(), project_id: item.id.clone(),
cluster_id, cluster_id,
content_type: content_type_for_slug(package_type), 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()); 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); 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() rect()
// The card behind this is one big press target. A press on the // 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()) .on_all_press(|e: Event<PressEventData>| e.stop_propagation())
.child( .child(
Button::new() Button::new()
.secondary() .primary()
.small() .width(Size::px(INSTALL_BUTTON))
.enabled(latest.is_some() && !have_latest && !installing) .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 |_| { .on_press(move |_| {
if let Some(version_id) = latest.clone() { if let Some(version_id) = latest.clone() {
dispatch.install_package( dispatch.install_package(
@ -410,13 +419,15 @@ impl Component for InstallButton {
); );
} }
}) })
.text(if installing { .child(
"Installing..." Icon::new(if installing {
} else if have_latest { IconType::Loading02
"Installed" } else {
} else { IconType::Download01
"Install" })
}), .size(15.)
.color(colors::fg_primary()),
),
) )
} }
} }

View file

@ -16,6 +16,7 @@ use oneclient_core::settings::ViewLayout;
use crate::components::{ use crate::components::{
Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput, Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput,
}; };
use crate::hooks::use_cluster;
use crate::hooks::{ use crate::hooks::{
BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list, BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list,
cluster_content_items, content_type_for_slug, pick_version_metadata, search_items, 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::routes::Route;
use crate::theme::colors; 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}; use crate::utils::{abbreviate_number, sort_clusters_for_home};
mod cards; mod cards;

View file

@ -13,6 +13,7 @@ use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}
use crate::components::{Icon, IconType}; use crate::components::{Icon, IconType};
use crate::hooks::{loaded_image, use_cached_image}; use crate::hooks::{loaded_image, use_cached_image};
use crate::theme::colors; use crate::theme::colors;
use crate::ui::border_all_color;
const BANNER_BG: Color = Color::from_rgb(21, 28, 34); 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 { 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(); let color = installed.color();
rect() rect()
@ -113,7 +135,8 @@ pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl
.spacing(4.) .spacing(4.)
.padding(Gaps::new_symmetric(2., 8.)) .padding(Gaps::new_symmetric(2., 8.))
.corner_radius(CornerRadius::new_all(999.)) .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( .child(
Icon::new(IconType::CheckCircle) Icon::new(IconType::CheckCircle)
.size(font_size) .size(font_size)