feat: added a picker for the amount of columns in a row (#784)

This commit is contained in:
Maciek 2026-09-04 17:30:01 +02:00 • committed by LynithDev
parent 12783fe59a
commit f55a86a024
13 changed files with 195 additions and 53 deletions

View file

@ -0,0 +1,27 @@
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

@ -9,6 +9,7 @@ mod context_menu;
mod dropdown;
mod dynamic_art;
mod file_drop;
mod grid_columns;
mod icons;
mod java_install_manager;
mod generic_prompt;
@ -51,6 +52,7 @@ pub use context_menu::ContextMenu;
pub use dropdown::Dropdown;
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
pub use file_drop::FileDropOverlay;
pub use grid_columns::{cell_width, grid_columns_picker, resolved_columns};
pub use icons::{Icon, IconTint, IconType};
pub use java_install_manager::JavaInstallManager;
pub use generic_prompt::GenericPromptOverlay;

View file

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

View file

@ -114,15 +114,6 @@ pub fn centered_note(text: &str) -> Element {
.into_element()
}
/// Returns 1 until the grid has measured itself so the first frame still renders
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)
}
/// Reports its measured size back through `width` for [`grid_columns_for_width`]
/// Short final rows are padded with empty flex cells so tiles keep the column width
pub fn flow_grid(items: Vec<Element>, cols: usize, mut width: State<f32>, gap: f32) -> Element {
let cols = cols.max(1);

View file

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

View file

@ -16,6 +16,7 @@ use oneclient_core::settings::ViewLayout;
use crate::components::{
Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput,
cell_width, grid_columns_picker, resolved_columns,
};
use crate::hooks::use_cluster;
use crate::hooks::{
@ -43,15 +44,24 @@ 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 GRID_COLUMNS: usize = 3;
const SCROLLBAR_GUTTER: f32 = 18.;
const CARD_H: f32 = 240.;
const BANNER_H: f32 = 100.;
const CARD_TEXT_H: f32 = CARD_H - BANNER_H;
const BANNER_RATIO: f32 = 0.35;
const BANNER_MAX_H: f32 = 200.;
const LIST_ROW_H: f32 = 78.;
const GRID_SPACING: f32 = 12.;
const LIST_SPACING: f32 = 8.;
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,
}
fn type_title(package_type: &str) -> &'static str {
match package_type {
"shader" => "Shaders",
@ -84,7 +94,9 @@ impl Component for Browser {
let query = use_state(|| saved.query.clone());
let provider = use_state(|| saved.provider);
let view_mode = use_view_state(&format!("browser.{package_type}")).layout;
let view = use_view_state(&format!("browser.{package_type}"));
let view_mode = view.layout;
let grid_columns = view.columns;
let compatible_only = use_browser_compat();
let selected_categories = use_state(|| saved.categories.clone());
let page = use_state(|| saved.page);
@ -178,6 +190,16 @@ impl Component for Browser {
let mode = *view_mode.read();
let mut grid_width = use_state(|| 0f32);
let cols = resolved_columns(*grid_columns.read());
let card_w = cell_width(
(*grid_width.read() - SCROLLBAR_GUTTER).max(0.),
cols,
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 = use_animation_with_dependencies(&fade_dep, |conf, _| {
conf.on_creation(OnCreation::Run);
@ -198,10 +220,21 @@ impl Component for Browser {
match mode {
ViewLayout::Grid => {
let rows: Vec<Vec<ProjectSummary>> =
packages.chunks(GRID_COLUMNS).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| {
grid_row(rows[i].clone(), cluster_id, &pkg, &installed).into_element()
sa.lazy(rows.len(), card_h, GRID_SPACING, move |i| {
grid_row(
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| {
@ -211,9 +244,14 @@ impl Component for Browser {
} else if pending {
match mode {
ViewLayout::Grid => {
let rows = BROWSE_PAGE_SIZE.div_ceil(GRID_COLUMNS);
sa.lazy(rows, CARD_H, GRID_SPACING, |_| {
skeleton_grid_row().into_element()
let rows = BROWSE_PAGE_SIZE.div_ceil(cols);
sa.lazy(rows, card_h, GRID_SPACING, move |_| {
skeleton_grid_row(GridMetrics {
cols,
card_h,
banner_h,
})
.into_element()
})
}
ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| {
@ -240,7 +278,7 @@ impl Component for Browser {
package_type: package_type.clone(),
}),
))
.child(controls(provider, query, view_mode))
.child(controls(provider, query, view_mode, grid_columns))
.child(
rect()
.horizontal()
@ -265,7 +303,13 @@ impl Component for Browser {
.width(Size::fill())
.height(Size::flex(1.0))
.child(results)
.opacity(fade_opacity),
.opacity(fade_opacity)
.on_sized(move |event: Event<SizedEventData>| {
let w = event.data().area.width();
if (w - *grid_width.peek()).abs() > 0.5 {
*grid_width.write() = w;
}
}),
)
.maybe_child(pages.map(|pages| {
Pagination::new(page, pages)
@ -379,6 +423,7 @@ fn controls(
provider: State<ProviderId>,
query: State<String>,
view_mode: State<ViewLayout>,
grid_columns: State<u8>,
) -> impl IntoElement {
rect()
.horizontal()
@ -387,6 +432,10 @@ fn controls(
.spacing(12.)
.content(Content::Flex)
.child(rect().width(Size::flex(1.0)))
.maybe_child(
(*view_mode.read() == ViewLayout::Grid)
.then(|| grid_columns_picker(grid_columns, 30.).into_element()),
)
.child(
SegmentedControl::new(view_mode)
.equal_width(30.)

View file

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

View file

@ -4,15 +4,18 @@ use super::*;
use oneclient_content::packages::types::GalleryImage;
use crate::components::{Button, Icon, IconType, OverlayPopup};
use crate::hooks::{loaded_image, query_is_busy, use_cached_image};
use crate::components::{
Button, Icon, IconType, OverlayPopup, cell_width, grid_columns_picker, resolved_columns,
};
use crate::hooks::{loaded_image, query_is_busy, use_cached_image, use_view_state};
use crate::theme::colors;
use crate::ui::{border_all_color, flow_grid, grid_columns_for_width};
use crate::ui::{border_all_color, flow_grid};
/// How wide a tile is allowed to get before the grid takes another column
const MAX_COL_W: f32 = 400.;
const GRID_GAP: f32 = 16.;
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
const TILE_EDGE: u32 = 640;
@ -32,6 +35,11 @@ impl Component for Gallery {
let grid_width = use_state(|| 0f32);
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
.images
.iter()
@ -40,6 +48,7 @@ impl Component for Gallery {
let key = image.url.clone();
GalleryTile {
image: image.clone(),
preview_h,
on_open: (move |()| viewing.set(Some(idx))).into(),
key: DiffKey::None,
}
@ -48,11 +57,21 @@ impl Component for Gallery {
})
.collect();
let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP);
let images = self.images.clone();
rect()
.vertical()
.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))
.maybe_child((*viewing.read()).map(|start| {
GalleryViewer {
@ -68,6 +87,7 @@ impl Component for Gallery {
#[derive(PartialEq)]
struct GalleryTile {
image: GalleryImage,
preview_h: f32,
on_open: EventHandler<()>,
key: DiffKey,
}
@ -88,7 +108,7 @@ impl Component for GalleryTile {
let preview = rect()
.width(Size::fill())
.height(Size::px(TILE_PREVIEW_H))
.height(Size::px(self.preview_h))
.center()
.overflow(Overflow::Clip)
.background(colors::component_bg())
@ -104,7 +124,6 @@ impl Component for GalleryTile {
rect()
.vertical()
.width(Size::flex(1.0))
.max_width(Size::px(MAX_COL_W))
.corner_radius(CornerRadius::new_all(10.))
.overflow(Overflow::Clip)
.background(PANEL_BG)

View file

@ -386,6 +386,7 @@ impl Component for PackageManager {
let view = use_view_state("cluster.packages");
let sort = view.sort;
let layout = view.layout;
let grid_columns = view.columns;
let query = SearchQuery::new(&search.read());
let sort_mode = sort
.read()
@ -438,6 +439,7 @@ impl Component for PackageManager {
enabled_filter,
hidden_filter,
layout,
grid_columns,
cluster_id,
package_type,
))
@ -450,6 +452,7 @@ impl Component for PackageManager {
cluster_id,
content_kind,
card_layout,
*grid_columns.read(),
))
}
}

View file

@ -7,7 +7,7 @@ use oneclient_core::settings::ViewLayout;
use crate::components::{
Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx,
Segment, SegmentedControl, TabBar, TabItem, TextInput,
Segment, SegmentedControl, TabBar, TabItem, TextInput, grid_columns_picker, resolved_columns,
};
use crate::hooks::{use_dispatch, use_overlay_claim};
use crate::routes::Route;
@ -140,6 +140,7 @@ pub(super) fn toolbar_bar(
enabled_filter: State<EnabledFilter>,
hidden_filter: State<HiddenFilter>,
layout: State<ViewLayout>,
grid_columns: State<u8>,
cluster_id: i64,
package_type: &'static str,
) -> impl IntoElement {
@ -193,6 +194,10 @@ pub(super) fn toolbar_bar(
enabled_filter,
hidden_filter,
})
.maybe_child(
(*layout.read() == ViewLayout::Grid)
.then(|| grid_columns_picker(grid_columns, 34.).into_element()),
)
.child(
SegmentedControl::new(layout)
.height(34.)
@ -557,6 +562,7 @@ pub(super) struct ContentBox {
cluster_id: i64,
kind: ContentKind,
layout: CardLayout,
grid_columns: u8,
}
impl ContentBox {
@ -569,6 +575,7 @@ impl ContentBox {
cluster_id: i64,
kind: ContentKind,
layout: CardLayout,
grid_columns: u8,
) -> Self {
Self {
items,
@ -578,6 +585,7 @@ impl ContentBox {
cluster_id,
kind,
layout,
grid_columns,
}
}
}
@ -591,6 +599,7 @@ impl Component for ContentBox {
let noun_plural = self.noun_plural;
let kind = &self.kind;
let layout = self.layout;
let grid_columns = self.grid_columns;
let dispatch = use_dispatch();
@ -615,7 +624,7 @@ impl Component for ContentBox {
.width(Size::fill())
.height(Size::fill())
.content(move |ctx: ScrollAreaCtx| {
grid_content(&items, package_type, cluster_id, ctx).into_element()
grid_content(&items, package_type, cluster_id, grid_columns, ctx).into_element()
})
.into_element(),
});
@ -680,11 +689,12 @@ fn grid_content(
items: &[PackageEntry],
package_type: &'static str,
cluster_id: i64,
grid_columns: u8,
ctx: ScrollAreaCtx,
) -> impl IntoElement {
let count = items.len();
let cols =
(((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 3);
let fits = ((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize;
let cols = resolved_columns(grid_columns).min(fits.max(1));
let rows_total = count.div_ceil(cols);
let slot = CARD_GRID_H + GRID_GAP;

View file

@ -7,12 +7,13 @@ use oneclient_core::settings::ViewLayout;
use crate::components::{
Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea,
Segment, SegmentedControl, open_folder_button,
Segment, SegmentedControl, cell_width, grid_columns_picker, open_folder_button,
resolved_columns,
};
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::theme::colors;
use crate::ui::{border_all_color, flow_grid, fmt_date, grid_columns_for_width};
use crate::ui::{border_all_color, flow_grid, fmt_date};
use crate::utils::{format_res, format_size};
use super::cluster_not_found;
@ -22,6 +23,8 @@ const THUMB_EDGE: u32 = 480;
const MAX_COL_W: f32 = 400.;
const GRID_GAP: f32 = 16.;
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_NAME: Color = Color::from_rgb(213, 219, 255);
@ -45,7 +48,9 @@ impl Component for ClusterScreenshots {
let shots = try_cluster_screenshots(&query).unwrap_or_default();
let view_mode = use_view_state("cluster.screenshots").layout;
let view = use_view_state("cluster.screenshots");
let view_mode = view.layout;
let grid_columns = view.columns;
let menu_dispatch = use_dispatch();
let mut edit_mode = use_state(|| false);
@ -59,11 +64,16 @@ impl Component for ClusterScreenshots {
&shots,
folder,
view_mode,
grid_columns,
edit_mode,
selected,
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() {
empty_state(query_is_loading(&query))
.into_element()
@ -98,6 +108,7 @@ impl Component for ClusterScreenshots {
info,
selected: is_selected,
edit_mode: editing,
preview_h,
on_activate: on_activate.into(),
on_context,
}
@ -115,10 +126,7 @@ impl Component for ClusterScreenshots {
}
match *view_mode.read() {
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::Grid => flow_grid(items, cols, grid_width, GRID_GAP),
ViewLayout::List => rect()
.vertical()
.width(Size::fill())
@ -200,6 +208,7 @@ fn toolbar_row(
shots: &[ScreenshotInfo],
folder: Option<PathBuf>,
view_mode: State<ViewLayout>,
grid_columns: State<u8>,
mut edit_mode: State<bool>,
mut selected: State<HashSet<PathBuf>>,
mut confirm_delete: State<bool>,
@ -267,6 +276,10 @@ fn toolbar_row(
);
} else {
right = right
.maybe_child(
(*view_mode.read() == ViewLayout::Grid)
.then(|| grid_columns_picker(grid_columns, 36.).into_element()),
)
.child(
SegmentedControl::new(view_mode)
.equal_width(40.)
@ -416,6 +429,7 @@ struct ScreenshotTile {
info: ScreenshotInfo,
selected: bool,
edit_mode: bool,
preview_h: f32,
on_activate: EventHandler<()>,
on_context: EventHandler<(f32, f32)>,
}
@ -440,7 +454,6 @@ impl Component for ScreenshotTile {
rect()
.vertical()
.width(Size::flex(1.0))
.max_width(Size::px(MAX_COL_W))
.corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG)
.overflow(Overflow::Clip)
@ -456,7 +469,7 @@ impl Component for ScreenshotTile {
})
.child(preview_box(
info.path.clone(),
TILE_PREVIEW_H,
self.preview_h,
info.resolution,
))
.child(

View file

@ -13,12 +13,25 @@ pub enum ViewLayout {
List,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
pub const DEFAULT_GRID_COLUMNS: u8 = 3;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(default)]
pub struct ViewState {
pub layout: ViewLayout,
#[serde(skip_serializing_if = "Option::is_none")]
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)]

View file

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