From 520e6c1ef11ad85a255d70609a8514371ab4dd8a Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:49:57 +0200 Subject: [PATCH] chore: redesigned browser grid (#869) --- .../oneclient_app/src/components/dropdown.rs | 9 + .../src/components/markdown/mod.rs | 2 +- .../src/components/markdown/render.rs | 4 +- .../oneclient_app/src/hooks/active_cluster.rs | 3 + .../src/view/app/browser/index/cards.rs | 170 ++++++---- .../src/view/app/browser/index/mod.rs | 317 ++++++++++++------ .../src/view/app/browser/index/sidebar.rs | 27 +- .../src/view/app/browser/index/skeletons.rs | 30 +- .../oneclient_app/src/view/app/browser/mod.rs | 29 +- .../src/view/app/browser/package/mod.rs | 18 +- .../src/view/app/browser/package/panels.rs | 2 +- .../src/view/app/browser/package/sidebar.rs | 9 +- 12 files changed, 385 insertions(+), 235 deletions(-) diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs index 4099d385..a8541e49 100644 --- a/packages/oneclient_app/src/components/dropdown.rs +++ b/packages/oneclient_app/src/components/dropdown.rs @@ -14,6 +14,7 @@ pub struct Dropdown { selected: String, options: Vec, on_select: Option>, + leading: Option, 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>) -> Self { self.on_select = Some(handler.into()); self @@ -67,6 +74,7 @@ impl Component for Dropdown { let mut list_size = use_state(|| None::); 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) diff --git a/packages/oneclient_app/src/components/markdown/mod.rs b/packages/oneclient_app/src/components/markdown/mod.rs index 482003ec..e927ce6e 100644 --- a/packages/oneclient_app/src/components/markdown/mod.rs +++ b/packages/oneclient_app/src/components/markdown/mod.rs @@ -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), } } } diff --git a/packages/oneclient_app/src/components/markdown/render.rs b/packages/oneclient_app/src/components/markdown/render.rs index 55928863..0461f7d1 100644 --- a/packages/oneclient_app/src/components/markdown/render.rs +++ b/packages/oneclient_app/src/components/markdown/render.rs @@ -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) } diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index 6608f6ff..b9df83f1 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -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, + 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, } } 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 47641eb0..9d8d4c6b 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,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 { 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, } 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, + ) -> 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| 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)), ) } 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 07ecb13f..d5996990 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -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, picker: Option, + provider: State, + query: State, + mut header_width: State, ) -> 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| { + 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, + pending: bool, + sort: State, + view_mode: State, +) -> 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, +} + +impl Component for SortPicker { + fn render(&self) -> impl IntoElement { + let mut sort = self.sort; + let current = *sort.read(); + + let labels: Vec = 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, - query: State, - view_mode: State, - mut controls_width: State, -) -> 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| { - 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(), - ), - ) -} diff --git a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs index f7e25dbb..3d6ac841 100644 --- a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs @@ -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 }) diff --git a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs index 77f8ae4f..01caa442 100644 --- a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs +++ b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs @@ -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.)) diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 7188dd2a..aedc8432 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -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, 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), } } diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index 31770866..dec9b70b 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -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 = 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() } diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index 2aa9b5aa..d8cacdd2 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -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) diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 628e8659..bf3c4f83 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -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()