fix: fixed most of the sizing issues in the launcher (#822)

This commit is contained in:
Maciek 2026-09-16 18:58:10 +02:00 • committed by GitHub
parent d32d3e1782
commit b4f067483a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 374 additions and 285 deletions

View file

@ -0,0 +1,4 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.4179 7.49976H46.4793C37.369 7.49976 28.9508 12.36 24.3956 20.2498L6.61807 51.0414C6.00926 52.0959 5.48182 53.1831 5.03576 54.2942L30.2924 49.6414C40.4908 47.7626 48.4706 39.7829 50.3494 29.5844L54.4179 7.49976ZM5 73.199C5.45356 74.3413 5.99291 75.4586 6.61807 76.5414L24.3956 107.333C28.9508 115.223 37.369 120.083 46.4793 120.083H54.4331L50.3494 97.9154C48.4706 87.717 40.4908 79.7372 30.2924 77.8585L5 73.199ZM74.4827 120.083H82.0344C91.1447 120.083 99.5629 115.223 104.118 107.333L121.896 76.5414C122.506 75.4839 123.035 74.3935 123.482 73.279L98.6234 77.8585C88.425 79.7372 80.4452 87.717 78.5664 97.9154L74.4827 120.083ZM123.446 54.2142C123.006 53.1309 122.49 52.0706 121.896 51.0414L104.118 20.2498C99.5629 12.36 91.1447 7.49976 82.0344 7.49976H74.498L78.5664 29.5844C80.4452 39.7828 88.425 47.7626 98.6234 49.6414L123.446 54.2142Z" fill="currentColor"/>
<path d="M64.458 24.5L67.962 45.0947C69.2479 52.6528 75.0625 58.6345 82.5811 60.1343L100.708 63.75L82.5811 67.3657C75.0625 68.8655 69.2479 74.8472 67.962 82.4053L64.458 103L60.954 82.4053C59.6681 74.8472 53.8535 68.8655 46.335 67.3657L28.208 63.75L46.335 60.1343C53.8536 58.6345 59.6681 52.6528 60.954 45.0947L64.458 24.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,3 @@
<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 21H4.6c-.56 0-.84 0-1.054-.109a1 1 0 0 1-.437-.437C3 20.2401 3 19.9601 3 19.4V3m17 5-3.9189 4.1827c-.1485.1585-.2227.2377-.3123.2787a.5.5 0 0 1-.2528.0433c-.0981-.0089-.1945-.0589-.3873-.159l-3.2574-1.6914c-.1928-.1001-.2892-.1501-.3873-.159a.5.5 0 0 0-.2528.0433c-.0896.041-.1638.1202-.3123.2787L7 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View file

@ -1,27 +0,0 @@
use freya::prelude::*;
use super::Dropdown;
pub const GRID_COLUMNS_MIN: u8 = 1;
pub const GRID_COLUMNS_MAX: u8 = 5;
pub fn resolved_columns(columns: u8) -> usize {
columns.clamp(GRID_COLUMNS_MIN, GRID_COLUMNS_MAX) as usize
}
pub fn grid_columns_picker(mut columns: State<u8>, height: f32) -> impl IntoElement {
let options: Vec<String> = (GRID_COLUMNS_MIN..=GRID_COLUMNS_MAX)
.map(|n| n.to_string())
.collect();
let selected = resolved_columns(*columns.read()).to_string();
Dropdown::new(selected, options)
.width(Size::px(60.))
.height(Size::px(height))
.on_select(move |idx: usize| columns.set(GRID_COLUMNS_MIN + idx as u8))
}
pub fn cell_width(total: f32, cols: usize, gap: f32) -> f32 {
let cols = cols.max(1);
((total - (cols - 1) as f32 * gap) / cols as f32).max(0.)
}

View file

@ -115,9 +115,11 @@ pub enum IconType {
FolderDownload, FolderDownload,
Globe01, Globe01,
HelpCircle, HelpCircle,
IconLogo,
InfoCircle, InfoCircle,
Key01, Key01,
LayoutTop, LayoutTop,
LineChartUp01,
Link03, Link03,
LinkExternal01, LinkExternal01,
Loading02, Loading02,

View file

@ -12,7 +12,6 @@ mod optional_mods_popup;
mod dropdown; mod dropdown;
mod dynamic_art; mod dynamic_art;
mod file_drop; mod file_drop;
mod grid_columns;
mod icons; mod icons;
mod java_install_manager; mod java_install_manager;
mod generic_prompt; mod generic_prompt;
@ -61,7 +60,6 @@ pub use optional_mods_popup::OptionalModsPopup;
pub use dropdown::Dropdown; pub use dropdown::Dropdown;
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
pub use file_drop::FileDropOverlay; pub use file_drop::FileDropOverlay;
pub use grid_columns::{cell_width, grid_columns_picker, resolved_columns};
pub use icons::{Icon, IconTint, IconType}; pub use icons::{Icon, IconTint, IconType};
pub use java_install_manager::JavaInstallManager; pub use java_install_manager::JavaInstallManager;
pub use generic_prompt::GenericPromptOverlay; pub use generic_prompt::GenericPromptOverlay;

View file

@ -16,12 +16,17 @@ use crate::{
}; };
const NAVBAR_INTRO_MS: u64 = 460; const NAVBAR_INTRO_MS: u64 = 460;
const LOGO_HIDE_NAVBAR_W: f32 = 1100.;
const NAVBAR_SIDE_PADDING_PX: f32 = 40.;
const NAV_LINK_SPACING_PX: f32 = 36.;
const COMPACT_LOGO_PX: f32 = 28.;
#[derive(PartialEq)] #[derive(PartialEq)]
pub struct Navbar; pub struct Navbar;
impl Component for Navbar { impl Component for Navbar {
fn render(&self) -> impl IntoElement { fn render(&self) -> impl IntoElement {
let mut navbar_width = use_state(|| 0f32);
let intro = use_animation(|conf| { let intro = use_animation(|conf| {
conf.on_creation(OnCreation::Run); conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.) AnimNum::new(0., 1.)
@ -32,6 +37,9 @@ impl Component for Navbar {
let eased = intro.get().value(); let eased = intro.get().value();
let slide = (1.0 - eased) * -theme::NAVBAR_HEIGHT_PX; let slide = (1.0 - eased) * -theme::NAVBAR_HEIGHT_PX;
let measured = *navbar_width.read();
let show_logo = measured <= 0. || measured > LOGO_HIDE_NAVBAR_W;
rect() rect()
.width(Size::fill()) .width(Size::fill())
.height(Size::px(theme::NAVBAR_HEIGHT_PX)) .height(Size::px(theme::NAVBAR_HEIGHT_PX))
@ -44,11 +52,17 @@ impl Component for Navbar {
.horizontal() .horizontal()
.content(Content::Flex) .content(Content::Flex)
.cross_align(Alignment::Center) .cross_align(Alignment::Center)
.padding(Gaps::new_symmetric(0.0, 40.0)) .padding(Gaps::new_symmetric(0.0, NAVBAR_SIDE_PADDING_PX))
.offset_y(slide) .offset_y(slide)
.opacity(eased) .opacity(eased)
.child(navbar_left()) .on_sized(move |event: Event<SizedEventData>| {
.child(navbar_center()) let next = event.data().area.width();
if (*navbar_width.peek() - next).abs() > 0.5 {
navbar_width.set(next);
}
})
.child(navbar_left(show_logo))
.child(navbar_center(!show_logo))
.child(NavbarRight), .child(NavbarRight),
) )
.child( .child(
@ -61,34 +75,60 @@ impl Component for Navbar {
} }
} }
fn navbar_left() -> impl IntoElement { fn navbar_left(show_logo: bool) -> impl IntoElement {
rect() rect()
.horizontal() .horizontal()
.width(Size::flex(1.0)) .width(if show_logo {
Size::flex(1.0)
} else {
Size::auto()
})
.cross_align(Alignment::Center) .cross_align(Alignment::Center)
.child(navbar_logo()) .maybe(!show_logo, |el| {
el.padding(Gaps::new(0., NAV_LINK_SPACING_PX, 0., 0.))
})
.child(if show_logo {
NavbarLogo.into_element()
} else {
Icon::new(IconType::IconLogo)
.size(COMPACT_LOGO_PX)
.into_element()
})
} }
fn navbar_logo() -> impl IntoElement { #[derive(PartialEq)]
let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default()); struct NavbarLogo;
SvgViewer::new(("logo.svg", bytes.read().cloned())) impl Component for NavbarLogo {
.show_loader(false) fn render(&self) -> impl IntoElement {
.height(Size::px(44.)) let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default());
.width(Size::px(214.))
.color(theme::colors::fg_primary()) SvgViewer::new(("logo.svg", bytes.read().cloned()))
.show_loader(false)
.height(Size::px(44.))
.width(Size::px(214.))
.color(theme::colors::fg_primary())
}
} }
fn navbar_center() -> impl IntoElement { fn navbar_center(is_small: bool) -> impl IntoElement {
let route = use_route::<Route>(); let route = use_route::<Route>();
let browse_target = browse_target(); let browse_target = browse_target();
rect() rect()
.horizontal() .horizontal()
.width(Size::flex(1.0)) .width(Size::flex(1.0))
.main_align(Alignment::Center) .main_align(if is_small {
Alignment::Start
} else {
Alignment::Center
})
.cross_align(Alignment::Center) .cross_align(Alignment::Center)
.spacing(36.) .spacing(if is_small {
NAV_LINK_SPACING_PX
} else {
NAV_LINK_SPACING_PX / 2.
})
.child(NavLink { .child(NavLink {
active: route == Route::Home {}, active: route == Route::Home {},
target: NavTarget::Route(Route::Home {}), target: NavTarget::Route(Route::Home {}),
@ -115,11 +155,6 @@ fn navbar_center() -> impl IntoElement {
target: NavTarget::Route(browse_target), target: NavTarget::Route(browse_target),
nav_label: "Browse", nav_label: "Browse",
}) })
.child(NavLink {
active: route == Route::Stats {},
target: NavTarget::Route(Route::Stats {}),
nav_label: "Stats",
})
} }
/// Falls back active cluster then most recently played then Versions when none exist /// Falls back active cluster then most recently played then Versions when none exist
@ -253,12 +288,22 @@ impl Component for NavbarRight {
let _ = RouterContext::get().push(Route::SettingsLauncher {}); let _ = RouterContext::get().push(Route::SettingsLauncher {});
}; };
let open_stats = |_| {
let _ = RouterContext::get().push(Route::Stats {});
};
rect() rect()
.horizontal() .horizontal()
.width(Size::flex(1.0)) .width(Size::flex(1.0))
.main_align(Alignment::End) .main_align(Alignment::End)
.cross_align(Alignment::Center) .cross_align(Alignment::Center)
.spacing(8.) .spacing(8.)
.child(
super::navbar_button()
.tooltip("Stats")
.child(Icon::new(IconType::LineChartUp01).size(20.))
.on_press(open_stats),
)
.child( .child(
super::navbar_button() super::navbar_button()
.tooltip("Notifications") .tooltip("Notifications")

View file

@ -93,6 +93,7 @@ fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement {
.text(text.to_string()) .text(text.to_string())
.font_size(font_size) .font_size(font_size)
.font_weight(ACTIVE_WEIGHT) .font_weight(ACTIVE_WEIGHT)
.max_lines(1)
.color(Color::TRANSPARENT) .color(Color::TRANSPARENT)
.text_align(TextAlign::Center), .text_align(TextAlign::Center),
) )
@ -115,6 +116,7 @@ fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement {
} else { } else {
FontWeight::NORMAL FontWeight::NORMAL
}) })
.max_lines(1)
.color(colors::fg_primary()) .color(colors::fg_primary())
.text_align(TextAlign::Center), .text_align(TextAlign::Center),
), ),
@ -135,23 +137,36 @@ fn count_pill(count: &str, font_size: f32) -> impl IntoElement {
) )
} }
fn min_tab_width(text: &str, font_size: f32) -> f32 {
text.chars().count() as f32 * font_size * 0.62
}
impl IntoElement for TabBar { impl IntoElement for TabBar {
fn into_element(self) -> Element { fn into_element(self) -> Element {
let font_size = self.font_size; let font_size = self.font_size;
let overflows = self.width == Size::auto();
let content = if overflows {
Content::Normal
} else {
Content::Fit
};
rect() rect()
.horizontal() .horizontal()
.width(self.width) .width(self.width)
.height(self.height) .height(self.height)
.spacing(self.spacing) .spacing(self.spacing)
.cross_align(Alignment::Center) .cross_align(Alignment::Center)
.content(Content::Fit) .content(content)
.children(self.tabs.into_iter().map(|tab| { .children(self.tabs.into_iter().map(|tab| {
let min_width = overflows.then(|| min_tab_width(&tab.label, font_size));
TabButton { TabButton {
label: tab.label, label: tab.label,
active: tab.active, active: tab.active,
count: tab.count, count: tab.count,
font_size, font_size,
min_width,
on_press: tab.on_press, on_press: tab.on_press,
} }
.into_element() .into_element()
@ -166,6 +181,7 @@ struct TabButton {
active: bool, active: bool,
count: Option<String>, count: Option<String>,
font_size: f32, font_size: f32,
min_width: Option<f32>,
on_press: Option<EventHandler<Event<PressEventData>>>, on_press: Option<EventHandler<Event<PressEventData>>>,
} }
@ -181,6 +197,8 @@ impl Component for TabButton {
let mut el = rect() let mut el = rect()
.vertical() .vertical()
.content(Content::Fit) .content(Content::Fit)
.cross_align(Alignment::Center)
.map(self.min_width, |el, w| el.min_width(Size::px(w)))
.a11y_id(a11y_id) .a11y_id(a11y_id)
.a11y_focusable(true) .a11y_focusable(true)
.a11y_role(AccessibilityRole::Button) .a11y_role(AccessibilityRole::Button)

View file

@ -7,7 +7,6 @@ use super::{use_dispatch, use_settings_snapshot};
pub struct PersistedView { pub struct PersistedView {
pub layout: State<ViewLayout>, pub layout: State<ViewLayout>,
pub sort: State<Option<String>>, pub sort: State<Option<String>>,
pub columns: State<u8>,
} }
pub fn use_view_state(key: &str) -> PersistedView { pub fn use_view_state(key: &str) -> PersistedView {
@ -18,17 +17,14 @@ pub fn use_view_state(key: &str) -> PersistedView {
let initial = settings.view_state(&key); let initial = settings.view_state(&key);
let init_layout = initial.layout; let init_layout = initial.layout;
let init_sort = initial.sort; let init_sort = initial.sort;
let init_columns = initial.columns;
let layout = use_state(move || init_layout); let layout = use_state(move || init_layout);
let sort = use_state(move || init_sort.clone()); let sort = use_state(move || init_sort.clone());
let columns = use_state(move || init_columns);
let mut first = use_state(|| true); let mut first = use_state(|| true);
use_side_effect(move || { use_side_effect(move || {
let next = ViewState { let next = ViewState {
layout: *layout.read(), layout: *layout.read(),
sort: sort.read().clone(), sort: sort.read().clone(),
columns: *columns.read(),
}; };
if *first.peek() { if *first.peek() {
first.set(false); first.set(false);
@ -42,9 +38,5 @@ pub fn use_view_state(key: &str) -> PersistedView {
dispatch.set_settings(updated); dispatch.set_settings(updated);
}); });
PersistedView { PersistedView { layout, sort }
layout,
sort,
columns,
}
} }

View file

@ -20,6 +20,7 @@ use crate::use_dispatch;
const SIDEBAR_WIDTH_PX: f32 = 225.; const SIDEBAR_WIDTH_PX: f32 = 225.;
const ITEM_HEIGHT_PX: f32 = 33.; const ITEM_HEIGHT_PX: f32 = 33.;
const SEARCH_WIDTH_PX: f32 = 256.; const SEARCH_WIDTH_PX: f32 = 256.;
const SEARCH_MIN_WIDTH_PX: f32 = 120.;
const SEARCH_RESULTS_MAX: usize = 12; const SEARCH_RESULTS_MAX: usize = 12;
thread_local! { thread_local! {
@ -386,6 +387,7 @@ impl Component for SettingsShell {
let changelog_unread = has_unread_changelog(); let changelog_unread = has_unread_changelog();
let search = use_state(String::new); let search = use_state(String::new);
let header_width = use_state(|| 0f32);
let query = SearchQuery::new(&search.read()); let query = SearchQuery::new(&search.read());
let scroll = use_scroll_controller(ScrollConfig::default); let scroll = use_scroll_controller(ScrollConfig::default);
SETTINGS_SCROLL_CONTROLLER.with(|cell| *cell.borrow_mut() = Some(scroll)); SETTINGS_SCROLL_CONTROLLER.with(|cell| *cell.borrow_mut() = Some(scroll));
@ -433,7 +435,11 @@ impl Component for SettingsShell {
.height(Size::fill()) .height(Size::fill())
.overflow(Overflow::Clip) .overflow(Overflow::Clip)
.spacing(24.) .spacing(24.)
.child(content_header(active.label().to_string(), search)) .child(content_header(
active.label().to_string(),
search,
header_width,
))
.child( .child(
ScrollArea::new() ScrollArea::new()
.width(Size::fill()) .width(Size::fill())
@ -451,27 +457,49 @@ impl Component for SettingsShell {
} }
} }
fn content_header(title: String, search: State<String>) -> impl IntoElement { fn content_header(
title: String,
search: State<String>,
mut width: State<f32>,
) -> impl IntoElement {
let search_width = search_width_for(*width.read());
rect() rect()
.horizontal() .horizontal()
.width(Size::fill()) .width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center) .cross_align(Alignment::Center)
.main_align(Alignment::SpaceBetween) .spacing(12.)
.on_sized(move |event: Event<SizedEventData>| {
let next = event.data().area.width();
if (*width.peek() - next).abs() > 0.5 {
width.set(next);
}
})
.child( .child(
label() label()
.text(title) .text(title)
.font_size(30.) .font_size(30.)
.font_weight(FontWeight::SEMI_BOLD) .font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.width(Size::flex(1.0))
.color(colors::fg_primary()), .color(colors::fg_primary()),
) )
.child(search_box(search)) .child(search_box(search, search_width))
} }
fn search_box(mut search: State<String>) -> impl IntoElement { fn search_width_for(header_width: f32) -> f32 {
if header_width <= 0. {
return SEARCH_WIDTH_PX;
}
(header_width * 0.45).clamp(SEARCH_MIN_WIDTH_PX, SEARCH_WIDTH_PX)
}
fn search_box(mut search: State<String>, width: f32) -> impl IntoElement {
let has_query = !search.read().trim().is_empty(); let has_query = !search.read().trim().is_empty();
let base = TextInput::new(search) let base = TextInput::new(search)
.placeholder("Search settings...") .placeholder("Search settings...")
.width(Size::px(SEARCH_WIDTH_PX)) .width(Size::px(width))
.leading( .leading(
Icon::new(IconType::SearchMd) Icon::new(IconType::SearchMd)
.size(16.) .size(16.)
@ -632,7 +660,7 @@ fn sidebar(active: SettingsTab, changelog_unread: bool) -> impl IntoElement {
.padding(Gaps::new(16., 0., 0., 0.)) .padding(Gaps::new(16., 0., 0., 0.))
.content(Content::Flex) .content(Content::Flex)
.child( .child(
rect() ScrollArea::new()
.width(Size::fill()) .width(Size::fill())
.height(Size::flex(1.0)) .height(Size::flex(1.0))
.spacing(16.) .spacing(16.)
@ -742,6 +770,9 @@ impl Component for SidebarInfo {
.width(Size::fill()) .width(Size::fill())
.spacing(4.) .spacing(4.)
.font_size(12.) .font_size(12.)
.padding(Gaps::new(
15.,0.,0.,0.
))
.color(colors::fg_secondary()) .color(colors::fg_secondary())
.children( .children(
items items

View file

@ -137,11 +137,20 @@ pub fn centered_note(text: &str) -> Element {
.into_element() .into_element()
} }
pub fn grid_columns_for_width(width: f32, max_col: f32, gap: f32) -> usize {
if width <= 0. {
return 1;
}
(((width + gap) / (max_col + gap)).ceil() as usize).max(1)
}
pub fn columns_for(width: f32, min_cell: f32, max: usize, gap: f32) -> usize { pub fn columns_for(width: f32, min_cell: f32, max: usize, gap: f32) -> usize {
let max = max.max(1); let max = max.max(1);
if width <= 0. { if width <= 0. {
return max; return max;
} }
(((width + gap) / (min_cell + gap)).floor() as usize).clamp(1, max) (((width + gap) / (min_cell + gap)).floor() as usize).clamp(1, max)
} }

View file

@ -241,15 +241,15 @@ impl Component for DailyChart {
.child(DateRangePicker::new(custom, (first_day, last_day)).height(30.)) .child(DateRangePicker::new(custom, (first_day, last_day)).height(30.))
.into_element(); .into_element();
chart_card( chart_card(
"Daily playtime", "Daily playtime",
subtitle, subtitle,
Some(nav), Some(nav),
BarChart::new(values, labels) BarChart::new(values, labels)
.unit(ValueUnit::Duration) .unit(ValueUnit::Duration)
.gap(3.) .gap(3.)
.into_element(), .into_element(),
) )
} }
} }

View file

@ -31,10 +31,10 @@ pub(super) fn grid_row(
cluster_id: i64, cluster_id: i64,
package_type: &str, package_type: &str,
installed: &InstalledMap, installed: &InstalledMap,
metrics: GridMetrics, cols: usize,
) -> impl IntoElement { ) -> impl IntoElement {
let package_type = package_type.to_string(); let package_type = package_type.to_string();
let fill = metrics.cols.saturating_sub(row.len()); let fill = cols.saturating_sub(row.len());
let installed: Vec<Option<InstallSource>> = row let installed: Vec<Option<InstallSource>> = row
.iter() .iter()
.map(|item| installed_for(installed, item)) .map(|item| installed_for(installed, item))
@ -43,14 +43,14 @@ pub(super) fn grid_row(
rect() rect()
.horizontal() .horizontal()
.width(Size::fill()) .width(Size::fill())
.height(Size::px(metrics.card_h)) .height(Size::px(CARD_H))
.spacing(GRID_SPACING) .spacing(GRID_SPACING)
.content(Content::Flex) .content(Content::Flex)
.children( .children(
row.into_iter() row.into_iter()
.zip(installed) .zip(installed)
.map(move |(item, installed)| { .map(move |(item, installed)| {
PackageCard::new(item, cluster_id, package_type.clone(), installed, metrics) PackageCard::new(item, cluster_id, package_type.clone(), installed)
.into_element() .into_element()
}), }),
) )
@ -77,7 +77,6 @@ struct PackageCard {
cluster_id: i64, cluster_id: i64,
package_type: String, package_type: String,
installed: Option<InstallSource>, installed: Option<InstallSource>,
metrics: GridMetrics,
} }
impl PackageCard { impl PackageCard {
@ -86,14 +85,12 @@ impl PackageCard {
cluster_id: i64, cluster_id: i64,
package_type: String, package_type: String,
installed: Option<InstallSource>, installed: Option<InstallSource>,
metrics: GridMetrics,
) -> Self { ) -> Self {
Self { Self {
item, item,
cluster_id, cluster_id,
package_type, package_type,
installed, installed,
metrics,
} }
} }
} }
@ -113,7 +110,7 @@ impl Component for PackageCard {
rect() rect()
.vertical() .vertical()
.width(Size::flex(1.0)) .width(Size::flex(1.0))
.height(Size::px(self.metrics.card_h)) .height(Size::px(CARD_H))
.corner_radius(CornerRadius::new_all(10.)) .corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG) .background(CARD_BG)
.border(border_all_color( .border(border_all_color(
@ -135,7 +132,7 @@ impl Component for PackageCard {
.margin(Gaps::new_all(1.)) .margin(Gaps::new_all(1.))
.corner_radius(CornerRadius::new(10., 10., 0., 0.)) .corner_radius(CornerRadius::new(10., 10., 0., 0.))
.overflow(Overflow::Clip) .overflow(Overflow::Clip)
.child(PackageBanner::new(icon_url, self.metrics.banner_h)) .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 // 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| { .maybe_child(self.installed.map(|installed| {
rect() rect()

View file

@ -16,7 +16,6 @@ use oneclient_core::settings::ViewLayout;
use crate::components::{ use crate::components::{
Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput, Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput,
cell_width, grid_columns_picker, resolved_columns,
}; };
use crate::hooks::use_cluster; use crate::hooks::use_cluster;
use crate::hooks::{ use crate::hooks::{
@ -28,6 +27,7 @@ use crate::hooks::{
}; };
use crate::routes::Route; use crate::routes::Route;
use crate::theme::colors; use crate::theme::colors;
use crate::ui::grid_columns_for_width;
use super::{ use super::{
InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_badge_overlay, InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_badge_overlay,
@ -52,21 +52,12 @@ const SEARCH_W: f32 = 260.;
const SEARCH_COMPACT_W: f32 = 170.; const SEARCH_COMPACT_W: f32 = 170.;
const CARD_H: f32 = 240.; const CARD_H: f32 = 240.;
const BANNER_H: f32 = 100.; const BANNER_H: f32 = 100.;
const CARD_TEXT_H: f32 = CARD_H - BANNER_H; const MAX_CARD_W: f32 = 400.;
const BANNER_RATIO: f32 = 0.35;
const BANNER_MAX_H: f32 = 200.;
const LIST_ROW_H: f32 = 78.; const LIST_ROW_H: f32 = 78.;
const GRID_SPACING: f32 = 12.; const GRID_SPACING: f32 = 12.;
const LIST_SPACING: f32 = 8.; const LIST_SPACING: f32 = 8.;
const SEARCH_DEBOUNCE_MS: u64 = 250; const SEARCH_DEBOUNCE_MS: u64 = 250;
#[derive(Clone, Copy, PartialEq)]
pub(super) struct GridMetrics {
pub cols: usize,
pub card_h: f32,
pub banner_h: f32,
}
const BROWSE_TYPES: [(&str, &str); 3] = [ const BROWSE_TYPES: [(&str, &str); 3] = [
("mod", "Mods"), ("mod", "Mods"),
("texture", "Textures"), ("texture", "Textures"),
@ -135,9 +126,7 @@ impl Component for BrowserBody {
let query = use_state(|| saved.query.clone()); let query = use_state(|| saved.query.clone());
let provider = use_state(|| saved.provider); let provider = use_state(|| saved.provider);
let view = use_view_state(&format!("browser.{package_type}")); let view_mode = use_view_state(&format!("browser.{package_type}")).layout;
let view_mode = view.layout;
let grid_columns = view.columns;
let compatible_only = use_browser_compat(); let compatible_only = use_browser_compat();
let selected_categories = use_state(|| saved.categories.clone()); let selected_categories = use_state(|| saved.categories.clone());
let page = use_state(|| saved.page); let page = use_state(|| saved.page);
@ -233,14 +222,11 @@ impl Component for BrowserBody {
let mut grid_width = use_state(|| 0f32); let mut grid_width = use_state(|| 0f32);
let controls_width = use_state(|| 0f32); let controls_width = use_state(|| 0f32);
let cols = resolved_columns(*grid_columns.read()); let cols = grid_columns_for_width(
let card_w = cell_width(
(*grid_width.read() - SCROLLBAR_GUTTER).max(0.), (*grid_width.read() - SCROLLBAR_GUTTER).max(0.),
cols, MAX_CARD_W,
GRID_SPACING, GRID_SPACING,
); );
let banner_h = (card_w * BANNER_RATIO).clamp(BANNER_H, BANNER_MAX_H);
let card_h = banner_h + CARD_TEXT_H;
let fade_dep = (current_page, packages.is_empty(), pending, mode); let fade_dep = (current_page, packages.is_empty(), pending, mode);
let fade = use_animation_with_dependencies(&fade_dep, |conf, _| { let fade = use_animation_with_dependencies(&fade_dep, |conf, _| {
@ -264,19 +250,8 @@ impl Component for BrowserBody {
let rows: Vec<Vec<ProjectSummary>> = let rows: Vec<Vec<ProjectSummary>> =
packages.chunks(cols).map(|c| c.to_vec()).collect(); packages.chunks(cols).map(|c| c.to_vec()).collect();
sa.lazy(rows.len(), card_h, GRID_SPACING, move |i| { sa.lazy(rows.len(), CARD_H, GRID_SPACING, move |i| {
grid_row( grid_row(rows[i].clone(), cluster_id, &pkg, &installed, cols).into_element()
rows[i].clone(),
cluster_id,
&pkg,
&installed,
GridMetrics {
cols,
card_h,
banner_h,
},
)
.into_element()
}) })
} }
ViewLayout::List => sa.lazy(packages.len(), LIST_ROW_H, LIST_SPACING, move |i| { ViewLayout::List => sa.lazy(packages.len(), LIST_ROW_H, LIST_SPACING, move |i| {
@ -287,13 +262,8 @@ impl Component for BrowserBody {
match mode { match mode {
ViewLayout::Grid => { ViewLayout::Grid => {
let rows = BROWSE_PAGE_SIZE.div_ceil(cols); let rows = BROWSE_PAGE_SIZE.div_ceil(cols);
sa.lazy(rows, card_h, GRID_SPACING, move |_| { sa.lazy(rows, CARD_H, GRID_SPACING, move |_| {
skeleton_grid_row(GridMetrics { skeleton_grid_row(cols).into_element()
cols,
card_h,
banner_h,
})
.into_element()
}) })
} }
ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| { ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| {
@ -329,7 +299,6 @@ impl Component for BrowserBody {
provider, provider,
query, query,
view_mode, view_mode,
grid_columns,
controls_width, controls_width,
)) ))
.child( .child(
@ -510,7 +479,6 @@ fn controls(
provider: State<ProviderId>, provider: State<ProviderId>,
query: State<String>, query: State<String>,
view_mode: State<ViewLayout>, view_mode: State<ViewLayout>,
grid_columns: State<u8>,
mut controls_width: State<f32>, mut controls_width: State<f32>,
) -> impl IntoElement { ) -> impl IntoElement {
let measured = *controls_width.read(); let measured = *controls_width.read();
@ -535,10 +503,6 @@ fn controls(
}) })
.child(type_picker) .child(type_picker)
.child(rect().width(Size::flex(1.0))) .child(rect().width(Size::flex(1.0)))
.maybe_child(
(*view_mode.read() == ViewLayout::Grid)
.then(|| grid_columns_picker(grid_columns, 30.).into_element()),
)
.child( .child(
SegmentedControl::new(view_mode) SegmentedControl::new(view_mode)
.equal_width(30.) .equal_width(30.)

View file

@ -27,9 +27,7 @@ fn use_skeleton_pulse() -> f32 {
} }
#[derive(PartialEq)] #[derive(PartialEq)]
struct SkeletonCard { struct SkeletonCard;
metrics: GridMetrics,
}
impl Component for SkeletonCard { impl Component for SkeletonCard {
fn render(&self) -> impl IntoElement { fn render(&self) -> impl IntoElement {
@ -37,13 +35,13 @@ impl Component for SkeletonCard {
rect() rect()
.vertical() .vertical()
.width(Size::flex(1.0)) .width(Size::flex(1.0))
.height(Size::px(self.metrics.card_h)) .height(Size::px(CARD_H))
.corner_radius(CornerRadius::new_all(10.)) .corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG) .background(CARD_BG)
.border(border_all_color(1., colors::component_border())) .border(border_all_color(1., colors::component_border()))
.overflow(Overflow::Clip) .overflow(Overflow::Clip)
.opacity(pulse) .opacity(pulse)
.child(skeleton_block(Size::fill(), self.metrics.banner_h)) .child(skeleton_block(Size::fill(), BANNER_H))
.child( .child(
rect() rect()
.vertical() .vertical()
@ -90,13 +88,13 @@ impl Component for SkeletonListRow {
} }
} }
pub(super) fn skeleton_grid_row(metrics: GridMetrics) -> impl IntoElement { pub(super) fn skeleton_grid_row(cols: usize) -> impl IntoElement {
rect() rect()
.horizontal() .horizontal()
.width(Size::fill()) .width(Size::fill())
.height(Size::px(metrics.card_h)) .height(Size::px(CARD_H))
.spacing(GRID_SPACING) .spacing(GRID_SPACING)
.content(Content::Flex) .content(Content::Flex)
.children((0..metrics.cols).map(move |_| SkeletonCard { metrics }.into_element())) .children((0..cols).map(|_| SkeletonCard.into_element()))
.into_element() .into_element()
} }

View file

@ -4,18 +4,14 @@ use super::*;
use oneclient_content::packages::types::GalleryImage; use oneclient_content::packages::types::GalleryImage;
use crate::components::{ use crate::components::{Button, Icon, IconType, OverlayPopup};
Button, Icon, IconType, OverlayPopup, cell_width, grid_columns_picker, resolved_columns, use crate::hooks::{loaded_image, query_is_busy, use_cached_image};
};
use crate::hooks::{loaded_image, query_is_busy, use_cached_image, use_view_state};
use crate::theme::colors; use crate::theme::colors;
use crate::ui::{ImageFallbackExt, border_all_color, flow_grid}; use crate::ui::{ImageFallbackExt, border_all_color, flow_grid, grid_columns_for_width};
const MAX_COL_W: f32 = 400.; const MAX_COL_W: f32 = 400.;
const GRID_GAP: f32 = 16.; const GRID_GAP: f32 = 16.;
const TILE_PREVIEW_H: f32 = 168.; const TILE_PREVIEW_H: f32 = 168.;
const TILE_PREVIEW_RATIO: f32 = TILE_PREVIEW_H / MAX_COL_W;
const TILE_PREVIEW_MAX_H: f32 = 420.;
/// Downscale ceilings for the image cache full-size tiles made the grid slow to fill /// Downscale ceilings for the image cache full-size tiles made the grid slow to fill
const TILE_EDGE: u32 = 640; const TILE_EDGE: u32 = 640;
@ -35,11 +31,6 @@ impl Component for Gallery {
let grid_width = use_state(|| 0f32); let grid_width = use_state(|| 0f32);
let mut viewing = use_state(|| None::<usize>); let mut viewing = use_state(|| None::<usize>);
let grid_columns = use_view_state("browser.gallery").columns;
let cols = resolved_columns(*grid_columns.read());
let tile_w = cell_width(*grid_width.read(), cols, GRID_GAP);
let preview_h = (tile_w * TILE_PREVIEW_RATIO).clamp(TILE_PREVIEW_H, TILE_PREVIEW_MAX_H);
let tiles: Vec<Element> = self let tiles: Vec<Element> = self
.images .images
.iter() .iter()
@ -48,7 +39,6 @@ impl Component for Gallery {
let key = image.url.clone(); let key = image.url.clone();
GalleryTile { GalleryTile {
image: image.clone(), image: image.clone(),
preview_h,
on_open: (move |()| viewing.set(Some(idx))).into(), on_open: (move |()| viewing.set(Some(idx))).into(),
key: DiffKey::None, key: DiffKey::None,
} }
@ -57,21 +47,11 @@ impl Component for Gallery {
}) })
.collect(); .collect();
let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP);
let images = self.images.clone(); let images = self.images.clone();
rect() rect()
.vertical()
.width(Size::fill()) .width(Size::fill())
.spacing(10.)
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.content(Content::Flex)
.child(rect().width(Size::flex(1.0)))
.child(grid_columns_picker(grid_columns, 30.)),
)
.child(flow_grid(tiles, cols, grid_width, GRID_GAP)) .child(flow_grid(tiles, cols, grid_width, GRID_GAP))
.maybe_child((*viewing.read()).map(|start| { .maybe_child((*viewing.read()).map(|start| {
GalleryViewer { GalleryViewer {
@ -87,7 +67,6 @@ impl Component for Gallery {
#[derive(PartialEq)] #[derive(PartialEq)]
struct GalleryTile { struct GalleryTile {
image: GalleryImage, image: GalleryImage,
preview_h: f32,
on_open: EventHandler<()>, on_open: EventHandler<()>,
key: DiffKey, key: DiffKey,
} }
@ -108,7 +87,7 @@ impl Component for GalleryTile {
let preview = rect() let preview = rect()
.width(Size::fill()) .width(Size::fill())
.height(Size::px(self.preview_h)) .height(Size::px(TILE_PREVIEW_H))
.center() .center()
.overflow(Overflow::Clip) .overflow(Overflow::Clip)
.background(colors::component_bg()) .background(colors::component_bg())
@ -125,6 +104,7 @@ impl Component for GalleryTile {
rect() rect()
.vertical() .vertical()
.width(Size::flex(1.0)) .width(Size::flex(1.0))
.max_width(Size::px(MAX_COL_W))
.corner_radius(CornerRadius::new_all(10.)) .corner_radius(CornerRadius::new_all(10.))
.overflow(Overflow::Clip) .overflow(Overflow::Clip)
.background(PANEL_BG) .background(PANEL_BG)

View file

@ -419,10 +419,10 @@ impl Component for PackageManager {
let search = use_state(String::new); let search = use_state(String::new);
let enabled_filter = use_state(|| EnabledFilter::All); let enabled_filter = use_state(|| EnabledFilter::All);
let hidden_filter = use_state(|| HiddenFilter::Hide); let hidden_filter = use_state(|| HiddenFilter::Hide);
let toolbar_width = use_state(|| 0f32);
let view = use_view_state("cluster.packages"); let view = use_view_state("cluster.packages");
let sort = view.sort; let sort = view.sort;
let layout = view.layout; let layout = view.layout;
let grid_columns = view.columns;
let query = SearchQuery::new(&search.read()); let query = SearchQuery::new(&search.read());
let sort_mode = sort let sort_mode = sort
.read() .read()
@ -475,9 +475,9 @@ impl Component for PackageManager {
enabled_filter, enabled_filter,
hidden_filter, hidden_filter,
layout, layout,
grid_columns,
cluster_id, cluster_id,
package_type, package_type,
toolbar_width,
)) ))
.maybe_child( .maybe_child(
content_type content_type
@ -493,7 +493,6 @@ impl Component for PackageManager {
cluster_id, cluster_id,
content_kind, content_kind,
card_layout, card_layout,
*grid_columns.read(),
)) ))
} }
} }

View file

@ -7,7 +7,7 @@ use oneclient_core::settings::ViewLayout;
use crate::components::{ use crate::components::{
Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx, Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx,
Segment, SegmentedControl, TabBar, TabItem, TextInput, grid_columns_picker, resolved_columns, Segment, SegmentedControl, TabBar, TabItem, TextInput,
}; };
use crate::hooks::{use_dispatch, use_overlay_claim}; use crate::hooks::{use_dispatch, use_overlay_claim};
use crate::routes::Route; use crate::routes::Route;
@ -122,6 +122,17 @@ impl HiddenFilter {
const FILTER_PANEL_W: f32 = 172.; const FILTER_PANEL_W: f32 = 172.;
const FILTER_BTN_W: f32 = 34.; const FILTER_BTN_W: f32 = 34.;
const TOOLBAR_STACK_W: f32 = 640.;
const TABS_ROW_H: f32 = 34.;
fn tabs_scrollbar_theme() -> ScrollBarThemePartial {
ScrollBarThemePartial {
thumb_background: Some(colors::fg_secondary().with_a(120).into()),
hover_thumb_background: Some(colors::fg_secondary().with_a(190).into()),
active_thumb_background: Some(colors::fg_primary().into()),
..Default::default()
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(super) fn toolbar_bar( pub(super) fn toolbar_bar(
@ -134,9 +145,9 @@ pub(super) fn toolbar_bar(
enabled_filter: State<EnabledFilter>, enabled_filter: State<EnabledFilter>,
hidden_filter: State<HiddenFilter>, hidden_filter: State<HiddenFilter>,
layout: State<ViewLayout>, layout: State<ViewLayout>,
grid_columns: State<u8>,
cluster_id: i64, cluster_id: i64,
package_type: &'static str, package_type: &'static str,
mut toolbar_width: State<f32>,
) -> impl IntoElement { ) -> impl IntoElement {
let tab_items = tabs.iter().enumerate().map(|(i, tab)| { let tab_items = tabs.iter().enumerate().map(|(i, tab)| {
let mut active = active; let mut active = active;
@ -146,69 +157,127 @@ pub(super) fn toolbar_bar(
let mut top_corners = CornerRadius::new_all(0.); let mut top_corners = CornerRadius::new_all(0.);
top_corners.fill_top(12.); top_corners.fill_top(12.);
rect() let measured = *toolbar_width.read();
.horizontal() let stacked = measured > 0. && measured < TOOLBAR_STACK_W;
.width(Size::fill())
.cross_align(Alignment::Center) let filter_tabs = ScrollView::new()
.spacing(8.) .direction(Direction::Horizontal)
.content(Content::Flex) .invert_scroll_wheel(true)
.overflow(Overflow::Clip) .show_scrollbar(stacked)
.padding(Gaps::new_symmetric(8., 12.)) .scrollbar_theme(tabs_scrollbar_theme())
.corner_radius(top_corners) .width(if stacked {
.background(colors::page_elevated()) Size::fill()
} else {
Size::flex(1.0)
})
.height(if stacked {
Size::fill()
} else {
Size::auto()
})
.child( .child(
ScrollView::new() TabBar::new()
.direction(Direction::Horizontal) .width(Size::auto())
.show_scrollbar(false) .height(if stacked {
.width(Size::flex(1.0)) Size::fill()
.height(Size::auto()) } else {
.child( Size::auto()
TabBar::new() })
.width(Size::auto()) .spacing(20.)
.height(Size::auto()) .font_size(12.)
.spacing(20.) .tabs(tab_items),
.font_size(12.)
.tabs(tab_items),
),
) )
.child( .into_element();
TextInput::new(search)
.placeholder("Search...") let mut controls: Vec<Element> = vec![
.width(Size::px(180.)) TextInput::new(search)
.leading( .placeholder("Search...")
Icon::new(IconType::SearchMd) .width(Size::px(180.))
.size(14.) .leading(
.color(colors::fg_secondary()) Icon::new(IconType::SearchMd)
.into_element(), .size(14.)
), .color(colors::fg_secondary())
) .into_element(),
.child(FilterButton { )
.into_element(),
FilterButton {
sort, sort,
current_sort, current_sort,
enabled_filter, enabled_filter,
hidden_filter, hidden_filter,
}
.into_element(),
];
controls.push(
SegmentedControl::new(layout)
.height(34.)
.icon_size(15.)
.equal_width(34.)
.segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap))
.segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid))
.into_element(),
);
controls.push(
Button::new()
.primary()
.height(Size::px(34.))
.font_size(12.)
.on_press(move |_| open_browser(cluster_id, package_type))
.child(Icon::new(IconType::Plus).size(15.))
.text("Add Content")
.into_element(),
);
let inner = if stacked {
rect()
.vertical()
.width(Size::fill())
.spacing(8.)
.child(
rect()
.horizontal()
.width(Size::fill())
.height(Size::px(TABS_ROW_H))
.cross_align(Alignment::Center)
.child(filter_tabs),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.content(Content::Flex)
.children(controls),
)
.into_element()
} else {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.content(Content::Flex)
.child(filter_tabs)
.children(controls)
.into_element()
};
rect()
.vertical()
.width(Size::fill())
.overflow(Overflow::Clip)
.padding(Gaps::new_symmetric(8., 12.))
.corner_radius(top_corners)
.background(colors::page_elevated())
.on_sized(move |event: Event<SizedEventData>| {
let next = event.data().area.width();
if (*toolbar_width.peek() - next).abs() > 0.5 {
toolbar_width.set(next);
}
}) })
.maybe_child( .child(inner)
(*layout.read() == ViewLayout::Grid)
.then(|| grid_columns_picker(grid_columns, 34.).into_element()),
)
.child(
SegmentedControl::new(layout)
.height(34.)
.icon_size(15.)
.equal_width(34.)
.segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap))
.segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)),
)
.child(
Button::new()
.primary()
.height(Size::px(34.))
.font_size(12.)
.on_press(move |_| open_browser(cluster_id, package_type))
.child(Icon::new(IconType::Plus).size(15.))
.text("Add Content"),
)
.into_element() .into_element()
} }
@ -576,7 +645,6 @@ pub(super) struct ContentBox {
cluster_id: i64, cluster_id: i64,
kind: ContentKind, kind: ContentKind,
layout: CardLayout, layout: CardLayout,
grid_columns: u8,
} }
impl ContentBox { impl ContentBox {
@ -589,7 +657,6 @@ impl ContentBox {
cluster_id: i64, cluster_id: i64,
kind: ContentKind, kind: ContentKind,
layout: CardLayout, layout: CardLayout,
grid_columns: u8,
) -> Self { ) -> Self {
Self { Self {
items, items,
@ -599,7 +666,6 @@ impl ContentBox {
cluster_id, cluster_id,
kind, kind,
layout, layout,
grid_columns,
} }
} }
} }
@ -613,7 +679,6 @@ impl Component for ContentBox {
let noun_plural = self.noun_plural; let noun_plural = self.noun_plural;
let kind = &self.kind; let kind = &self.kind;
let layout = self.layout; let layout = self.layout;
let grid_columns = self.grid_columns;
let dispatch = use_dispatch(); let dispatch = use_dispatch();
@ -640,7 +705,7 @@ impl Component for ContentBox {
.height(Size::fill()) .height(Size::fill())
.scrollbar_gutter(true) .scrollbar_gutter(true)
.content(move |ctx: ScrollAreaCtx| { .content(move |ctx: ScrollAreaCtx| {
grid_content(&items, package_type, cluster_id, grid_columns, ctx).into_element() grid_content(&items, package_type, cluster_id, ctx).into_element()
}) })
.into_element(), .into_element(),
}); });
@ -705,12 +770,11 @@ fn grid_content(
items: &[PackageEntry], items: &[PackageEntry],
package_type: &'static str, package_type: &'static str,
cluster_id: i64, cluster_id: i64,
grid_columns: u8,
ctx: ScrollAreaCtx, ctx: ScrollAreaCtx,
) -> impl IntoElement { ) -> impl IntoElement {
let count = items.len(); let count = items.len();
let fits = ((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize; let cols =
let cols = resolved_columns(grid_columns).min(fits.max(1)); (((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 5);
let rows_total = count.div_ceil(cols); let rows_total = count.div_ceil(cols);
let slot = CARD_GRID_H + GRID_GAP; let slot = CARD_GRID_H + GRID_GAP;

View file

@ -7,13 +7,12 @@ use oneclient_core::settings::ViewLayout;
use crate::components::{ use crate::components::{
Button, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, Segment, Button, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, Segment,
SegmentedControl, cell_width, grid_columns_picker, open_folder_button, resolved_columns, SegmentedControl, open_folder_button, screenshot_context_menu,
screenshot_context_menu,
}; };
use crate::hooks::{ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, use_screenshot_action, use_screenshot_folder_watch, use_view_state}; use crate::hooks::{ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, use_screenshot_action, use_screenshot_folder_watch, use_view_state};
use crate::layout::cluster_content; use crate::layout::cluster_content;
use crate::theme::colors; use crate::theme::colors;
use crate::ui::{border_all_color, flow_grid, fmt_date}; use crate::ui::{border_all_color, flow_grid, fmt_date, grid_columns_for_width};
use crate::utils::{format_res, format_size}; use crate::utils::{format_res, format_size};
use super::cluster_not_found; use super::cluster_not_found;
@ -23,8 +22,6 @@ const THUMB_EDGE: u32 = 480;
const MAX_COL_W: f32 = 400.; const MAX_COL_W: f32 = 400.;
const GRID_GAP: f32 = 16.; const GRID_GAP: f32 = 16.;
const TILE_PREVIEW_H: f32 = 168.; const TILE_PREVIEW_H: f32 = 168.;
const TILE_PREVIEW_RATIO: f32 = TILE_PREVIEW_H / MAX_COL_W;
const TILE_PREVIEW_MAX_H: f32 = 420.;
const CARD_BG: Color = Color::from_rgb(26, 34, 41); const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const CARD_NAME: Color = Color::from_rgb(213, 219, 255); const CARD_NAME: Color = Color::from_rgb(213, 219, 255);
@ -48,9 +45,7 @@ impl Component for ClusterScreenshots {
let shots = try_cluster_screenshots(&query).unwrap_or_default(); let shots = try_cluster_screenshots(&query).unwrap_or_default();
let view = use_view_state("cluster.screenshots"); let view_mode = use_view_state("cluster.screenshots").layout;
let view_mode = view.layout;
let grid_columns = view.columns;
let menu_dispatch = use_dispatch(); let menu_dispatch = use_dispatch();
let mut edit_mode = use_state(|| false); let mut edit_mode = use_state(|| false);
@ -64,16 +59,11 @@ impl Component for ClusterScreenshots {
&shots, &shots,
folder, folder,
view_mode, view_mode,
grid_columns,
edit_mode, edit_mode,
selected, selected,
confirm_delete, confirm_delete,
); );
let cols = resolved_columns(*grid_columns.read());
let tile_w = cell_width(*grid_width.read(), cols, GRID_GAP);
let preview_h = (tile_w * TILE_PREVIEW_RATIO).clamp(TILE_PREVIEW_H, TILE_PREVIEW_MAX_H);
let content: Element = if shots.is_empty() { let content: Element = if shots.is_empty() {
empty_state(query_is_loading(&query)) empty_state(query_is_loading(&query))
.into_element() .into_element()
@ -108,7 +98,6 @@ impl Component for ClusterScreenshots {
info, info,
selected: is_selected, selected: is_selected,
edit_mode: editing, edit_mode: editing,
preview_h,
on_activate: on_activate.into(), on_activate: on_activate.into(),
on_context, on_context,
} }
@ -126,7 +115,10 @@ impl Component for ClusterScreenshots {
} }
match *view_mode.read() { match *view_mode.read() {
ViewLayout::Grid => flow_grid(items, cols, grid_width, GRID_GAP), ViewLayout::Grid => {
let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP);
flow_grid(items, cols, grid_width, GRID_GAP)
}
ViewLayout::List => rect() ViewLayout::List => rect()
.vertical() .vertical()
.width(Size::fill()) .width(Size::fill())
@ -185,7 +177,6 @@ fn toolbar_row(
shots: &[ScreenshotInfo], shots: &[ScreenshotInfo],
folder: Option<PathBuf>, folder: Option<PathBuf>,
view_mode: State<ViewLayout>, view_mode: State<ViewLayout>,
grid_columns: State<u8>,
mut edit_mode: State<bool>, mut edit_mode: State<bool>,
mut selected: State<HashSet<PathBuf>>, mut selected: State<HashSet<PathBuf>>,
mut confirm_delete: State<bool>, mut confirm_delete: State<bool>,
@ -253,10 +244,6 @@ fn toolbar_row(
); );
} else { } else {
right = right right = right
.maybe_child(
(*view_mode.read() == ViewLayout::Grid)
.then(|| grid_columns_picker(grid_columns, 36.).into_element()),
)
.child( .child(
SegmentedControl::new(view_mode) SegmentedControl::new(view_mode)
.equal_width(40.) .equal_width(40.)
@ -406,7 +393,6 @@ struct ScreenshotTile {
info: ScreenshotInfo, info: ScreenshotInfo,
selected: bool, selected: bool,
edit_mode: bool, edit_mode: bool,
preview_h: f32,
on_activate: EventHandler<()>, on_activate: EventHandler<()>,
on_context: EventHandler<(f32, f32)>, on_context: EventHandler<(f32, f32)>,
} }
@ -431,6 +417,7 @@ impl Component for ScreenshotTile {
rect() rect()
.vertical() .vertical()
.width(Size::flex(1.0)) .width(Size::flex(1.0))
.max_width(Size::px(MAX_COL_W))
.corner_radius(CornerRadius::new_all(10.)) .corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG) .background(CARD_BG)
.overflow(Overflow::Clip) .overflow(Overflow::Clip)
@ -446,7 +433,7 @@ impl Component for ScreenshotTile {
}) })
.child(preview_box( .child(preview_box(
info.path.clone(), info.path.clone(),
self.preview_h, TILE_PREVIEW_H,
info.resolution, info.resolution,
)) ))
.child( .child(

View file

@ -22,6 +22,8 @@ const MIN_CARD_WIDTH_PX: f32 = 280.;
const SIDEBAR_WIDTH_PX: f32 = 300.; const SIDEBAR_WIDTH_PX: f32 = 300.;
const CARD_HEIGHT_PX: f32 = 146.; const CARD_HEIGHT_PX: f32 = 146.;
const PLACEHOLDER_VERSION_INFO: &str = "Placeholder version info"; const PLACEHOLDER_VERSION_INFO: &str = "Placeholder version info";
const ART_MAX_HEIGHT_PX: f32 = 140.;
const ART_MIN_SIDEBAR_PX: f32 = 260.;
#[derive(PartialEq)] #[derive(PartialEq)]
pub struct Clusters; pub struct Clusters;
@ -196,6 +198,7 @@ struct DetailSidebar {
impl Component for DetailSidebar { impl Component for DetailSidebar {
fn render(&self) -> impl IntoElement { fn render(&self) -> impl IntoElement {
let mut sidebar_height = use_state(|| 0f32);
let active_id = use_active_cluster_id(); let active_id = use_active_cluster_id();
let dispatch = use_dispatch(); let dispatch = use_dispatch();
let game = use_game_snapshot(); let game = use_game_snapshot();
@ -224,6 +227,8 @@ impl Component for DetailSidebar {
.map(|m| m.tags.clone()) .map(|m| m.tags.clone())
.unwrap_or_default(); .unwrap_or_default();
let art_height = art_height_for(*sidebar_height.read());
rect() rect()
.width(Size::px(SIDEBAR_WIDTH_PX)) .width(Size::px(SIDEBAR_WIDTH_PX))
.min_width(Size::px(SIDEBAR_WIDTH_PX)) .min_width(Size::px(SIDEBAR_WIDTH_PX))
@ -231,13 +236,29 @@ impl Component for DetailSidebar {
.vertical() .vertical()
.spacing(8.) .spacing(8.)
.padding(8.) .padding(8.)
.content(Content::Flex)
.corner_radius(CornerRadius::new_all(12.)) .corner_radius(CornerRadius::new_all(12.))
.background(colors::page_elevated()) .background(colors::page_elevated())
.border(border_all_color(1., colors::component_border())) .border(border_all_color(1., colors::component_border()))
.overflow(Overflow::Clip) .overflow(Overflow::Clip)
.child(rect().width(Size::fill()).max_height(Size::px(140.)).child( .on_sized(move |event: Event<SizedEventData>| {
ClusterLandscapeArt::for_version(self.line.major, version_value, loader_value, false), let height = event.data().area.height();
)) if (*sidebar_height.peek() - height).abs() > 0.5 {
*sidebar_height.write() = height;
}
})
.maybe_child(art_height.map(|max| {
rect()
.width(Size::fill())
.max_height(Size::px(max))
.child(ClusterLandscapeArt::for_version(
self.line.major,
version_value,
loader_value,
false,
))
.into_element()
}))
.child( .child(
rect() rect()
.vertical() .vertical()
@ -251,20 +272,27 @@ impl Component for DetailSidebar {
.vertical() .vertical()
.width(Size::fill()) .width(Size::fill())
.height(Size::flex(1.0)) .height(Size::flex(1.0))
.content(Content::Flex)
.spacing(4.) .spacing(4.)
.child( .child(
label() ScrollArea::new()
.text(heading) .width(Size::fill())
.font_size(24.) .height(Size::flex(1.0))
.font_weight(FontWeight::SEMI_BOLD) .spacing(4.)
.color(colors::fg_primary()), .child(
) label()
.maybe_child(tags_row(&tags)) .text(heading)
.child( .font_size(24.)
label() .font_weight(FontWeight::SEMI_BOLD)
.text(description) .color(colors::fg_primary()),
.font_size(12.) )
.color(colors::fg_secondary()), .children(tags_row(&tags))
.child(
label()
.text(description)
.font_size(12.)
.color(colors::fg_secondary()),
),
) )
.children(version_rows(self.line, &keys, version_value, self.selected_version)) .children(version_rows(self.line, &keys, version_value, self.selected_version))
.children(loader_rows(&loaders, loader_value, self.selected_loader)), .children(loader_rows(&loaders, loader_value, self.selected_loader)),
@ -288,6 +316,16 @@ impl Component for DetailSidebar {
} }
} }
fn art_height_for(sidebar_height: f32) -> Option<f32> {
if sidebar_height <= 0. {
return Some(ART_MAX_HEIGHT_PX);
}
if sidebar_height < ART_MIN_SIDEBAR_PX {
return None;
}
Some((sidebar_height * 0.32).min(ART_MAX_HEIGHT_PX))
}
fn tags_row(tags: &[String]) -> Option<Element> { fn tags_row(tags: &[String]) -> Option<Element> {
if tags.is_empty() { if tags.is_empty() {
return None; return None;

View file

@ -13,25 +13,12 @@ pub enum ViewLayout {
List, List,
} }
pub const DEFAULT_GRID_COLUMNS: u8 = 3; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(default)] #[serde(default)]
pub struct ViewState { pub struct ViewState {
pub layout: ViewLayout, pub layout: ViewLayout,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub sort: Option<String>, pub sort: Option<String>,
pub columns: u8,
}
impl Default for ViewState {
fn default() -> Self {
Self {
layout: ViewLayout::default(),
sort: None,
columns: DEFAULT_GRID_COLUMNS,
}
}
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]

View file

@ -2,7 +2,7 @@ mod launcher;
pub mod data_dir; pub mod data_dir;
pub use launcher::{DEFAULT_GRID_COLUMNS, LauncherSettings, ViewLayout, ViewState}; pub use launcher::{LauncherSettings, ViewLayout, ViewState};
pub use oneclient_cluster::{ pub use oneclient_cluster::{
GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra,
}; };