feat: added sections for advanced mods (#876)

* feat: added sections for advanced mods

* fix: treat a null bundle file type as normal

* chore: drop ScrollArea::lazy_grid

* chore: pass one header height to lazy_sections

* chore: name BundleFileType::Normal in test fixtures

* feat: don't offer advanced bundle mods as optional mods

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
This commit is contained in:
Maciek 2026-09-28 20:31:18 +02:00 • committed by GitHub
parent f94ca969b0
commit 7f1a3176cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 303 additions and 69 deletions

View file

@ -95,7 +95,7 @@ pub use progress_track::progress_track;
pub use recents_row::RecentsRow;
pub use screenshot_viewer::{ScreenshotViewer, screenshot_context_menu};
pub(crate) use scrollview::corrected_scroll;
pub use scrollview::{ScrollArea, ScrollAreaCtx};
pub use scrollview::{LazySection, ScrollArea, ScrollAreaCtx};
pub use segmented_control::{Segment, SegmentedControl};
pub use splash_curtain::SplashCurtain;
pub use status_bar::StatusBar;

View file

@ -125,6 +125,7 @@ fn entry_from_item(
hash: None,
manifest_default: false,
hidden: false,
advanced: false,
update_available: false,
// Offered, not installed, so there is no recency badge to show
seen_status: SeenStatus::default(),

View file

@ -52,6 +52,7 @@ pub struct PackageEntry {
pub manifest_default: bool,
/// Private bundle dependency only set for bundle rows
pub hidden: bool,
pub advanced: bool,
/// Only set for browser-installed content bundle packages use the bundle update flow
pub update_available: bool,
/// Recency badge state cleared once the user views the list

View file

@ -81,6 +81,22 @@ pub struct ScrollAreaCtx {
pub viewport_left: f32,
}
#[derive(Clone, Copy)]
pub struct LazySection {
pub header: bool,
pub count: usize,
}
enum LazyRow {
Header(usize),
Items {
section: usize,
row: usize,
start: usize,
end: usize,
},
}
pub struct ScrollArea {
width: Size,
height: Size,
@ -246,51 +262,108 @@ impl ScrollArea {
self
}
pub fn lazy_grid(
#[allow(clippy::too_many_arguments)]
pub fn lazy_sections(
mut self,
count: usize,
sections: Vec<LazySection>,
item_height: f32,
gap: f32,
min_width: f32,
max_cols: usize,
header_height: f32,
render: impl Fn(usize) -> Element + 'static,
render_header: impl Fn(usize) -> Element + 'static,
) -> Self {
let slot = (item_height + gap).max(1.);
let header_slot = header_height + gap;
self.builder = Some(Box::new(move |ctx: ScrollAreaCtx| {
let cols = (((ctx.viewport_w + gap) / (min_width + gap)).floor() as usize)
.clamp(1, max_cols.max(1));
let rows_total = count.div_ceil(cols);
let first =
(((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize;
let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize;
let last = (first + span).min(rows_total);
let mut rows = Vec::new();
let mut offset = 0;
for (s, section) in sections.iter().enumerate() {
if section.header {
rows.push(LazyRow::Header(s));
}
for r in 0..section.count.div_ceil(cols) {
let start = offset + r * cols;
rows.push(LazyRow::Items {
section: s,
row: r,
start,
end: (start + cols).min(offset + section.count),
});
}
offset += section.count;
}
let top_pad = first as f32 * slot;
let bottom_pad = rows_total.saturating_sub(last) as f32 * slot;
let row_slot = |row: &LazyRow| match row {
LazyRow::Header(_) => header_slot,
LazyRow::Items { .. } => slot,
};
let overscan = LAZY_OVERSCAN as f32 * slot;
let visible_top = -ctx.corrected_y - overscan;
let visible_bottom = -ctx.corrected_y + ctx.viewport_h + overscan;
let mut top_pad = 0.;
let mut bottom_pad = 0.;
let mut visible = Vec::new();
let mut y = 0.;
for row in &rows {
let h = row_slot(row);
if y + h <= visible_top {
top_pad += h;
} else if y >= visible_bottom {
bottom_pad += h;
} else {
visible.push(row);
}
y += h;
}
let mut container = rect().vertical().width(Size::fill());
if top_pad > 0. {
container = container.child(rect().width(Size::fill()).height(Size::px(top_pad)));
}
for r in first..last {
let mut row = rect()
.key(r)
.horizontal()
.width(Size::fill())
.height(Size::px(slot))
.spacing(gap)
.content(Content::Flex);
for c in 0..cols {
let idx = r * cols + c;
let cell = rect().width(Size::flex(1.0)).height(Size::px(item_height));
row = row.child(if idx < count {
cell.child(render(idx))
} else {
cell
});
}
container = container.child(row);
for row in visible {
let el = match *row {
LazyRow::Header(section) => rect()
.key(("h", section))
.width(Size::fill())
.height(Size::px(header_slot))
.child(
rect()
.width(Size::fill())
.height(Size::px(header_height))
.child(render_header(section)),
),
LazyRow::Items {
section,
row,
start,
end,
} => {
let mut items = rect()
.key(("r", section, row))
.horizontal()
.width(Size::fill())
.height(Size::px(slot))
.spacing(gap)
.content(Content::Flex);
for idx in start..start + cols {
let cell = rect().width(Size::flex(1.0)).height(Size::px(item_height));
items = items.child(if idx < end {
cell.child(render(idx))
} else {
cell
});
}
items
}
};
container = container.child(el);
}
if bottom_pad > 0. {
container =

View file

@ -402,6 +402,7 @@ mod tests {
hidden: false,
path: format!("mods/{project_id}.jar"),
size: 1,
file_type: oneclient_core::BundleFileType::Normal,
kind: BundleFileKind::Managed {
provider: ProviderId::Modrinth,
project_id: project_id.to_string(),

View file

@ -4,7 +4,7 @@ use freya::prelude::*;
use oneclient_common::search::{MatchScore, SearchQuery};
use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId};
use oneclient_core::{
BundleFileKind, BundleWithUpdateStatus, FileUpdateStatus, LinkedArtifactInfo,
BundleFileKind, BundleFileType, BundleWithUpdateStatus, FileUpdateStatus, LinkedArtifactInfo,
};
use oneclient_db::models::OverrideType;
@ -12,7 +12,9 @@ use crate::components::{CARD_GRID_H, CardLayout, GRID_GAP, GRID_MIN_W, PackageEn
use crate::hooks::{package_meta_batch, use_game_snapshot, use_package_meta_batch, use_view_state};
mod views;
use views::{ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, toolbar_bar};
use views::{
AdvancedSection, ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, toolbar_bar,
};
const CARD_H: f32 = 84.;
const CARD_SPACING: f32 = 8.;
@ -119,10 +121,14 @@ pub fn bundle_packages(
// Hidden is per-bundle so one bundle carrying a mod as a private dependency must not suppress a bundle that offers it openly
let mut shown_elsewhere: HashSet<String> = HashSet::new();
let mut normal_elsewhere: HashSet<String> = HashSet::new();
for bundle in bundles {
for (file, _status) in &bundle.files {
if !file.hidden && file.content_type() == content_type {
shown_elsewhere.insert(file.kind.package_id());
if file.file_type == BundleFileType::Normal {
normal_elsewhere.insert(file.kind.package_id());
}
}
}
}
@ -172,13 +178,17 @@ pub fn bundle_packages(
None => oneclient_core::effective_enabled(file, ov.and_then(OverrideType::parse)),
};
let advanced = content_type == ContentType::Mod
&& file.file_type == BundleFileType::Advanced
&& !normal_elsewhere.contains(&pid);
let categories = if category.is_empty() {
Vec::new()
} else {
vec![category.clone()]
};
rows.push(make_row(
let mut row = make_row(
pid,
Some(bundle_name.clone()),
provider,
@ -193,7 +203,9 @@ pub fn bundle_packages(
false,
// Flagged rather than dropped `HiddenFilter` filters on the row and the seen id stops the loose-content pass resurrecting it as a local file
file.hidden,
));
);
row.advanced = advanced;
rows.push(row);
}
}
@ -281,6 +293,7 @@ fn make_row(
hash: installed_info.map(|i| i.hash.clone()),
update_available,
hidden,
advanced: false,
seen_status: installed_info.map(|i| i.seen_status).unwrap_or_default(),
}
}
@ -444,6 +457,7 @@ impl Component for PackageManager {
let search = use_state(String::new);
let enabled_filter = use_state(|| EnabledFilter::All);
let hidden_filter = use_state(|| HiddenFilter::Hide);
let advanced_open = use_state(|| false);
let toolbar_width = use_state(|| 0f32);
let view = use_view_state("cluster.packages");
let sort = view.sort;
@ -486,6 +500,8 @@ impl Component for PackageManager {
}
};
let (advanced, filtered): (Vec<_>, Vec<_>) = filtered.into_iter().partition(|p| p.advanced);
rect()
.vertical()
.width(Size::fill())
@ -512,6 +528,11 @@ impl Component for PackageManager {
.maybe_child(session_live.then(|| views::running_notice(noun_plural, content_type)))
.child(ContentBox::new(
filtered,
advanced,
AdvancedSection {
open: advanced_open,
forced: !query.is_empty(),
},
noun_plural,
package_type,
content_type,

View file

@ -6,7 +6,7 @@ use oneclient_content::packages::ContentType;
use oneclient_core::settings::ViewLayout;
use crate::components::{
Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, Segment,
Button, CardLayout, Icon, IconType, LazySection, PackageEntry, PackageRow, ScrollArea, Segment,
SegmentedControl, TextInput, package_context_menu, use_shared_delete,
};
use crate::hooks::{ClusterAction, use_cluster_mutation, use_dispatch, use_overlay_claim};
@ -695,9 +695,84 @@ pub(super) enum ContentKind {
},
}
const SECTION_HEADER_H: f32 = 36.;
#[derive(Clone, Copy, PartialEq)]
pub(super) struct AdvancedSection {
pub(super) open: State<bool>,
pub(super) forced: bool,
}
impl AdvancedSection {
fn expanded(self) -> bool {
self.forced || *self.open.read()
}
}
#[derive(PartialEq)]
struct SectionHeader {
label: &'static str,
count: usize,
section: AdvancedSection,
}
impl Component for SectionHeader {
fn render(&self) -> impl IntoElement {
let mut hovered = use_state(|| false);
let section = self.section;
let mut open = section.open;
let expanded = section.expanded();
let interactive = !section.forced;
rect()
.horizontal()
.width(Size::fill())
.height(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.padding(Gaps::new_symmetric(0., 12.))
.corner_radius(CornerRadius::new_all(10.))
.background(if interactive && *hovered.read() {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
})
.maybe(interactive, |el| {
el.cursor(CursorIcon::Pointer)
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| hovered.set(false))
.on_press(move |_| open.toggle())
})
.child(
Icon::new(if expanded {
IconType::ChevronDown
} else {
IconType::ChevronRight
})
.size(14.)
.color(colors::fg_secondary()),
)
.child(
label()
.text(self.label)
.font_size(13.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
label()
.text(self.count.to_string())
.font_size(12.)
.color(colors::fg_secondary()),
)
}
}
#[derive(PartialEq)]
pub(super) struct ContentBox {
items: Vec<PackageEntry>,
advanced: Vec<PackageEntry>,
section: AdvancedSection,
noun_plural: &'static str,
package_type: &'static str,
content_type: ContentType,
@ -710,6 +785,8 @@ impl ContentBox {
#[allow(clippy::too_many_arguments)]
pub(super) fn new(
items: Vec<PackageEntry>,
advanced: Vec<PackageEntry>,
section: AdvancedSection,
noun_plural: &'static str,
package_type: &'static str,
content_type: ContentType,
@ -719,6 +796,8 @@ impl ContentBox {
) -> Self {
Self {
items,
advanced,
section,
noun_plural,
package_type,
content_type,
@ -731,7 +810,14 @@ impl ContentBox {
impl Component for ContentBox {
fn render(&self) -> impl IntoElement {
let items = self.items.clone();
let section = self.section;
let expanded = section.expanded();
let advanced_count = self.advanced.len();
let mut items = self.items.clone();
let normal_count = items.len();
if expanded {
items.extend(self.advanced.iter().cloned());
}
let package_type = self.package_type;
let content_type = self.content_type;
let cluster_id = self.cluster_id;
@ -760,19 +846,44 @@ impl Component for ContentBox {
}
};
let count = items.len();
let count = normal_count + advanced_count;
let scroll = (count > 0).then(|| {
let area = ScrollArea::new()
let mut sections = vec![LazySection {
header: false,
count: normal_count,
}];
if advanced_count > 0 {
sections.push(LazySection {
header: true,
count: if expanded { advanced_count } else { 0 },
});
}
let (item_height, gap, min_width, max_cols) = match layout {
CardLayout::List => (CARD_H, CARD_SPACING, 0., 1),
CardLayout::Grid => (CARD_GRID_H, GRID_GAP, GRID_MIN_W, GRID_MAX_COLS),
};
ScrollArea::new()
.width(Size::fill())
.height(Size::fill())
.scrollbar_gutter(true);
match layout {
CardLayout::List => area.lazy(count, CARD_H, CARD_SPACING, row),
CardLayout::Grid => {
area.lazy_grid(count, CARD_GRID_H, GRID_GAP, GRID_MIN_W, GRID_MAX_COLS, row)
}
}
.into_element()
.scrollbar_gutter(true)
.lazy_sections(
sections,
item_height,
gap,
min_width,
max_cols,
SECTION_HEADER_H,
row,
move |_| {
SectionHeader {
label: "Advanced",
count: advanced_count,
section,
}
.into_element()
},
)
.into_element()
});
let menu_overlay = menu.read().clone().map(|(x, y, item)| {

View file

@ -16,8 +16,8 @@ use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::view::onboarding::{
archive_selected, choice_row_sized, is_default_bundle, is_optional_file, onboarding_nav,
onboarding_slide, pkg_key, set_archive_selected, step_heading, version_chip,
archive_selected, choice_row_sized, is_default_bundle, onboarding_nav, onboarding_slide,
pkg_key, set_archive_selected, step_heading, version_chip,
};
mod row;
@ -173,7 +173,7 @@ fn optional_mods(clusters: &[ClusterBundles]) -> Vec<OptionalMod> {
.manifest
.files
.iter()
.filter(|f| is_optional_file(f))
.filter(|f| f.is_optional_offer())
{
let package_id = file.kind.package_id();
let entry = map.entry(package_id.clone()).or_insert_with(|| {

View file

@ -12,8 +12,7 @@ pub(crate) mod test_support;
mod welcome;
pub(crate) use selection::{
archive_selected, default_selection, is_default_bundle, is_optional_file, pkg_key,
set_archive_selected,
archive_selected, default_selection, is_default_bundle, pkg_key, set_archive_selected,
};
pub use account::OnboardingAccount;

View file

@ -16,10 +16,6 @@ pub fn is_default_file(file: &BundleFile) -> bool {
file.enabled && !file.hidden
}
pub fn is_optional_file(file: &BundleFile) -> bool {
!file.enabled && !file.hidden
}
pub fn default_selection(
items: &[ClusterBundles],
opted_in_categories: Option<&[String]>,

View file

@ -1,7 +1,9 @@
use oneclient_common::domain::{ContentType, GameLoader};
use oneclient_content::packages::types::ExternalFile;
use oneclient_core::clusters::{Cluster, ClusterStage};
use oneclient_core::{Bundle, BundleArchive, BundleFile, BundleFileKind, BundleManifest};
use oneclient_core::{
Bundle, BundleArchive, BundleFile, BundleFileKind, BundleFileType, BundleManifest,
};
use std::path::PathBuf;
pub fn file(package_id: &str, enabled: bool, hidden: bool) -> BundleFile {
@ -10,6 +12,7 @@ pub fn file(package_id: &str, enabled: bool, hidden: bool) -> BundleFile {
hidden,
path: format!("mods/{package_id}.jar"),
size: 1,
file_type: BundleFileType::Normal,
kind: BundleFileKind::External {
file: ExternalFile {
name: format!("{package_id}.jar"),

View file

@ -896,6 +896,7 @@ pub async fn delete_artifact(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> C
#[cfg(test)]
mod tests {
use super::*;
use crate::bundles::types::BundleFileType;
use oneclient_common::domain::ContentType;
use oneclient_db::models::ClusterBundleOverrideRow;
@ -905,6 +906,7 @@ mod tests {
hidden: false,
path: "mods/example.jar".to_string(),
size: 1,
file_type: BundleFileType::Normal,
kind: BundleFileKind::External {
file: ExternalFile {
name: "example.jar".to_string(),

View file

@ -24,9 +24,9 @@ pub use optional::{
PendingOptionalMod, pending_optional_mods, resolve_optional_mods, skip_optional_mods,
};
pub use types::{
ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleManifest,
BundleOptionalPackage, BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate,
BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus,
ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleFileType,
BundleManifest, BundleOptionalPackage, BundlePackageAddition, BundlePackageRemoval,
BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus,
};
pub use updates::{
apply_bundle_updates, apply_bundle_updates_with, check_bundle_updates,

View file

@ -51,6 +51,10 @@ pub async fn pending_optional_mods(
continue;
};
if !file.is_optional_offer() {
continue;
}
// The queue row can outlive the offer the user may have opted in from
// the package manager since it was written
let user_override = overrides

View file

@ -6,7 +6,8 @@ use serde::Deserialize;
use crate::bundles::error::BundleError;
use crate::bundles::types::{
BundleFile, BundleFileKind, BundleManifest, ExternalFileMeta, content_type_from_bundle_path,
BundleFile, BundleFileKind, BundleFileType, BundleManifest, ExternalFileMeta,
content_type_from_bundle_path,
};
use crate::error::ContentResult;
use crate::packages::types::ExternalFile;
@ -49,6 +50,8 @@ struct PolyMrpackFile {
pub enabled: bool,
#[serde(default)]
pub hidden: bool,
#[serde(rename = "type", default)]
pub file_type: Option<BundleFileType>,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
@ -156,6 +159,7 @@ fn parse_bundle_file(file: &PolyMrpackFile) -> Option<BundleFile> {
hidden: file.hidden,
path: file.path.clone(),
size: file.file_size,
file_type: file.file_type.unwrap_or_default(),
kind: BundleFileKind::Managed {
provider: ProviderId::Modrinth,
project_id: paths[0].to_string(),
@ -181,6 +185,7 @@ fn parse_bundle_file(file: &PolyMrpackFile) -> Option<BundleFile> {
hidden: file.hidden,
path: file.path.clone(),
size: file.file_size,
file_type: file.file_type.unwrap_or_default(),
kind: BundleFileKind::External {
file: ExternalFile {
name: file_name,

View file

@ -30,10 +30,25 @@ pub struct BundleFile {
pub path: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub file_type: BundleFileType,
pub kind: BundleFileKind,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BundleFileType {
Advanced,
#[default]
#[serde(other)]
Normal,
}
impl BundleFile {
pub fn is_optional_offer(&self) -> bool {
!self.enabled && !self.hidden && self.file_type == BundleFileType::Normal
}
pub fn content_type(&self) -> ContentType {
if let BundleFileKind::External { file, .. } = &self.kind {
return file.content_type;

View file

@ -356,7 +356,7 @@ async fn check_bundle_updates_inner(
if !crate::bundles::effective_enabled(file, user_override) {
if user_override.is_none()
&& !file.hidden
&& file.is_optional_offer()
&& !optional_available.iter().any(|(key, _)| *key == file_key)
{
optional_available.push((

View file

@ -43,15 +43,15 @@ pub use oneclient_cluster::screenshots::{
};
pub use oneclient_content::bundles::{
ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, BundleFileKind,
BundleManifest, BundleOptionalPackage, BundleUpdateCheckResult, BundleWithUpdateStatus,
BundlesManager, FileUpdateStatus, PendingOptionalMod, apply_bundle_updates,
apply_bundle_updates_with, check_bundle_updates, cluster_has_bundle_content,
clusters_sharing_artifact, delete_artifact, effective_enabled, get_bundles_with_update_status,
install_bundle, install_cluster_bundles, install_package_from_bundle,
list_cluster_bundle_overrides, pending_optional_mods, remove_artifact_from_cluster,
resolve_optional_mods, set_artifact_enabled_to, set_bundle_package_enabled,
set_bundle_package_opt_in, set_bundle_package_override, set_bundle_package_overrides,
skip_optional_mods,
BundleFileType, BundleManifest, BundleOptionalPackage, BundleUpdateCheckResult,
BundleWithUpdateStatus, BundlesManager, FileUpdateStatus, PendingOptionalMod,
apply_bundle_updates, apply_bundle_updates_with, check_bundle_updates,
cluster_has_bundle_content, clusters_sharing_artifact, delete_artifact, effective_enabled,
get_bundles_with_update_status, install_bundle, install_cluster_bundles,
install_package_from_bundle, list_cluster_bundle_overrides, pending_optional_mods,
remove_artifact_from_cluster, resolve_optional_mods, set_artifact_enabled_to,
set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override,
set_bundle_package_overrides, skip_optional_mods,
};
pub use oneclient_content::packages::LinkedArtifactInfo;
pub use oneclient_content::packages::updates::{

View file

@ -1,6 +1,6 @@
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
use oneclient_content::bundles::{
BundleFile, BundleFileKind, BundleManifest, check_bundle_updates,
BundleFile, BundleFileKind, BundleFileType, BundleManifest, check_bundle_updates,
};
use oneclient_core::LauncherState;
use oneclient_core::clusters::CreateClusterOptions;
@ -19,6 +19,7 @@ fn managed_file(enabled: bool) -> BundleFile {
hidden: false,
path: "mods/sodium.jar".to_string(),
size: 1,
file_type: BundleFileType::Normal,
kind: BundleFileKind::Managed {
provider: ProviderId::Modrinth,
project_id: PROJECT_ID.to_string(),
@ -35,6 +36,7 @@ fn newly_shipped_file() -> BundleFile {
hidden: false,
path: "mods/newcomer.jar".to_string(),
size: 1,
file_type: BundleFileType::Normal,
kind: BundleFileKind::Managed {
provider: ProviderId::Modrinth,
project_id: "newcomer".to_string(),