diff options
| author | salmonik <raisersoft@proton.me> | 2026-07-11 13:32:48 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-07-11 13:32:48 +0200 |
| commit | 7df4ceb1cf2a6edc058d28527140598ca90e1f10 (patch) | |
| tree | c8ade0334e962f0089ef4535cb07d5feaf6b857a /src/gui.rs | |
| download | factorio-mod-downloader-7df4ceb1cf2a6edc058d28527140598ca90e1f10.tar.gz factorio-mod-downloader-7df4ceb1cf2a6edc058d28527140598ca90e1f10.zip | |
initmain
Diffstat (limited to '')
| -rw-r--r-- | src/gui.rs | 369 |
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 @@ | |||
| 1 | use std::path::PathBuf; | ||
| 2 | use std::sync::mpsc; | ||
| 3 | use std::sync::mpsc::TryRecvError; | ||
| 4 | use std::sync::{Arc, Mutex}; | ||
| 5 | use std::thread; | ||
| 6 | |||
| 7 | use egui::{Color32, ProgressBar, ScrollArea, TextEdit}; | ||
| 8 | use rfd::FileDialog; | ||
| 9 | |||
| 10 | use crate::downloader::{self, Event}; | ||
| 11 | |||
| 12 | pub 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 | |||
| 31 | struct DownloadState { | ||
| 32 | name: String, | ||
| 33 | total_bytes: u64, | ||
| 34 | downloaded_bytes: u64, | ||
| 35 | complete: bool, | ||
| 36 | error: Option<String>, | ||
| 37 | } | ||
| 38 | |||
| 39 | struct LogEntry { | ||
| 40 | text: String, | ||
| 41 | is_error: bool, | ||
| 42 | } | ||
| 43 | |||
| 44 | impl 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 | |||
| 196 | impl 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 | } | ||
