Skip to main content

modde_ui/views/
collections.rs

1use iced::widget::{
2    button, column, container, progress_bar, row, scrollable, text, text_input,
3};
4use iced::{Alignment, Element, Length};
5
6use modde_core::manifest::collection::CollectionManifest;
7
8use crate::app::Message;
9
10/// State for an in-progress collection download.
11#[derive(Debug, Clone)]
12pub struct CollectionDownload {
13    pub slug: String,
14    pub bytes_downloaded: u64,
15    pub bytes_total: u64,
16}
17
18impl CollectionDownload {
19    pub fn progress_fraction(&self) -> f32 {
20        if self.bytes_total == 0 {
21            0.0
22        } else {
23            self.bytes_downloaded as f32 / self.bytes_total as f32
24        }
25    }
26}
27
28/// Render the collections browser view.
29pub fn view<'a>(
30    search_query: &'a str,
31    collections: &'a [CollectionManifest],
32    active_downloads: &'a [CollectionDownload],
33) -> Element<'a, Message> {
34    let title_bar = row![
35        text("Nexus Collections").size(20),
36        iced::widget::space::horizontal(),
37    ]
38    .align_y(Alignment::Center);
39
40    let search_bar = text_input("Search collections...", search_query)
41        .on_input(Message::SearchCollections)
42        .on_submit(Message::SearchCollections(search_query.to_string()))
43        .padding(8)
44        .width(Length::Fill);
45
46    let content: Element<Message> = if collections.is_empty() {
47        container(
48            text("No collections loaded. Enter a search term above to browse Nexus Collections.")
49                .size(14),
50        )
51        .padding(20)
52        .width(Length::Fill)
53        .center_x(Length::Fill)
54        .into()
55    } else {
56        let cards = collections
57            .iter()
58            .fold(column![].spacing(8), |col, collection| {
59                let download_state = active_downloads
60                    .iter()
61                    .find(|d| d.slug == collection.slug);
62
63                let card = collection_card(collection, download_state);
64                col.push(card)
65            });
66
67        scrollable(cards).height(Length::Fill).into()
68    };
69
70    column![title_bar, search_bar, iced::widget::rule::horizontal(1), content,]
71        .spacing(8)
72        .padding(16)
73        .width(Length::Fill)
74        .height(Length::Fill)
75        .into()
76}
77
78fn collection_card<'a>(
79    collection: &'a CollectionManifest,
80    download: Option<&'a CollectionDownload>,
81) -> Element<'a, Message> {
82    let mod_count = collection.mods.len();
83    let endorsements = collection.endorsements;
84
85    let header = row![
86        text(&collection.name).size(16),
87        iced::widget::space::horizontal(),
88        text(format!("v{}", collection.version.version)).size(12),
89    ]
90    .align_y(Alignment::Center);
91
92    let meta = row![
93        text(format!("by {}", collection.author.name)).size(12),
94        text(" | ").size(12),
95        text(format!("{mod_count} mod(s)")).size(12),
96        text(" | ").size(12),
97        text(format!("{endorsements} endorsement(s)")).size(12),
98    ]
99    .spacing(0);
100
101    let summary = collection
102        .summary
103        .as_deref()
104        .unwrap_or("No description available.");
105    let description = text(summary).size(13);
106
107    let action_row: Element<Message> = if let Some(dl) = download {
108        let pct = dl.progress_fraction() * 100.0;
109        column![
110            progress_bar(0.0..=100.0, pct).girth(8),
111            text(format!(
112                "Downloading: {:.1}% ({} / {} bytes)",
113                pct, dl.bytes_downloaded, dl.bytes_total
114            ))
115            .size(11),
116        ]
117        .spacing(4)
118        .into()
119    } else {
120        let slug = collection.slug.clone();
121        let version = collection.version.version.clone();
122        button(text("Install").size(14))
123            .on_press(Message::InstallCollection { slug, version })
124            .style(button::primary)
125            .padding([6, 14])
126            .into()
127    };
128
129    container(
130        column![header, meta, description, action_row,]
131            .spacing(6)
132            .padding(12),
133    )
134    .width(Length::Fill)
135    .style(container::rounded_box)
136    .into()
137}