Skip to main content

modde_ui/views/
downloads.rs

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