Skip to main content

launcher/app/
ui.rs

1use super::LauncherApp;
2use crate::pages::trainer::TrainerPageAction;
3use crate::pages::{
4    audio, display_page, language_page, mods_page, music_page, replays_page, riders_page,
5    snowboards_page, tournament_play_selection, trainer_page, troubleshoot_page,
6};
7use crate::state::{OperationType, Page};
8use crate::updater::UpdateStatus;
9use crate::widgets::nav_button::nav_button;
10use crate::widgets::nav_group_button::{NavGroupItem, nav_group_button};
11use crate::widgets::play_quit_button::{FooterAction, play_quit_button};
12use eframe::egui;
13
14impl LauncherApp {
15    pub fn update_unified_operations_ui(&mut self) {
16        self.update_unified_operations();
17    }
18
19    pub fn show_sidebar(&mut self, ui: &mut egui::Ui, update_status: &UpdateStatus) {
20        ui.add_space(4.0);
21
22        let logo_resp = ui
23            .add(
24                egui::Image::new(egui::include_image!("../../../icon.svg"))
25                    .max_width(ui.available_width())
26                    .sense(egui::Sense::click()),
27            )
28            .on_hover_cursor(egui::CursorIcon::PointingHand);
29
30        let badge_count = self.news_manager.badge_count();
31        if badge_count > 0 {
32            let badge_radius = 8.0;
33            let badge_center = egui::pos2(logo_resp.rect.right() - 6.0, logo_resp.rect.top() + 6.0);
34
35            ui.painter()
36                .circle_filled(badge_center, badge_radius, ui.visuals().warn_fg_color);
37
38            let text_color = egui::Color32::WHITE;
39            let text_galley = ui.painter().layout_no_wrap(
40                badge_count.to_string(),
41                egui::FontId::proportional(11.0),
42                text_color,
43            );
44            let text_pos = egui::pos2(
45                badge_center.x - text_galley.size().x / 2.0,
46                badge_center.y - text_galley.size().y / 2.0,
47            );
48            ui.painter().galley(text_pos, text_galley, text_color);
49        }
50
51        if logo_resp.clicked() {
52            self.show_news = true;
53            self.current_article_index = 0;
54            self.news_manager.clear_badge(&mut self.launcher_config);
55        }
56
57        ui.horizontal(|ui| {
58            // Version label
59            ui.label(
60                egui::RichText::new(format!("v{}", env!("CARGO_PKG_VERSION")))
61                    .weak()
62                    .size(10.0),
63            );
64
65            // Status indicator
66            let (color, tooltip) = match update_status {
67                UpdateStatus::UpdateAvailable(_) => {
68                    (egui::Color32::YELLOW, "Downloading update...".to_string())
69                }
70                _ => (egui::Color32::TRANSPARENT, String::new()),
71            };
72
73            if color != egui::Color32::TRANSPARENT {
74                ui.label(egui::RichText::new("ยฐ").color(color))
75                    .on_hover_text(tooltip);
76            }
77        });
78
79        ui.add_space(20.0);
80
81        ui.vertical(|ui| {
82            ui.spacing_mut().item_spacing.y = 8.0;
83
84            // Navigation buttons
85            let display_warning = self.config_manager.detail_config.ground_detail >= 4
86                || self.config_manager.detail_config.visibility_cube_width > 400;
87
88            if nav_button(
89                ui,
90                "๐Ÿ–ฅ",
91                "Display Config",
92                self.current_page == Page::Display,
93                display_warning,
94            )
95            .clicked()
96            {
97                self.current_page = Page::Display;
98                self.error_message = None;
99            }
100
101            if nav_button(
102                ui,
103                "๐ŸŒ",
104                "Language",
105                self.current_page == Page::Language,
106                false,
107            )
108            .clicked()
109            {
110                self.current_page = Page::Language;
111                self.error_message = None;
112            }
113
114            let music_warning =
115                match audio::SoundMode::from_config(&self.config_manager.sound_config) {
116                    audio::SoundMode::Disabled | audio::SoundMode::WithoutMusic => true,
117                    audio::SoundMode::Full => false,
118                };
119
120            if nav_button(
121                ui,
122                "๐Ÿ”Š",
123                "Audio",
124                self.current_page == Page::Music,
125                music_warning,
126            )
127            .clicked()
128            {
129                self.current_page = Page::Music;
130                self.load_music_configs();
131                self.error_message = None;
132            }
133
134            let tweaks_items = [
135                NavGroupItem {
136                    page: Page::Mods,
137                    icon: "๐Ÿ”ง",
138                    label: "Mods",
139                },
140                NavGroupItem {
141                    page: Page::Trainer,
142                    icon: "โš™",
143                    label: "Trainer",
144                },
145            ];
146
147            nav_group_button(ui, "๐Ÿ“", "Tweaks", &tweaks_items, &mut self.current_page);
148
149            let snowboarder_items = [
150                NavGroupItem {
151                    page: Page::Snowboards,
152                    icon: "๐Ÿ’Š",
153                    label: "Boards",
154                },
155                NavGroupItem {
156                    page: Page::Riders,
157                    icon: "๐Ÿ‘จ",
158                    label: "Riders",
159                },
160            ];
161
162            nav_group_button(
163                ui,
164                "๐Ÿ‚",
165                "Snowboarders",
166                &snowboarder_items,
167                &mut self.current_page,
168            );
169
170            if nav_button(
171                ui,
172                "๐Ÿ”ฌ",
173                "Troubleshoot",
174                self.current_page == Page::Troubleshoot,
175                false,
176            )
177            .clicked()
178            {
179                self.current_page = Page::Troubleshoot;
180                self.error_message = None;
181            }
182
183            if nav_button(
184                ui,
185                "๐ŸŽฌ",
186                "Replays",
187                self.current_page == Page::Replays,
188                false,
189            )
190            .clicked()
191            {
192                self.current_page = Page::Replays;
193                self.error_message = None;
194            }
195        });
196    }
197
198    pub fn show_footer(&mut self, ui: &mut egui::Ui) {
199        ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
200            ui.add_space(10.0);
201
202            let play_enabled = self.is_supreme_initiator;
203
204            // No tournament: plain offline "Play!" button.
205            let tournament_selection = tournament_play_selection(&self.storage, &self.mods_state);
206            let now = chrono::Utc::now().timestamp();
207            let (play_label, tournament_tooltip, tournament_ok) = match &tournament_selection {
208                Some(selection) => {
209                    let room_desc = if selection.name.is_empty() {
210                        format!("{} / {}", selection.env, selection.difficulty)
211                    } else {
212                        format!(
213                            "{} ({} / {})",
214                            selection.name, selection.env, selection.difficulty
215                        )
216                    };
217                    match selection.play_blocker(now) {
218                        None => (
219                            "๐ŸŒ Online",
220                            Some(format!(
221                                "Online Tournament ยท room {room_desc}\n\nCreate or join another room on the Mods page to switch."
222                            )),
223                            true,
224                        ),
225                        Some(blocker) => (
226                            "๐ŸŒ Online",
227                            Some(format!(
228                                "Online Tournament ยท room {room_desc}:\n{blocker}.\n\nJoin a live room on the Mods page."
229                            )),
230                            false,
231                        ),
232                    }
233                }
234                None => ("๐ŸŽฎ Play!", None, true),
235            };
236            let play_enabled = play_enabled && tournament_ok;
237
238            let play_tooltip = if !play_enabled && !self.is_supreme_initiator {
239                Some("Supreme.exe could not be found.".to_string())
240            } else {
241                tournament_tooltip
242            };
243
244            match play_quit_button(ui, !self.has_focused_play, play_enabled, play_tooltip, play_label) {
245                FooterAction::Quit => {
246                    self.save_all_configs();
247                    std::process::exit(1);
248                }
249                FooterAction::Play => {
250                    if self.error_message.is_some() {
251                        // Start anyway if an error is already showing
252                        self.save_all_configs();
253                        std::process::exit(0);
254                    }
255
256                    self.save_all_configs();
257
258                    if self.error_message.is_none() {
259                        self.run_injector();
260                    }
261                }
262                FooterAction::None => {}
263            }
264
265            if !self.has_focused_play {
266                self.has_focused_play = true;
267            }
268
269            ui.add_space(5.0);
270
271            if let Some(error) = &self.error_message {
272                let err = crate::theme::palette(ui.ctx()).err;
273                ui.colored_label(err, format!("โŒ Error: {error}"));
274                ui.separator();
275            }
276
277            #[cfg(feature = "local")]
278            if ui.button("๐Ÿ“ค Export Config").clicked() {
279                self.export_configs();
280            }
281        });
282    }
283
284    pub fn render_page_content(&mut self, ui: &mut egui::Ui) {
285        // Update unified operations before rendering content
286        self.update_unified_operations();
287
288        match self.current_page {
289            Page::Display => {
290                display_page(
291                    ui,
292                    &self.storage,
293                    &mut self.config_manager.display_config,
294                    &mut self.config_manager.detail_config,
295                    &mut self.config_manager.trainer_config,
296                );
297            }
298            Page::Language => language_page(ui, &mut self.config_manager.language_config),
299            Page::Music => music_page(
300                ui,
301                &mut self.music_state,
302                &mut self.config_manager.sound_config,
303                &mut self.config_manager.music_configs,
304                &self.storage,
305            ),
306            Page::Mods => {
307                if mods_page(
308                    ui,
309                    &self.storage,
310                    &mut self.mods_state,
311                    &mut self.launcher_config,
312                ) {
313                    crate::utils::refresh_hook_resolver(&mut self.storage);
314                }
315            }
316            Page::Trainer => {
317                let action = trainer_page(
318                    ui,
319                    &mut self.trainer_state,
320                    &mut self.config_manager.trainer_config,
321                    &self.storage,
322                );
323
324                match action {
325                    TrainerPageAction::InstallDependencies(handle) => {
326                        self.dependency_receiver = Some(handle);
327                        self.unified_operation.operation_type = OperationType::Dependency;
328                        self.unified_operation.is_running = true;
329                        self.unified_operation.total_items = 0;
330                        self.unified_operation.processed_items = 0;
331                        self.unified_operation.status = "Downloading dependencies...".to_string();
332                        self.unified_operation.logs.clear();
333                        self.unified_operation
334                            .logs
335                            .push("Starting dependency installation...".to_string());
336                    }
337                    TrainerPageAction::InstallHdFonts(handle) => {
338                        self.dependency_receiver = Some(handle);
339                        self.unified_operation.operation_type = OperationType::Dependency;
340                        self.unified_operation.is_running = true;
341                        self.unified_operation.total_items = 0;
342                        self.unified_operation.processed_items = 0;
343                        self.unified_operation.status = "Downloading HD Fonts...".to_string();
344                        self.unified_operation.logs.clear();
345                        self.unified_operation
346                            .logs
347                            .push("Starting HD Font installation...".to_string());
348                    }
349                    TrainerPageAction::CheckStatus(handle) => {
350                        self.trainer_receiver = Some(handle);
351                    }
352                    TrainerPageAction::None => {}
353                }
354            }
355            Page::Snowboards => snowboards_page(ui, &self.storage, &mut self.snowboards_state),
356            Page::Riders => riders_page(ui, &self.storage, &mut self.riders_state),
357            Page::Troubleshoot => {
358                troubleshoot_page(ui, &mut self.troubleshoot_state, &self.storage);
359            }
360            Page::Replays => replays_page(ui, &mut self.replays_state, &self.storage),
361        }
362    }
363
364    pub fn render_operation_progress(&mut self, ui: &mut egui::Ui) {
365        let show_progress = self.unified_operation.is_running
366            || !self.unified_operation.logs.is_empty()
367            || self.troubleshoot_state.is_running
368            || !self.troubleshoot_state.logs.is_empty();
369
370        if show_progress {
371            egui::Panel::bottom("operation_progress")
372                .resizable(false)
373                .min_size(100.0)
374                .max_size(300.0)
375                .show(ui, |ui| {
376                    ui.add_space(10.0);
377                    ui.horizontal_wrapped(|ui| {
378                        ui.vertical(|ui| {
379                            ui.horizontal(|ui| {
380                                ui.heading("Operation Progress");
381                                ui.with_layout(
382                                    egui::Layout::right_to_left(egui::Align::Center),
383                                    |ui| {
384                                        if ui.button("โŒ Close").clicked() {
385                                            self.unified_operation.logs.clear();
386                                            self.troubleshoot_state.logs.clear();
387                                        }
388                                    },
389                                );
390                            });
391
392                            let (status, total_items, processed_items, is_running) =
393                                if self.troubleshoot_state.is_running {
394                                    (
395                                        self.troubleshoot_state.status.clone(),
396                                        self.troubleshoot_state.total_items,
397                                        self.troubleshoot_state.processed_items,
398                                        true,
399                                    )
400                                } else if self.unified_operation.is_running {
401                                    (
402                                        self.unified_operation.status.clone(),
403                                        self.unified_operation.total_items,
404                                        self.unified_operation.processed_items,
405                                        true,
406                                    )
407                                } else if !self.troubleshoot_state.logs.is_empty() {
408                                    (
409                                        self.troubleshoot_state.status.clone(),
410                                        self.troubleshoot_state.total_items,
411                                        self.troubleshoot_state.processed_items,
412                                        false,
413                                    )
414                                } else {
415                                    (
416                                        self.unified_operation.status.clone(),
417                                        self.unified_operation.total_items,
418                                        self.unified_operation.processed_items,
419                                        false,
420                                    )
421                                };
422
423                            if total_items > 0 {
424                                ui.label(format!("{status} ({processed_items}/{total_items})"));
425                                let progress = processed_items as f32 / total_items as f32;
426                                ui.add(egui::ProgressBar::new(progress).show_percentage());
427                            } else if is_running {
428                                ui.label(&status);
429                                ui.spinner();
430                            } else {
431                                ui.label(&status);
432                            }
433                        });
434
435                        ui.separator();
436
437                        let logs_to_show = if self.troubleshoot_state.is_running {
438                            self.troubleshoot_state.logs.clone()
439                        } else if self.unified_operation.is_running {
440                            self.unified_operation.logs.clone()
441                        } else if !self.troubleshoot_state.logs.is_empty() {
442                            self.troubleshoot_state.logs.clone()
443                        } else {
444                            self.unified_operation.logs.clone()
445                        };
446
447                        if !logs_to_show.is_empty() {
448                            ui.vertical(|ui| {
449                                ui.label("Operation Logs:");
450                                egui::ScrollArea::vertical()
451                                    .max_height(180.0)
452                                    .stick_to_bottom(true)
453                                    .auto_shrink([false, true])
454                                    .show(ui, |ui| {
455                                        for log in &logs_to_show {
456                                            ui.label(log);
457                                        }
458                                    });
459                            });
460                        }
461                    });
462                });
463        }
464    }
465
466    pub fn show_news_window(&mut self, ctx: &egui::Context) {
467        let mut open = self.show_news;
468
469        egui::Window::new("News")
470            .open(&mut open)
471            .resizable(true)
472            .default_width(500.0)
473            .default_height(450.0)
474            .show(ctx, |ui| {
475                let articles = self.news_manager.articles();
476                let article_count = articles.len();
477
478                // Clamp the index in case the article list shrank since last open.
479                if self.current_article_index >= article_count && article_count > 0 {
480                    self.current_article_index = article_count - 1;
481                }
482
483                if article_count == 0 {
484                    ui.vertical_centered(|ui| {
485                        ui.add_space(20.0);
486                        if self.news_manager.is_checking() {
487                            ui.spinner();
488                            ui.label("Checking for news...");
489                        } else {
490                            ui.label("No news articles available.");
491                            if ui.button("Check Now").clicked() {
492                                self.news_manager.start_check(&self.launcher_config.news.last_read);
493                            }
494                        }
495                        ui.add_space(20.0);
496                    });
497                } else {
498                    let article = &self.news_manager.articles()[self.current_article_index];
499
500                    let (title, content) = if let Some(pos) = article.content.find('\n') {
501                        let first = &article.content[..pos];
502
503                        if let Some(rest) = first.strip_prefix("# ") {
504                            (Some(rest.to_string()), article.content[pos + 1..].to_string())
505                        } else {
506                            (None, article.content.clone())
507                        }
508                    } else {
509                        // Single-line file
510                        (article.content.strip_prefix("# ").map(|t| t.to_string()), String::new())
511                    };
512
513                    let title_text = title.unwrap_or_else(|| article.title.clone());
514                    let date_text = article.date.clone();
515
516                    ui.horizontal(|ui| {
517                        let prev_enabled = self.current_article_index + 1 < article_count;
518                        if ui.add_enabled(prev_enabled, egui::Button::new("โ—€")).clicked() {
519                            self.current_article_index += 1;
520                        }
521
522                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
523                            let next_enabled = self.current_article_index > 0;
524                            if ui.add_enabled(next_enabled, egui::Button::new("โ–ถ")).clicked() {
525                                self.current_article_index -= 1;
526                            }
527
528                            ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
529                                ui.add_space(2.0);
530                                ui.label(egui::RichText::new(&title_text).strong().size(15.0));
531                                ui.label(egui::RichText::new(&date_text).weak().size(12.0));
532                            });
533                        });
534                    });
535
536                    ui.separator();
537
538                    egui::ScrollArea::vertical()
539                        .auto_shrink([false, false])
540                        .max_height(350.0)
541                        .show(ui, |ui| {
542                            egui_commonmark::CommonMarkViewer::new()
543                                .show(ui, &mut self.markdown_cache, &content);
544                        });
545                }
546
547                ui.separator();
548
549                ui.vertical_centered(|ui| {
550                    ui.hyperlink_to(
551                        "Supreme Snowboarding Community Launcher homepage",
552                        "https://codeberg.org/SupremeSnowboardingCommunity/SupremeSnowboardingLauncher/",
553                    );
554                });
555            });
556
557        self.show_news = open;
558    }
559}