chore: redesigned browser grid (#869)
This commit is contained in:
parent
3a5f06e8ac
commit
520e6c1ef1
12 changed files with 385 additions and 235 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue