chore: redesigned browser grid (#869)

This commit is contained in:
LynithDev 2026-09-23 23:49:57 +02:00 • committed by GitHub
parent 3a5f06e8ac
commit 520e6c1ef1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 385 additions and 235 deletions

View file

@ -14,6 +14,7 @@ pub struct Dropdown {
selected: String,
options: Vec<String>,
on_select: Option<EventHandler<usize>>,
leading: Option<Element>,
width: Size,
height: Size,
key: DiffKey,
@ -25,6 +26,7 @@ impl Dropdown {
selected: selected.into(),
options,
on_select: None,
leading: None,
width: Size::px(72.),
height: Size::px(24.),
key: DiffKey::None,
@ -43,6 +45,11 @@ impl Dropdown {
self
}
pub fn leading(mut self, leading: impl IntoElement) -> Self {
self.leading = Some(leading.into_element());
self
}
pub fn on_select(mut self, handler: impl Into<EventHandler<usize>>) -> Self {
self.on_select = Some(handler.into());
self
@ -67,6 +74,7 @@ impl Component for Dropdown {
let mut list_size = use_state(|| None::<Size2D>);
let selected = self.selected.clone();
let leading = self.leading.clone();
let options = self.options.clone();
let on_select = self.on_select.clone();
let is_open = open();
@ -143,6 +151,7 @@ impl Component for Dropdown {
e.stop_propagation();
open.toggle();
})
.maybe_child(leading)
.child(
label()
.text(selected)

View file

@ -37,7 +37,7 @@ impl Default for MarkdownStyle {
paragraph_size: 16.,
code_font_size: 14.,
table_font_size: 14.,
code_font_family: Cow::Borrowed("Jetbrains Mono"),
code_font_family: Cow::Borrowed(crate::theme::MONO_FONT),
}
}
}

View file

@ -79,7 +79,9 @@ fn styled_span(span: &TextSpan, text_color: Color, code_color: Color) -> Span<'s
styled = styled.font_slant(FontSlant::Italic);
}
if span.code {
styled.font_family("monospace").color(code_color)
styled
.font_family(crate::theme::MONO_FONT)
.color(code_color)
} else {
styled.color(text_color)
}

View file

@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet};
use freya::prelude::*;
use oneclient_content::packages::ProviderId;
use oneclient_content::packages::types::SearchSort;
use oneclient_db::models::ClusterId;
#[derive(Clone)]
@ -82,6 +83,7 @@ pub struct BrowserUiState {
pub query: String,
pub provider: ProviderId,
pub categories: Vec<String>,
pub sort: SearchSort,
pub page: usize,
}
@ -91,6 +93,7 @@ impl Default for BrowserUiState {
query: String::new(),
provider: ProviderId::Modrinth,
categories: Vec::new(),
sort: SearchSort::Relevance,
page: 0,
}
}

View file

@ -17,8 +17,8 @@ use crate::ui::border_all_color;
type InstalledMap = HashMap<(ProviderId, String), Installed>;
/// Diameter of the round install button
const INSTALL_BUTTON: f32 = 30.;
/// Height of the install control, matched by the installed pill that replaces it
const INSTALL_BUTTON_H: f32 = 28.;
fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option<InstallSource> {
installed
@ -111,8 +111,8 @@ impl Component for PackageCard {
.vertical()
.width(Size::flex(1.0))
.height(Size::px(CARD_H))
.corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG)
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(
1.,
if focused {
@ -129,37 +129,28 @@ impl Component for PackageCard {
.on_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.child(
rect()
.margin(Gaps::new_all(1.))
.corner_radius(CornerRadius::new(10., 10., 0., 0.))
.width(Size::fill())
.height(Size::px(BANNER_H))
.overflow(Overflow::Clip)
.child(PackageBanner::new(icon_url, BANNER_H))
// Top-left of the banner the first thing worth knowing about a result and the opposite corner is the install button's
.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()
}))
.maybe_child(self.installed.is_none().then(|| {
rect()
.position(Position::new_absolute().top(8.).right(8.))
.layer(Layer::Relative(7))
.child(InstallButton::new(
&self.item,
self.cluster_id,
&self.package_type,
false,
))
.into_element()
})),
// No installed badge, the control at the foot of the card already states it
.child(PackageBanner::new(icon_url.clone(), BANNER_H).backdrop_only()),
)
.child(
rect()
.position(
Position::new_absolute()
.top(BANNER_H - CARD_ICON + CARD_ICON_OVERHANG)
.left(14.),
)
.layer(Layer::Relative(7))
.child(Thumbnail::new(icon_url, CARD_ICON).radius(6.)),
)
.child(
rect()
.vertical()
.width(Size::fill())
.height(Size::flex(1.0))
.padding(Gaps::new_all(12.))
.padding(Gaps::new(CARD_ICON_OVERHANG + 6., 14., 12., 14.))
.main_align(Alignment::SpaceBetween)
.child(
rect()
@ -169,35 +160,45 @@ impl Component for PackageCard {
.child(
label()
.text(self.item.name.clone())
.font_size(16.)
.font_weight(FontWeight::MEDIUM)
.font_size(14.)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(CARD_NAME),
.color(colors::fg_primary()),
)
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(4.)
.spacing(5.)
.child(
label()
.text(format!("by {}", self.item.author))
.text(format!(
"by {} · {} downloads",
self.item.author,
abbreviate_number(self.item.downloads)
))
.font_size(10.)
.max_lines(1)
.color(colors::fg_secondary()),
.color(colors::fg_primary().with_a(140)),
)
.child(Icon::new(self.item.provider).size(12.)),
.child(Icon::new(self.item.provider).size(11.)),
)
.child(
label()
.text(self.item.summary.clone())
.font_size(11.)
.line_height(1.45)
.max_lines(2)
.width(Size::fill())
.color(colors::fg_secondary()),
.color(colors::fg_primary().with_a(184)),
),
)
.child(downloads_row(self.item.downloads)),
.child(InstallButton::new(
&self.item,
self.cluster_id,
&self.package_type,
self.installed,
)),
)
}
}
@ -243,8 +244,8 @@ impl Component for ListRow {
.cross_align(Alignment::Center)
.spacing(12.)
.padding(Gaps::new_all(16.))
.corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG)
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(
1.,
if focused {
@ -260,7 +261,7 @@ impl Component for ListRow {
.a11y_role(AccessibilityRole::Button)
.cursor(CursorIcon::Pointer)
.on_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.))
.child(Thumbnail::new(item.icon_url.clone(), 48.).radius(6.))
.child(
rect()
.vertical()
@ -275,24 +276,18 @@ impl Component for ListRow {
label()
.text(item.name.clone())
.font_size(15.)
.font_weight(FontWeight::MEDIUM)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(CARD_NAME),
.color(colors::fg_primary()),
)
.child(
label()
.text(format!("by {}", item.author))
.font_size(10.)
.max_lines(1)
.color(colors::fg_secondary()),
.color(colors::fg_primary().with_a(140)),
)
.child(Icon::new(item.provider).size(12.))
// Beside the attribution rather than far right where it drifted from the package and crowded the install button
.maybe_child(
self.installed.map(|installed| {
installed_badge(installed, 10.).into_element()
}),
),
.child(Icon::new(item.provider).size(12.)),
)
.child(
label()
@ -300,7 +295,7 @@ impl Component for ListRow {
.font_size(11.)
.max_lines(2)
.width(Size::fill())
.color(colors::fg_secondary()),
.color(colors::fg_primary().with_a(184)),
),
)
.child(downloads_row(item.downloads))
@ -308,7 +303,7 @@ impl Component for ListRow {
&self.item,
self.cluster_id,
&self.package_type,
self.installed.is_some(),
self.installed,
))
}
}
@ -319,12 +314,17 @@ struct InstallButton {
project_id: String,
cluster_id: i64,
content_type: ContentType,
/// Cluster already has this one callers that hide the button entirely pass `false`
installed: bool,
/// Cluster already has this one, so the control becomes a static pill
installed: Option<InstallSource>,
}
impl InstallButton {
fn new(item: &ProjectSummary, cluster_id: i64, package_type: &str, installed: bool) -> Self {
fn new(
item: &ProjectSummary,
cluster_id: i64,
package_type: &str,
installed: Option<InstallSource>,
) -> Self {
Self {
provider: item.provider,
project_id: item.id.clone(),
@ -354,10 +354,8 @@ impl Component for InstallButton {
_ => (None, None),
};
let installed = self.installed;
let versions = version_list(&use_package_versions_when(
!installed,
self.installed.is_none(),
provider,
project_id.clone(),
game_version,
@ -369,24 +367,39 @@ impl Component for InstallButton {
// Nothing to start twice while an install is running or before versions arrive
let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id);
if let Some(installed) = self.installed {
let color = installed.color();
return rect()
.horizontal()
.cross_align(Alignment::Center)
.height(Size::px(INSTALL_BUTTON_H))
.spacing(6.)
.padding(Gaps::new_symmetric(0., 10.))
.corner_radius(CornerRadius::new_all(6.))
.background(color.with_a(36))
.border(border_all_color(1., color.with_a(115)))
.child(Icon::new(IconType::CheckCircle).size(12.).color(color))
.child(
label()
.text(installed.label())
.font_size(11.)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(color),
)
.into_element();
}
rect()
// The card behind is one big press target stop here so the package page doesn't open on top of the install
.on_press(|e: Event<PressEventData>| e.stop_propagation())
.child(
Button::new()
.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 installed {
"Already installed"
} else if installing {
"Installing"
} else {
"Install"
})
.enabled(!installed && latest.is_some() && !installing)
.small()
.height(Size::px(INSTALL_BUTTON_H))
.padding(Gaps::new_symmetric(0., 11.))
.enabled(latest.is_some() && !installing)
.on_press(move |_| {
if let Some(version_id) = latest.clone() {
dispatch.install_package(
@ -401,12 +414,21 @@ impl Component for InstallButton {
Icon::new(if installing {
IconType::Loading02
} else {
IconType::Download01
IconType::Plus
})
.size(15.)
.size(12.)
.color(colors::fg_primary()),
)
.child(
label()
.text(if installing { "Installing" } else { "Install" })
.font_size(11.)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(colors::fg_primary()),
),
)
.into_element()
}
}
@ -418,13 +440,13 @@ fn downloads_row(downloads: u64) -> impl IntoElement {
.child(
Icon::new(IconType::Download01)
.size(12.)
.color(colors::fg_secondary()),
.color(colors::fg_primary().with_a(140)),
)
.child(
label()
.text(abbreviate_number(downloads))
.font_size(11.)
.color(colors::fg_secondary()),
.color(colors::fg_primary().with_a(140)),
)
}

View file

@ -29,10 +29,7 @@ use crate::routes::Route;
use crate::theme::colors;
use crate::ui::grid_columns_for_width;
use super::{
InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_badge_overlay,
installed_map,
};
use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_map};
use crate::utils::{abbreviate_number, sort_clusters_for_home};
mod cards;
@ -42,22 +39,30 @@ use cards::{empty_state, grid_row, list_row};
use sidebar::CategorySidebar;
use skeletons::{SkeletonListRow, skeleton_grid_row};
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const CARD_NAME: Color = Color::from_rgb(213, 219, 255);
const SCROLLBAR_GUTTER: f32 = 18.;
const CATEGORY_SIDEBAR_W: f32 = 190.;
const CATEGORY_SIDEBAR_W: f32 = 196.;
const PROVIDER_LABELS_W: f32 = 900.;
const WIDE_SEARCH_W: f32 = 780.;
const SEARCH_W: f32 = 260.;
const SEARCH_COMPACT_W: f32 = 170.;
const CARD_H: f32 = 240.;
const BANNER_H: f32 = 100.;
const MAX_CARD_W: f32 = 400.;
const CARD_H: f32 = 226.;
const BANNER_H: f32 = 74.;
const CARD_ICON: f32 = 46.;
/// How far the package icon drops past the banner into the card body
const CARD_ICON_OVERHANG: f32 = 14.;
const MAX_CARD_W: f32 = 300.;
const LIST_ROW_H: f32 = 78.;
const GRID_SPACING: f32 = 12.;
const GRID_SPACING: f32 = 16.;
const LIST_SPACING: f32 = 8.;
const SEARCH_DEBOUNCE_MS: u64 = 250;
const SORTS: [(SearchSort, &str); 4] = [
(SearchSort::Relevance, "Relevance"),
(SearchSort::Downloads, "Downloads"),
(SearchSort::Newest, "Newest"),
(SearchSort::Updated, "Updated"),
];
const BROWSE_TYPES: [(&str, &str); 3] = [
("mod", "Mods"),
("texture", "Textures"),
@ -129,6 +134,7 @@ impl Component for BrowserBody {
let view_mode = use_view_state(&format!("browser.{package_type}")).layout;
let compatible_only = use_browser_compat();
let selected_categories = use_state(|| saved.categories.clone());
let sort = use_state(|| saved.sort);
let page = use_state(|| saved.page);
{
@ -139,6 +145,7 @@ impl Component for BrowserBody {
query: query.read().clone(),
provider: *provider.read(),
categories: selected_categories.read().clone(),
sort: *sort.read(),
page: *page.read(),
};
store.write().insert(key.clone(), snapshot);
@ -170,8 +177,9 @@ impl Component for BrowserBody {
let mut page_state = page;
// Normalised like the search key so a query differing only by a space does not reset the page
let sort_by = *sort.read();
let signature = format!(
"{provider_id:?}|{}|{compat}|{}",
"{provider_id:?}|{}|{compat}|{}|{sort_by:?}",
normalize_query(&debounced_query.read()).to_lowercase(),
cats.join(",")
);
@ -190,7 +198,7 @@ impl Component for BrowserBody {
game_versions,
loaders,
cats.clone(),
SearchSort::Relevance,
sort_by,
*page.read(),
);
let categories_query = use_package_categories(provider_id, content_type);
@ -281,24 +289,16 @@ impl Component for BrowserBody {
.height(Size::fill())
.overflow(Overflow::Clip)
.padding(Gaps::new(0., 40., 40., 40.))
.spacing(16.)
.child(page_title(
.spacing(18.)
.child(header(
&package_type,
cluster.as_ref().map(|c| c.name.clone()),
self.pick_cluster.then(|| ClusterPicker {
cluster_id,
package_type: package_type.clone(),
}),
))
.child(controls(
TypePicker {
cluster_id,
package_type: package_type.clone(),
pick_cluster: self.pick_cluster,
},
provider,
query,
view_mode,
controls_width,
))
.child(
@ -307,12 +307,24 @@ impl Component for BrowserBody {
.width(Size::fill())
.height(Size::flex(1.0))
.spacing(24.)
.maybe(!all_categories.is_empty(), |el| {
el.child(CategorySidebar {
categories: all_categories,
selected: selected_categories,
})
})
.child(
rect()
.vertical()
.width(Size::px(CATEGORY_SIDEBAR_W))
.height(Size::fill())
.spacing(14.)
.child(TypePicker {
cluster_id,
package_type: package_type.clone(),
pick_cluster: self.pick_cluster,
})
.maybe(!all_categories.is_empty(), |el| {
el.child(CategorySidebar {
categories: all_categories,
selected: selected_categories,
})
}),
)
.child(
rect()
.vertical()
@ -320,6 +332,14 @@ impl Component for BrowserBody {
.width(Size::flex(1.0))
.height(Size::fill())
.spacing(12.)
.child(results_toolbar(
total,
current_page,
pages,
pending,
sort,
view_mode,
))
.child(
rect()
.width(Size::fill())
@ -347,32 +367,175 @@ impl Component for BrowserBody {
}
}
fn page_title(
fn header(
package_type: &str,
cluster_name: Option<String>,
picker: Option<ClusterPicker>,
provider: State<ProviderId>,
query: State<String>,
mut header_width: State<f32>,
) -> impl IntoElement {
let measured = *header_width.read();
let show_provider_labels = measured == 0. || measured >= PROVIDER_LABELS_W;
let search_width = if measured == 0. || measured >= WIDE_SEARCH_W {
SEARCH_W
} else {
SEARCH_COMPACT_W
};
rect()
.vertical()
.spacing(2.)
.horizontal()
.width(Size::fill())
.cross_align(Alignment::End)
.spacing(12.)
.content(Content::Flex)
.on_sized(move |event: Event<SizedEventData>| {
let w = event.data().area.width();
if (w - *header_width.peek()).abs() > 0.5 {
*header_width.write() = w;
}
})
.child(
rect()
.vertical()
.spacing(10.)
.child(
label()
.text(format!("Browse {}", type_title(package_type)))
.font_size(32.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
)
.maybe_child(match picker {
Some(picker) => Some(picker.into_element()),
// Without the picker the cluster is fixed so the same pill is read-only
None => cluster_name.map(|name| target_pill(&name).into_element()),
}),
)
.child(rect().width(Size::flex(1.0)))
.child(
SegmentedControl::new(provider)
.no_tint()
.segments(ProviderId::REMOTE_PROVIDERS.iter().map(move |provider| {
let segment = Segment::new(*provider).icon(IconType::from(*provider));
if show_provider_labels {
segment.label(provider.to_string())
} else {
segment
}
}))
.into_element(),
)
.child(
TextInput::new(query)
.width(Size::px(search_width))
.placeholder("Search for content")
.leading(
Icon::new(IconType::SearchMd)
.size(14.)
.color(colors::fg_secondary())
.into_element(),
),
)
}
/// Reads like the cluster dropdown next to it so a fixed target doesn't look like a missing control
fn target_pill(name: &str) -> impl IntoElement {
rect()
.horizontal()
.cross_align(Alignment::Center)
.height(Size::px(30.))
.spacing(6.)
.padding(Gaps::new_symmetric(0., 10.))
.corner_radius(CornerRadius::new_all(6.))
.background(colors::ghost_overlay())
.child(
label()
.text(format!("Browse {}", type_title(package_type)))
.font_size(36.)
.font_weight(FontWeight::BOLD)
.text("Installing to")
.font_size(12.)
.color(colors::fg_secondary()),
)
.child(
label()
.text(name.to_string())
.font_size(12.)
.max_lines(1)
.color(colors::fg_primary()),
)
.maybe_child(match picker {
Some(picker) => Some(picker.into_element()),
// Without the picker the cluster is fixed so it's just a subtitle
None => cluster_name.map(|name| {
label()
.text(name)
.font_size(14.)
.color(colors::fg_secondary())
.into_element()
}),
})
}
fn results_toolbar(
total: usize,
current_page: usize,
pages: Option<usize>,
pending: bool,
sort: State<SearchSort>,
view_mode: State<ViewLayout>,
) -> impl IntoElement {
// Blank rather than a stale count while a page is in flight
let summary = match (pending, pages) {
(false, Some(pages)) => format!(
"{} results · page {} of {}",
abbreviate_number(total as u64),
current_page + 1,
pages
),
_ => String::new(),
};
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.content(Content::Flex)
.child(
label()
.text(summary)
.font_size(12.)
.color(colors::fg_primary().with_a(140)),
)
.child(rect().width(Size::flex(1.0)))
.child(SortPicker { sort })
.child(
SegmentedControl::new(view_mode)
.equal_width(30.)
.icon_size(15.)
.segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid))
.segment(Segment::new(ViewLayout::List).icon(IconType::LayoutTop)),
)
}
#[derive(PartialEq)]
struct SortPicker {
sort: State<SearchSort>,
}
impl Component for SortPicker {
fn render(&self) -> impl IntoElement {
let mut sort = self.sort;
let current = *sort.read();
let labels: Vec<String> = SORTS.iter().map(|(_, l)| (*l).to_string()).collect();
let selected = SORTS
.iter()
.find(|(s, _)| *s == current)
.map_or("Relevance", |(_, l)| *l);
Dropdown::new(selected, labels)
.width(Size::px(132.))
.height(Size::px(30.))
.leading(
Icon::new(IconType::Sliders04)
.size(13.)
.color(colors::fg_secondary()),
)
.on_select(move |idx: usize| {
if let Some((picked, _)) = SORTS.get(idx) {
sort.set(*picked);
}
})
}
}
/// Falls back to the bare version while the manifest hasn't arrived or doesn't cover it
@ -473,65 +636,3 @@ impl Component for ClusterPicker {
)
}
}
fn controls(
type_picker: TypePicker,
provider: State<ProviderId>,
query: State<String>,
view_mode: State<ViewLayout>,
mut controls_width: State<f32>,
) -> impl IntoElement {
let measured = *controls_width.read();
let show_provider_labels = measured == 0. || measured >= PROVIDER_LABELS_W;
let search_width = if measured == 0. || measured >= WIDE_SEARCH_W {
SEARCH_W
} else {
SEARCH_COMPACT_W
};
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(12.)
.content(Content::Flex)
.on_sized(move |event: Event<SizedEventData>| {
let w = event.data().area.width();
if (w - *controls_width.peek()).abs() > 0.5 {
*controls_width.write() = w;
}
})
.child(type_picker)
.child(rect().width(Size::flex(1.0)))
.child(
SegmentedControl::new(view_mode)
.equal_width(30.)
.icon_size(16.)
.segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid))
.segment(Segment::new(ViewLayout::List).icon(IconType::LayoutTop)),
)
.child(
SegmentedControl::new(provider)
.no_tint()
.segments(ProviderId::REMOTE_PROVIDERS.iter().map(move |provider| {
let segment = Segment::new(*provider).icon(IconType::from(*provider));
if show_provider_labels {
segment.label(provider.to_string())
} else {
segment
}
}))
.into_element(),
)
.child(
TextInput::new(query)
.width(Size::px(search_width))
.placeholder("Search for content")
.leading(
Icon::new(IconType::SearchMd)
.size(14.)
.color(colors::fg_secondary())
.into_element(),
),
)
}

View file

@ -2,6 +2,7 @@ use super::*;
use crate::components::ScrollArea;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::capitalize;
#[derive(PartialEq)]
@ -13,7 +14,7 @@ pub(super) struct CategorySidebar {
impl Component for CategorySidebar {
fn render(&self) -> impl IntoElement {
let selected = self.selected;
let list = rect().vertical().width(Size::fill()).spacing(2.).children(
let list = rect().vertical().width(Size::fill()).spacing(1.).children(
self.categories.clone().into_iter().map(move |cat| {
let is_selected = selected.read().contains(&cat);
let mut selected = selected;
@ -41,14 +42,15 @@ impl Component for CategorySidebar {
rect()
.vertical()
.width(Size::px(CATEGORY_SIDEBAR_W))
.height(Size::fill())
.width(Size::fill())
.height(Size::flex(1.0))
.spacing(8.)
.child(
label()
.text("CATEGORIES")
.font_size(11.)
.font_weight(FontWeight::LIGHT)
.font_weight(FontWeight::MEDIUM)
.letter_spacing(1.4)
.color(colors::fg_secondary()),
)
.child(
@ -92,8 +94,19 @@ impl Component for CategoryRow {
};
rect()
.horizontal()
.width(Size::fill())
.padding(Gaps::new_symmetric(3., 0.))
.height(Size::px(29.))
.cross_align(Alignment::Center)
.padding(Gaps::new_symmetric(0., 10.))
.corner_radius(CornerRadius::new_all(8.))
.maybe(selected, |el| {
el.background(colors::brand().with_a(40))
.border(border_all_color(1., colors::brand().with_a(115)))
})
.maybe(!selected && hovered, |el| {
el.background(colors::ghost_overlay())
})
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
@ -104,10 +117,10 @@ impl Component for CategoryRow {
.child(
label()
.text(capitalize(&self.name))
.font_size(13.)
.font_size(12.)
.max_lines(1)
.font_weight(if selected {
FontWeight::SEMI_BOLD
FontWeight::MEDIUM
} else {
FontWeight::NORMAL
})

View file

@ -36,25 +36,35 @@ impl Component for SkeletonCard {
.vertical()
.width(Size::flex(1.0))
.height(Size::px(CARD_H))
.corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG)
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.overflow(Overflow::Clip)
.opacity(pulse)
.child(skeleton_block(Size::fill(), BANNER_H))
.child(
rect()
.position(
Position::new_absolute()
.top(BANNER_H - CARD_ICON + CARD_ICON_OVERHANG)
.left(14.),
)
.layer(Layer::Relative(7))
.child(skeleton_block(Size::px(CARD_ICON), CARD_ICON)),
)
.child(
rect()
.vertical()
.width(Size::fill())
.height(Size::flex(1.0))
.padding(Gaps::new_all(12.))
.spacing(8.)
.child(skeleton_block(Size::percent(70.), 16.))
.child(skeleton_block(Size::percent(40.), 10.))
.child(skeleton_block(Size::fill(), 10.))
.padding(Gaps::new(CARD_ICON_OVERHANG + 6., 14., 12., 14.))
.spacing(6.)
.child(skeleton_block(Size::percent(60.), 14.))
.child(skeleton_block(Size::percent(85.), 10.))
.child(skeleton_block(Size::fill(), 10.))
.child(skeleton_block(Size::percent(70.), 10.))
.child(rect().width(Size::fill()).height(Size::flex(1.0)))
.child(skeleton_block(Size::percent(30.), 10.)),
.child(skeleton_block(Size::px(76.), 28.)),
)
}
}
@ -72,8 +82,8 @@ impl Component for SkeletonListRow {
.cross_align(Alignment::Center)
.spacing(12.)
.padding(Gaps::new_all(10.))
.corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG)
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.opacity(pulse)
.child(skeleton_block(Size::px(48.), 48.))

View file

@ -150,16 +150,6 @@ pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl
badge(installed, font_size, installed.color().with_a(38), None)
}
/// Brings its own backdrop and outline so it stays legible over card artwork
pub(crate) fn installed_badge_overlay(installed: InstallSource) -> impl IntoElement {
badge(
installed,
10.,
BANNER_BG.with_a(225),
Some(installed.color().with_a(110)),
)
}
/// Which of several installed versions the game actually loads
pub(crate) fn activity_badge(active: bool) -> impl IntoElement {
let (text, color) = if active {
@ -279,6 +269,7 @@ fn thumbnail_placeholder(size: f32, radius: f32, icon_ratio: f32) -> Element {
pub(crate) struct PackageBanner {
icon_url: Option<String>,
height: f32,
backdrop_only: bool,
key: DiffKey,
}
@ -287,9 +278,16 @@ impl PackageBanner {
Self {
icon_url,
height,
backdrop_only: false,
key: DiffKey::None,
}
}
/// Drops the centred icon so a caller can place its own artwork over the blur
pub fn backdrop_only(mut self) -> Self {
self.backdrop_only = true;
self
}
}
impl KeyExt for PackageBanner {
@ -313,6 +311,7 @@ impl Component for PackageBanner {
.background(BANNER_BG);
let icon_placeholder = thumbnail_placeholder(icon, 10., 0.45);
let backdrop_only = self.backdrop_only;
match loaded {
Some((url, bytes)) => banner
@ -342,7 +341,7 @@ impl Component for PackageBanner {
.overflow(Overflow::Clip)
.layer(Layer::Relative(3)),
)
.child(
.maybe_child((!backdrop_only).then(|| {
rect()
.width(Size::px(icon))
.height(Size::px(icon))
@ -352,10 +351,12 @@ impl Component for PackageBanner {
.height(Size::px(icon))
.aspect_ratio(AspectRatio::Min)
.corner_radius(CornerRadius::new_all(10.))
.fallback(icon_placeholder),
.fallback(icon_placeholder.clone()),
)
.layer(Layer::Relative(5)),
),
.layer(Layer::Relative(5))
.into_element()
})),
None if backdrop_only => banner,
None => banner.child(icon_placeholder),
}
}

View file

@ -14,7 +14,7 @@ use crate::ui::border_all_color;
use super::{
Installed, InstalledVersion, PackageBanner, Thumbnail, activity_badge, installed_badge,
installed_badge_overlay, installed_map,
installed_map,
};
use crate::utils::abbreviate_number;
@ -60,7 +60,7 @@ fn pill(text: String) -> impl IntoElement {
.into_element()
}
fn pill_flow(items: &[String], per_row: usize, max: usize) -> impl IntoElement {
fn pill_flow(items: &[String], max: usize) -> impl IntoElement {
let shown: Vec<String> = items.iter().take(max).cloned().collect();
let overflow = items.len().saturating_sub(shown.len());
let mut all = shown;
@ -69,15 +69,11 @@ fn pill_flow(items: &[String], per_row: usize, max: usize) -> impl IntoElement {
}
rect()
.vertical()
.horizontal()
.width(Size::fill())
.content(Content::wrap_spacing(4.))
.spacing(4.)
.children(all.chunks(per_row.max(1)).map(|chunk| {
rect()
.horizontal()
.spacing(4.)
.children(chunk.iter().map(|t| pill(t.clone()).into_element()))
.into_element()
}))
.children(all.into_iter().map(|t| pill(t).into_element()))
.into_element()
}
@ -92,7 +88,7 @@ fn pill_detail(key: &str, items: &[String]) -> Element {
.font_size(12.)
.color(colors::fg_secondary()),
)
.child(pill_flow(items, 4, 18))
.child(pill_flow(items, 18))
.into_element()
}

View file

@ -264,7 +264,7 @@ fn version_row(
.max_lines(1)
.color(colors::fg_primary()),
)
.maybe(has_chips, |el| el.child(pill_flow(&chips, 8, 10)))
.maybe(has_chips, |el| el.child(pill_flow(&chips, 10)))
.child(
label()
.text(stats)

View file

@ -51,14 +51,7 @@ pub(super) fn sidebar(
rect()
.width(Size::fill())
.overflow(Overflow::Clip)
.child(PackageBanner::new(project.icon_url.clone(), 110.))
.maybe_child(installed.as_ref().map(|installed| {
rect()
.position(Position::new_absolute().top(8.).left(8.))
.layer(Layer::Relative(7))
.child(installed_badge_overlay(installed.source))
.into_element()
})),
.child(PackageBanner::new(project.icon_url.clone(), 110.)),
)
.child(
rect()