Skip to main content

modde_ui/views/
downloads.rs

1use std::path::PathBuf;
2
3use crate::views::selectable_text::text;
4use iced::widget::{button, column, container, progress_bar, row, scrollable};
5use iced::{Alignment, Element, Length, color};
6
7use crate::action_button::{ButtonAction, DescribedButtonExt};
8use crate::app::Message;
9
10/// Download state for UI display.
11#[derive(Debug, Clone)]
12pub enum DownloadState {
13    Queued,
14    Active {
15        bytes_downloaded: u64,
16        total_bytes: Option<u64>,
17    },
18    Paused {
19        bytes_downloaded: u64,
20        total_bytes: Option<u64>,
21    },
22    Complete {
23        path: PathBuf,
24    },
25    Failed {
26        error: String,
27    },
28}
29
30/// A download task for UI display.
31#[derive(Debug, Clone)]
32pub struct DownloadTask {
33    pub id: usize,
34    pub name: String,
35    pub state: DownloadState,
36}
37
38/// Render the downloads view.
39#[must_use]
40pub fn view(tasks: &[DownloadTask]) -> Element<'static, Message> {
41    let title_bar = row![
42        text("Downloads").size(20),
43        iced::widget::space::horizontal(),
44    ]
45    .align_y(Alignment::Center);
46
47    if tasks.is_empty() {
48        let empty = container(text("No downloads").size(14))
49            .padding(20)
50            .width(Length::Fill)
51            .center_x(Length::Fill);
52
53        return column![title_bar, iced::widget::rule::horizontal(1), empty]
54            .spacing(8)
55            .padding(16)
56            .width(Length::Fill)
57            .height(Length::Fill)
58            .into();
59    }
60
61    let items: Vec<Element<Message>> = tasks
62        .iter()
63        .map(|task| {
64            let name = text(task.name.clone()).size(14);
65
66            let (status_text, status_color) = match &task.state {
67                DownloadState::Queued => ("Queued", color!(0xAAAAFF)),
68                DownloadState::Active { .. } => ("Downloading", color!(0x88CC88)),
69                DownloadState::Paused { .. } => ("Paused", color!(0xFFAA44)),
70                DownloadState::Complete { .. } => ("Complete", color!(0x44CC44)),
71                DownloadState::Failed { .. } => ("Failed", color!(0xFF4444)),
72            };
73
74            let status = text(status_text).size(12).color(status_color);
75
76            // Progress bar for active/paused
77            let progress_widget: Element<Message> = match &task.state {
78                DownloadState::Active {
79                    bytes_downloaded,
80                    total_bytes,
81                }
82                | DownloadState::Paused {
83                    bytes_downloaded,
84                    total_bytes,
85                } => {
86                    let ratio = total_bytes.map_or(0.0, |t| {
87                        if t > 0 {
88                            *bytes_downloaded as f32 / t as f32
89                        } else {
90                            0.0
91                        }
92                    });
93                    let pct = format!("{:.0}%", ratio * 100.0);
94                    let speed_info = if let DownloadState::Active {
95                        bytes_downloaded, ..
96                    } = &task.state
97                    {
98                        format!(" ({} downloaded)", format_bytes(*bytes_downloaded))
99                    } else {
100                        String::new()
101                    };
102                    column![
103                        progress_bar(0.0..=1.0, ratio),
104                        text(format!("{pct}{speed_info}")).size(11),
105                    ]
106                    .spacing(2)
107                    .into()
108                }
109                _ => text("").into(),
110            };
111
112            // Action buttons
113            let actions: Element<Message> = match &task.state {
114                DownloadState::Active { .. } => row![
115                    button(text("Pause").size(11))
116                        .style(button::secondary)
117                        .padding([3, 8])
118                        .on_action(ButtonAction::PauseDownload(task.id)),
119                    button(text("Cancel").size(11))
120                        .style(button::danger)
121                        .padding([3, 8])
122                        .on_action(ButtonAction::CancelDownload(task.id)),
123                ]
124                .spacing(4)
125                .into(),
126                DownloadState::Paused { .. } => row![
127                    button(text("Resume").size(11))
128                        .style(button::success)
129                        .padding([3, 8])
130                        .on_action(ButtonAction::ResumeDownload(task.id)),
131                    button(text("Cancel").size(11))
132                        .style(button::danger)
133                        .padding([3, 8])
134                        .on_action(ButtonAction::CancelDownload(task.id)),
135                ]
136                .spacing(4)
137                .into(),
138                DownloadState::Queued => button(text("Cancel").size(11))
139                    .style(button::danger)
140                    .padding([3, 8])
141                    .on_action(ButtonAction::CancelDownload(task.id)),
142                DownloadState::Failed { error } => column![
143                    text(format!("Error: {error}"))
144                        .size(11)
145                        .color(color!(0xFF4444)),
146                    button(text("Retry").size(11))
147                        .style(button::secondary)
148                        .padding([3, 8])
149                        .on_action(ButtonAction::ResumeDownload(task.id)),
150                ]
151                .spacing(2)
152                .into(),
153                DownloadState::Complete { .. } => text("").into(),
154            };
155
156            container(column![row![name, status].spacing(8), progress_widget, actions,].spacing(4))
157                .padding(8)
158                .width(Length::Fill)
159                .style(container::rounded_box)
160                .into()
161        })
162        .collect();
163
164    let list = scrollable(column(items).spacing(8)).height(Length::Fill);
165
166    column![title_bar, iced::widget::rule::horizontal(1), list]
167        .spacing(8)
168        .padding(16)
169        .width(Length::Fill)
170        .height(Length::Fill)
171        .into()
172}
173
174fn format_bytes(bytes: u64) -> String {
175    if bytes < 1024 {
176        format!("{bytes} B")
177    } else if bytes < 1024 * 1024 {
178        format!("{:.1} KB", bytes as f64 / 1024.0)
179    } else if bytes < 1024 * 1024 * 1024 {
180        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
181    } else {
182        format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
183    }
184}