1use std::collections::{HashMap, HashSet};
21use std::path::{Path, PathBuf};
22use std::str::FromStr;
23use std::time::Instant;
24
25use anyhow::{Context, anyhow};
26use smallvec::SmallVec;
27
28use iced_test::core::renderer::Headless;
29use iced_test::core::{Event, Font, Settings, Size, mouse};
30use iced_test::core::{clipboard, renderer, window};
31
32use modde_core::filter::{FilterCriterion, FilterKind, FilterMode};
33use modde_core::nexus_id::NexusModId;
34use modde_core::profile::{EnabledMod, Profile, ProfileSource};
35
36use crate::app::{ButtonHoverToastState, FOMODWizardState, Modde, View};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Screen {
44 ModList,
46 Downloads,
48 FomodWizard,
50 Tools,
52 BrowseNexus,
54}
55
56impl Screen {
57 #[must_use]
60 pub fn as_str(self) -> &'static str {
61 match self {
62 Screen::ModList => "mod-list",
63 Screen::Downloads => "downloads",
64 Screen::FomodWizard => "fomod-wizard",
65 Screen::Tools => "tools",
66 Screen::BrowseNexus => "browse-nexus",
67 }
68 }
69}
70
71impl FromStr for Screen {
72 type Err = anyhow::Error;
73
74 fn from_str(value: &str) -> Result<Self, Self::Err> {
75 match value {
76 "mod-list" | "modlist" => Ok(Screen::ModList),
77 "downloads" | "download-queue" => Ok(Screen::Downloads),
78 "fomod-wizard" | "fomod" => Ok(Screen::FomodWizard),
79 "tools" => Ok(Screen::Tools),
80 "browse-nexus" | "nexus" => Ok(Screen::BrowseNexus),
81 other => Err(anyhow!(
82 "unknown screen '{other}' (expected one of: mod-list, downloads, \
83 fomod-wizard, tools, browse-nexus)"
84 )),
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
91pub struct ShotOptions {
92 pub width: f32,
94 pub height: f32,
96 pub scale: f32,
98 pub theme: String,
100}
101
102impl Default for ShotOptions {
103 fn default() -> Self {
104 Self {
105 width: 1280.0,
106 height: 800.0,
107 scale: 2.0,
108 theme: "Dark".to_string(),
109 }
110 }
111}
112
113#[must_use]
115pub fn all_screens() -> &'static [Screen] {
116 &[
117 Screen::ModList,
118 Screen::Downloads,
119 Screen::FomodWizard,
120 Screen::Tools,
121 Screen::BrowseNexus,
122 ]
123}
124
125pub fn capture(screen: Screen, opts: &ShotOptions) -> anyhow::Result<image::RgbaImage> {
133 let app = demo_app(screen, opts);
134
135 let theme = app.active_theme();
137 let base = <iced::Theme as iced::theme::Base>::base(&theme);
138
139 let settings = Settings::default();
141 let default_font = match settings.default_font {
142 Font::DEFAULT => Font::with_name("Fira Sans"),
143 font => font,
144 };
145 let mut renderer = iced_test::futures::futures::executor::block_on(
146 iced_test::renderer::Renderer::new(
147 default_font,
148 settings.default_text_size,
149 Some("tiny-skia"),
150 ),
151 )
152 .ok_or_else(|| anyhow!("failed to create headless tiny-skia renderer"))?;
153
154 let logical_size = Size::new(opts.width, opts.height);
155
156 let mut ui = iced_test::runtime::UserInterface::build(
158 app.render_root(),
159 logical_size,
160 iced_test::runtime::user_interface::Cache::new(),
161 &mut renderer,
162 );
163
164 let mut messages = Vec::new();
166 let _ = ui.update(
167 &[Event::Window(window::Event::RedrawRequested(Instant::now()))],
168 mouse::Cursor::Unavailable,
169 &mut renderer,
170 &mut clipboard::Null,
171 &mut messages,
172 );
173
174 ui.draw(
176 &mut renderer,
177 &theme,
178 &renderer::Style {
179 text_color: base.text_color,
180 },
181 mouse::Cursor::Unavailable,
182 );
183
184 let scale_factor = opts.scale;
186 let physical_w = (opts.width * scale_factor).round() as u32;
187 let physical_h = (opts.height * scale_factor).round() as u32;
188 let physical_size = Size::new(physical_w, physical_h);
189 let rgba: Vec<u8> = renderer.screenshot(physical_size, scale_factor, base.background_color);
190
191 image::RgbaImage::from_raw(physical_w, physical_h, rgba).ok_or_else(|| {
194 anyhow!(
195 "screenshot buffer did not match expected {physical_w}x{physical_h} RGBA dimensions"
196 )
197 })
198}
199
200pub fn capture_to_png(screen: Screen, opts: &ShotOptions, out: &Path) -> anyhow::Result<()> {
209 let image = capture(screen, opts)?;
210 if let Some(parent) = out.parent() {
211 if !parent.as_os_str().is_empty() {
212 std::fs::create_dir_all(parent)
213 .with_context(|| format!("creating screenshot output dir {}", parent.display()))?;
214 }
215 }
216 image
217 .save(out)
218 .with_context(|| format!("writing screenshot PNG to {}", out.display()))?;
219 Ok(())
220}
221
222fn demo_app(screen: Screen, opts: &ShotOptions) -> Modde {
227 let mut app = base_demo_app(opts);
228 match screen {
229 Screen::ModList => populate_mod_list(&mut app),
230 Screen::Downloads => populate_downloads(&mut app),
231 Screen::FomodWizard => populate_fomod_wizard(&mut app),
232 Screen::Tools => populate_tools(&mut app),
233 Screen::BrowseNexus => populate_browse_nexus(&mut app),
234 }
235 app
236}
237
238fn base_demo_app(opts: &ShotOptions) -> Modde {
241 let db = crate::app::block_on(modde_core::db::ModdeDb::open_memory())
242 .expect("open in-memory demo DB");
243
244 Modde {
245 db,
246 active_view: View::ModList,
247 active_profile: None,
248 profiles: Vec::new(),
249 status_message: "Ready".to_string(),
250 button_hover_toast: ButtonHoverToastState::default(),
251 pending_tools_load_status_message: None,
252 settings: modde_core::settings::AppSettings::default(),
253 collection_search: String::new(),
254 collections: Vec::new(),
255 fomod_installer: None,
256 fomod_visible_step_indices: SmallVec::new(),
257 fomod_wizard_pos: 0,
258 fomod_source_dir: None,
259 fomod_dest_dir: None,
260 fomod_conflicts: SmallVec::new(),
261 fomod_can_undo: false,
262 fomod_selections: HashMap::new(),
263 selected_mod_index: None,
264 selected_mod_details: None,
265 mod_filter: String::new(),
266 mod_id_filter_keys: Vec::new(),
267 theme_name: opts.theme.clone(),
268 wabbajack_manifest: None,
269 active_downloads: Vec::new(),
270 download_queue: modde_sources::queue::DownloadQueue::new(2),
271 download_lookup: HashMap::new(),
272 loaded_profile: None,
273 save_snapshots: Vec::new(),
274 current_fingerprint: None,
275 selected_save_details: None,
276 experiment_depth: 0,
277 nexus_status: None,
278 nexus_api_key_draft: String::new(),
279 nexus_api_key_visible: false,
280 nexus_api_key_source: None,
281 nexus_config_key_exists: false,
282 new_profile_name: String::new(),
283 new_profile_dialog_open: false,
284 game_path_dialog_open: false,
285 add_custom_game_dialog_open: false,
286 manage_custom_games_dialog_open: false,
287 pending_game_path_game_id: None,
288 previous_game_before_path_dialog: None,
289 game_path_dialog_error: None,
290 add_custom_game: crate::app::AddCustomGameState::default(),
291 available_games: smallvec::smallvec![
292 ("skyrim-se".to_string(), "Skyrim SE".to_string()),
293 ("fallout4".to_string(), "Fallout 4".to_string()),
294 ("stellar-blade".to_string(), "Stellar Blade".to_string()),
295 ],
296 detected_games: HashSet::from(["skyrim-se".to_string()]),
297 selected_game: Some("skyrim-se".to_string()),
298 stock_snapshot_exists: false,
299 window_id: iced::window::Id::unique(),
300 collapsed_categories: HashSet::new(),
301 mod_categories: vec![
302 (None, "Uncategorized".to_string()),
303 (Some(1), "Gameplay".to_string()),
304 (Some(2), "Graphics".to_string()),
305 ],
306 data_tab_state: Default::default(),
307 data_tab_conflicts: Vec::new(),
308 diagnostics_state: Default::default(),
309 tool_state: Default::default(),
310 browse_nexus: Default::default(),
311 filter_mode: FilterMode::default(),
312 filter_criteria: vec![
313 FilterCriterion::new(FilterKind::Enabled),
314 FilterCriterion::new(FilterKind::HasNotes),
315 FilterCriterion::new(FilterKind::HasNexusId),
316 ],
317 compact_mod_list: false,
318 collapsed_sidebar_groups: HashSet::new(),
319 update_available: None,
320 context_generation: 0,
321 data_tab_generation: 0,
322 diagnostics_generation: 0,
323 }
324}
325
326fn demo_profile() -> Profile {
328 let mods = vec![
329 demo_mod("skyui", "SkyUI", Some("5.2.0"), None, None, Some(12_604)),
330 demo_mod(
331 "uiextensions",
332 "UIExtensions",
333 Some("1.2.0"),
334 None,
335 Some("Required by several follower mods."),
336 Some(57_046),
337 ),
338 demo_mod(
339 "smim",
340 "Static Mesh Improvement Mod (SMIM)",
341 Some("2.06"),
342 Some(2),
343 Some("Big visual upgrade — keep high in the order."),
344 Some(8_655),
345 ),
346 demo_mod(
347 "noble-skyrim",
348 "Noble Skyrim - Texture Overhaul",
349 Some("1.1"),
350 Some(2),
351 None,
352 Some(50_022),
353 ),
354 demo_mod(
355 "ussep",
356 "Unofficial Skyrim Special Edition Patch",
357 Some("4.3.2"),
358 Some(1),
359 Some("Load order foundation. Do not disable."),
360 Some(266),
361 ),
362 demo_mod(
363 "alternate-start",
364 "Alternate Start - Live Another Life",
365 Some("4.1.6"),
366 Some(1),
367 None,
368 Some(272),
369 ),
370 disabled_mod(
372 "old-magic",
373 "Apocalypse - Magic of Skyrim (disabled)",
374 Some("9.45"),
375 Some(1),
376 ),
377 demo_mod(
378 "immersive-armors",
379 "Immersive Armors",
380 Some("8.1"),
381 None,
382 Some("Pending cleanup of conflicting meshes."),
383 Some(19_733),
384 ),
385 ];
386
387 Profile {
388 id: Some(1),
389 name: "Demo".to_string(),
390 game_id: modde_core::GameId::from("skyrim-se"),
391 source: ProfileSource::Manual,
392 mods,
393 overrides: PathBuf::from("/tmp/demo/overrides"),
394 load_order_rules: SmallVec::new(),
395 load_order_lock: None,
396 }
397}
398
399fn demo_mod(
400 mod_id: &str,
401 display_name: &str,
402 version: Option<&str>,
403 category_id: Option<i64>,
404 notes: Option<&str>,
405 nexus_mod_id: Option<u64>,
406) -> EnabledMod {
407 EnabledMod {
408 mod_id: mod_id.to_string(),
409 display_name: Some(display_name.to_string()),
410 enabled: true,
411 version: version.map(str::to_string),
412 category_id,
413 notes: notes.map(str::to_string),
414 nexus_mod_id: nexus_mod_id.map(NexusModId::from),
415 ..Default::default()
416 }
417}
418
419fn disabled_mod(
420 mod_id: &str,
421 display_name: &str,
422 version: Option<&str>,
423 category_id: Option<i64>,
424) -> EnabledMod {
425 EnabledMod {
426 mod_id: mod_id.to_string(),
427 display_name: Some(display_name.to_string()),
428 enabled: false,
429 version: version.map(str::to_string),
430 category_id,
431 ..Default::default()
432 }
433}
434
435fn populate_mod_list(app: &mut Modde) {
436 let profile = demo_profile();
437 app.mod_id_filter_keys = modde_core::filter::mod_id_filter_keys(&profile.mods);
438 app.active_profile = Some(profile.name.clone());
439 app.profiles = vec![demo_profile_summary()];
440 app.loaded_profile = Some(profile);
441 app.selected_mod_index = Some(0);
442 app.active_view = View::ModList;
443 app.status_message = "Loaded profile 'Demo' (8 mods)".to_string();
444}
445
446fn demo_profile_summary() -> modde_core::profile::ProfileSummary {
447 modde_core::profile::ProfileSummary {
448 id: 1,
449 name: "Demo".to_string(),
450 game_id: modde_core::GameId::from("skyrim-se"),
451 mod_count: 8,
452 source_type: "manual".to_string(),
453 }
454}
455
456fn populate_downloads(app: &mut Modde) {
457 use modde_sources::queue::DownloadState;
458
459 let active = enqueue_demo(app, "noble-skyrim", "Noble Skyrim - Texture Overhaul");
463 let queued = enqueue_demo(app, "smim", "Static Mesh Improvement Mod (SMIM)");
464 let complete = enqueue_demo(app, "skyui", "SkyUI");
465 let failed = enqueue_demo(app, "immersive-armors", "Immersive Armors");
466
467 if let Some(task) = app.download_queue.get_mut(active) {
468 task.state = DownloadState::Active {
469 bytes_downloaded: 734_003_200,
470 total_bytes: Some(1_181_116_006),
471 };
472 }
473 let _ = queued;
475 if let Some(task) = app.download_queue.get_mut(complete) {
476 task.state = DownloadState::Complete {
477 path: PathBuf::from("/home/demo/.local/share/modde/downloads/skyui.zip"),
478 hash: 0xDEAD_BEEF,
479 };
480 }
481 if let Some(task) = app.download_queue.get_mut(failed) {
482 task.state = DownloadState::Failed {
483 error: "connection reset by peer (Nexus rate limit)".to_string(),
484 };
485 }
486
487 app.active_view = View::Downloads;
488 app.status_message = "1 active, 1 queued, 1 complete, 1 failed".to_string();
489}
490
491fn enqueue_demo(app: &mut Modde, key: &str, name: &str) -> usize {
492 app.download_queue.enqueue(
493 key.to_string(),
494 PathBuf::from(format!("/home/demo/.local/share/modde/downloads/{key}.download")),
495 None,
496 modde_sources::meta::DownloadMeta {
497 url: format!("https://example.invalid/{key}"),
498 expected_hash: None,
499 bytes_downloaded: 0,
500 total_bytes: None,
501 nexus_mod_id: None,
502 nexus_file_id: None,
503 game_domain: Some("skyrimspecialedition".to_string()),
504 mod_name: Some(name.to_string()),
505 version: None,
506 status: "queued".to_string(),
507 },
508 )
509}
510
511const DEMO_FOMOD_XML: &str = r#"<?xml version="1.0" encoding="utf-8"?>
514<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
515 <moduleName>Demo Texture Pack FOMOD</moduleName>
516 <installSteps order="Explicit">
517 <installStep name="Choose Texture Resolution">
518 <optionalFileGroups order="Explicit">
519 <group name="Texture Resolution" type="SelectExactlyOne">
520 <plugins order="Explicit">
521 <plugin name="2K Textures">
522 <description>Balanced quality and VRAM usage. Recommended for most setups.</description>
523 <typeDescriptor><type name="Recommended"/></typeDescriptor>
524 </plugin>
525 <plugin name="4K Textures">
526 <description>Highest quality. Requires 8GB+ VRAM.</description>
527 <typeDescriptor><type name="Optional"/></typeDescriptor>
528 </plugin>
529 <plugin name="1K Textures (Performance)">
530 <description>Lowest VRAM footprint for low-end hardware.</description>
531 <typeDescriptor><type name="Optional"/></typeDescriptor>
532 </plugin>
533 </plugins>
534 </group>
535 </optionalFileGroups>
536 </installStep>
537 <installStep name="Optional Add-ons">
538 <optionalFileGroups order="Explicit">
539 <group name="Extra Content" type="SelectAny">
540 <plugins order="Explicit">
541 <plugin name="Parallax Meshes">
542 <description>Adds depth to flat surfaces. Needs an ENB that supports parallax.</description>
543 <typeDescriptor><type name="Optional"/></typeDescriptor>
544 </plugin>
545 <plugin name="Snow Shader Tweaks">
546 <description>Improves snow material blending.</description>
547 <typeDescriptor><type name="Optional"/></typeDescriptor>
548 </plugin>
549 </plugins>
550 </group>
551 <group name="Compatibility Patches" type="SelectAtMostOne">
552 <plugins order="Explicit">
553 <plugin name="USSEP Patch">
554 <description>Patch for the Unofficial Skyrim Special Edition Patch.</description>
555 <typeDescriptor><type name="Optional"/></typeDescriptor>
556 </plugin>
557 <plugin name="No Patch">
558 <description>Install the base files only.</description>
559 <typeDescriptor><type name="Optional"/></typeDescriptor>
560 </plugin>
561 </plugins>
562 </group>
563 </optionalFileGroups>
564 </installStep>
565 </installSteps>
566</config>
567"#;
568
569fn populate_fomod_wizard(app: &mut Modde) {
570 let config = fomod_oxide::ModuleConfig::parse(DEMO_FOMOD_XML)
571 .expect("parse embedded demo FOMOD ModuleConfig");
572 let installer = fomod_oxide::Installer::new(config);
573 let mut state = FOMODWizardState::with_installer(installer);
574
575 let defaults = state.default_selections();
577 app.fomod_selections.clear();
578 for (step_idx, group_idx, sel) in defaults {
579 state.select(step_idx, group_idx, sel.clone());
580 app.fomod_selections.insert((step_idx, group_idx), sel);
581 }
582
583 app.fomod_installer = Some(state);
584 app.fomod_source_dir = Some(PathBuf::from("/tmp/demo/fomod-source"));
585 app.fomod_dest_dir = Some(PathBuf::from("/tmp/demo/fomod-dest"));
586 app.fomod_wizard_pos = 0;
587 app.fomod_can_undo = false;
588 app.refresh_fomod_visible_steps();
589 app.fomod_conflicts = app
591 .fomod_installer
592 .as_ref()
593 .map(|i| SmallVec::from_vec(i.detect_conflicts()))
594 .unwrap_or_default();
595
596 app.active_view = View::FOMODWizard(FOMODWizardState::new());
598 app.status_message = "FOMOD wizard started".to_string();
599}
600
601fn populate_tools(app: &mut Modde) {
602 let profile = demo_profile();
604 app.active_profile = Some(profile.name.clone());
605 app.loaded_profile = Some(profile);
606 app.tool_state = demo_tool_state();
607 app.active_view = View::Tools;
608 app.status_message = "Tools for Skyrim SE".to_string();
609}
610
611fn demo_tool_state() -> crate::app::ToolState {
612 use crate::app::{ToolReleaseSupport, ToolState, ToolUiEntry};
613
614 let entry = ToolUiEntry {
615 tool_id: "optiscaler".to_string(),
616 display_name: "OptiScaler".to_string(),
617 description: "FSR / DLSS / XeSS upscaling injection".to_string(),
618 category: "Graphics".to_string(),
619 available: true,
620 availability_text: "available".to_string(),
621 enabled: true,
622 settings: serde_json::json!({}),
623 setting_specs: Vec::new(),
624 generated_config_path: None,
625 applied_files: vec!["dxgi.dll".to_string(), "OptiScaler.ini".to_string()],
626 has_file_patching: true,
627 release_support: ToolReleaseSupport::Supported,
628 status_message: Some("Managed; proxy d3d12.dll".to_string()),
629 env_preview: Vec::new(),
630 dll_overrides: Vec::new(),
631 wrapper_preview: Vec::new(),
632 derived_facts: Vec::new(),
633 optiscaler_state: Some("version goverlay-edge 0.9.12".to_string()),
634 optiscaler_latest_backup: None,
635 optiscaler_detected_files: 3,
636 apply_pending: false,
637 apply_missing_inputs: Vec::new(),
638 setting_history: Vec::new(),
639 };
640
641 let reshade = ToolUiEntry {
642 tool_id: "reshade".to_string(),
643 display_name: "ReShade".to_string(),
644 description: "Post-processing shader injector".to_string(),
645 category: "Graphics".to_string(),
646 available: true,
647 availability_text: "available".to_string(),
648 enabled: false,
649 ..entry.clone()
650 };
651
652 ToolState {
653 entries: vec![entry, reshade],
654 active_tool_id: Some("optiscaler".to_string()),
655 game_label: Some("Skyrim SE".to_string()),
656 game_dir_configured: true,
657 ..Default::default()
658 }
659}
660
661fn populate_browse_nexus(app: &mut Modde) {
662 let profile = demo_profile();
665 app.active_profile = Some(profile.name.clone());
666 app.loaded_profile = Some(profile);
667 app.active_view = View::BrowseNexus;
668 app.status_message = "Browse Nexus".to_string();
669}