summaryrefslogtreecommitdiffstats
path: root/src/gui.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/gui.rs')
-rw-r--r--src/gui.rs369
1 files changed, 369 insertions, 0 deletions
diff --git a/src/gui.rs b/src/gui.rs
new file mode 100644
index 0000000..5fe1092
--- /dev/null
+++ b/src/gui.rs
@@ -0,0 +1,369 @@
1use std::path::PathBuf;
2use std::sync::mpsc;
3use std::sync::mpsc::TryRecvError;
4use std::sync::{Arc, Mutex};
5use std::thread;
6
7use egui::{Color32, ProgressBar, ScrollArea, TextEdit};
8use rfd::FileDialog;
9
10use crate::downloader::{self, Event};
11
12pub struct App {
13 mod_url: String,
14 output_path: String,
15 target_version: String,
16 include_optional: bool,
17
18 versions: Vec<(String, String)>,
19 loading_versions: bool,
20 version_handle: Option<Arc<Mutex<Option<Vec<(String, String)>>>>>,
21
22 downloading: bool,
23 finished: bool,
24
25 downloads: Vec<DownloadState>,
26 log_entries: Vec<LogEntry>,
27
28 receiver: Option<mpsc::Receiver<Event>>,
29}
30
31struct DownloadState {
32 name: String,
33 total_bytes: u64,
34 downloaded_bytes: u64,
35 complete: bool,
36 error: Option<String>,
37}
38
39struct LogEntry {
40 text: String,
41 is_error: bool,
42}
43
44impl App {
45 pub fn new() -> Self {
46 Self {
47 mod_url: String::new(),
48 output_path: std::env::current_dir()
49 .map(|p| p.display().to_string())
50 .unwrap_or_default(),
51 target_version: String::new(),
52 include_optional: false,
53 versions: Vec::new(),
54 loading_versions: false,
55 version_handle: None,
56 downloading: false,
57 finished: false,
58 downloads: Vec::new(),
59 log_entries: Vec::new(),
60 receiver: None,
61 }
62 }
63
64 fn log(&mut self, text: String, is_error: bool) {
65 self.log_entries.push(LogEntry { text, is_error });
66 if self.log_entries.len() > 500 {
67 self.log_entries.remove(0);
68 }
69 }
70
71 fn find_or_create_download(&mut self, name: &str) -> &mut DownloadState {
72 if let Some(pos) = self.downloads.iter().position(|d| d.name == name) {
73 &mut self.downloads[pos]
74 } else {
75 self.downloads.push(DownloadState {
76 name: name.to_string(),
77 total_bytes: 0,
78 downloaded_bytes: 0,
79 complete: false,
80 error: None,
81 });
82 self.downloads.last_mut().unwrap()
83 }
84 }
85
86 fn poll_events(&mut self) {
87 let mut events = Vec::new();
88 if let Some(ref rx) = self.receiver {
89 loop {
90 match rx.try_recv() {
91 Ok(event) => events.push(event),
92 Err(TryRecvError::Empty) => break,
93 Err(TryRecvError::Disconnected) => {
94 self.downloading = false;
95 self.receiver = None;
96 break;
97 }
98 }
99 }
100 }
101 for event in events {
102 self.handle_event(event);
103 }
104 }
105
106 fn handle_event(&mut self, event: Event) {
107 match event {
108 Event::Log(text) => self.log(text, false),
109 Event::DownloadStart { name, total_bytes } => {
110 self.log(format!("Downloading: {name}"), false);
111 let dl = self.find_or_create_download(&name);
112 dl.total_bytes = total_bytes;
113 dl.downloaded_bytes = 0;
114 dl.complete = false;
115 dl.error = None;
116 }
117 Event::DownloadProgress { name, bytes } => {
118 let dl = self.find_or_create_download(&name);
119 dl.downloaded_bytes = bytes;
120 }
121 Event::DownloadComplete { name } => {
122 let dl = self.find_or_create_download(&name);
123 dl.complete = true;
124 dl.downloaded_bytes = dl.total_bytes;
125 self.log(format!("Downloaded: {name}"), false);
126 }
127 Event::DownloadError { name, error } => {
128 let dl = self.find_or_create_download(&name);
129 dl.error = Some(error.clone());
130 self.log(format!("Error: {name} - {error}"), true);
131 }
132 Event::Finished { success, message } => {
133 self.log(message.clone(), !success);
134 self.downloading = false;
135 self.finished = true;
136 self.receiver = None;
137 }
138 }
139 }
140
141 fn start_download(&mut self) {
142 let mod_url = self.mod_url.trim().to_string();
143 if mod_url.is_empty() {
144 return;
145 }
146
147 let output = PathBuf::from(&self.output_path);
148 let target_version = if self.target_version.trim().is_empty() {
149 None
150 } else {
151 Some(self.target_version.trim().to_string())
152 };
153 let include_optional = self.include_optional;
154
155 let (tx, rx) = mpsc::channel();
156 self.receiver = Some(rx);
157 self.downloading = true;
158 self.finished = false;
159 self.downloads.clear();
160 self.log_entries.clear();
161
162 self.log("Starting download...".into(), false);
163
164 thread::spawn(move || {
165 downloader::run_gui_download(&mod_url, &output, target_version, include_optional, tx);
166 });
167 }
168
169 fn load_versions(&mut self) {
170 let mod_url = self.mod_url.trim().to_string();
171 if mod_url.is_empty() {
172 return;
173 }
174
175 self.loading_versions = true;
176 let result: Arc<Mutex<Option<Vec<(String, String)>>>> = Arc::new(Mutex::new(None));
177 self.version_handle = Some(result.clone());
178
179 thread::spawn(move || {
180 let name = downloader::extract_mod_name(&mod_url);
181 let versions = match name {
182 Ok(name) => downloader::fetch_versions(&name).ok().unwrap_or_default(),
183 Err(_) => Vec::new(),
184 };
185 *result.lock().unwrap() = Some(versions);
186 });
187 }
188
189 fn browse_output(&mut self) {
190 if let Some(path) = FileDialog::new().pick_folder() {
191 self.output_path = path.display().to_string();
192 }
193 }
194}
195
196impl eframe::App for App {
197 fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
198 self.poll_events();
199
200 // Check version loading result
201 if self.loading_versions {
202 let result = if let Some(ref handle) = self.version_handle {
203 handle.lock().ok().and_then(|mut g| g.take())
204 } else {
205 None
206 };
207 if let Some(versions) = result {
208 self.versions = versions;
209 self.loading_versions = false;
210 self.version_handle = None;
211 }
212 }
213
214 if self.downloading {
215 ctx.request_repaint();
216 }
217 }
218
219 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
220 ui.heading("Factorio Mod Downloader");
221 ui.separator();
222
223 // ── Input section ──
224 ui.horizontal(|ui| {
225 ui.label("Mod URL:");
226 ui.add_sized(
227 [ui.available_width() - 10.0, 20.0],
228 TextEdit::singleline(&mut self.mod_url)
229 .hint_text("https://mods.factorio.com/mod/... or mod name"),
230 );
231 });
232
233 ui.horizontal(|ui| {
234 ui.label("Output:");
235 ui.add_sized(
236 [ui.available_width() - 80.0, 20.0],
237 TextEdit::singleline(&mut self.output_path).hint_text("Download directory"),
238 );
239 if ui.button("Browse").clicked() {
240 self.browse_output();
241 }
242 });
243
244 ui.horizontal(|ui| {
245 ui.label("Version:");
246 let version_enabled = !self.downloading && !self.versions.is_empty();
247 ui.add_enabled_ui(version_enabled, |ui| {
248 egui::ComboBox::from_id_salt("version_combo")
249 .width(200.0)
250 .selected_text(if self.target_version.is_empty() {
251 "Latest"
252 } else {
253 &self.target_version
254 })
255 .show_ui(ui, |ui| {
256 if ui.selectable_label(false, "Latest").clicked() {
257 self.target_version.clear();
258 }
259 for (ver, label) in &self.versions {
260 if ui.selectable_label(self.target_version == *ver, label).clicked() {
261 self.target_version = ver.clone();
262 }
263 }
264 });
265 });
266
267 if ui
268 .add_enabled(
269 !self.downloading && !self.loading_versions,
270 egui::Button::new(if self.loading_versions {
271 "Loading..."
272 } else {
273 "Load Versions"
274 }),
275 )
276 .clicked()
277 {
278 self.load_versions();
279 }
280
281 ui.checkbox(&mut self.include_optional, "Include optional deps");
282 });
283
284 ui.add_space(8.0);
285
286 // ── Download button ──
287 let can_download = !self.downloading
288 && !self.mod_url.trim().is_empty()
289 && !self.output_path.is_empty();
290
291 if ui
292 .add_enabled(
293 can_download,
294 egui::Button::new(if self.downloading {
295 "Downloading..."
296 } else if self.finished {
297 "Download Again"
298 } else {
299 "Start Download"
300 }),
301 )
302 .clicked()
303 {
304 self.start_download();
305 }
306
307 ui.add_space(8.0);
308
309 // ── Progress section ──
310 if !self.downloads.is_empty() {
311 egui::CollapsingHeader::new("Downloads")
312 .default_open(true)
313 .show(ui, |ui| {
314 for dl in &self.downloads {
315 let percentage = if dl.total_bytes > 0 {
316 dl.downloaded_bytes as f32 / dl.total_bytes as f32
317 } else {
318 0.0
319 };
320
321 ui.horizontal(|ui| {
322 let status = if dl.complete {
323 "[OK]"
324 } else if dl.error.is_some() {
325 "[ERR]"
326 } else {
327 "[>>]"
328 };
329 ui.label(format!("{status} {}", dl.name));
330
331 if let Some(ref err) = dl.error {
332 ui.colored_label(Color32::RED, format!("Error: {err}"));
333 } else if dl.total_bytes > 0 {
334 ui.label(format!(
335 "{:.1} / {:.1} MB",
336 dl.downloaded_bytes as f64 / 1_048_576.0,
337 dl.total_bytes as f64 / 1_048_576.0
338 ));
339 }
340 });
341
342 let bar =
343 ProgressBar::new(percentage).desired_width(ui.available_width());
344 ui.add(bar);
345 }
346 });
347 }
348
349 ui.add_space(8.0);
350
351 // ── Log section ──
352 egui::CollapsingHeader::new("Log")
353 .default_open(true)
354 .show(ui, |ui| {
355 ScrollArea::vertical()
356 .max_height(300.0)
357 .stick_to_bottom(true)
358 .show(ui, |ui| {
359 for entry in &self.log_entries {
360 if entry.is_error {
361 ui.colored_label(Color32::RED, &entry.text);
362 } else {
363 ui.label(&entry.text);
364 }
365 }
366 });
367 });
368 }
369}