Skip to main content

modde_ui/
screenshot.rs

1//! Headless offscreen screenshot capture for modde's hottest GUI screens.
2//!
3//! This module renders fully populated demo screens to RGBA pixels and PNG
4//! files **without a GPU or display server**, using iced 0.14's dual-backend
5//! [`iced_test::renderer::Renderer`], which falls back to the pure-CPU
6//! tiny-skia backend when no GPU is present. It powers the hidden
7//! `modde dev screenshot` CLI command used to generate Flathub / website
8//! assets and (in the future) CI automation.
9//!
10//! The whole module is gated behind the non-default `screenshot` cargo
11//! feature so release binaries carry zero extra weight.
12//!
13//! Screens are driven by constructing a demo [`Modde`] and setting its state
14//! fields **directly** — never by dispatching `update()` messages, which would
15//! fire async `Task`s that need a database or network.
16//!
17//! For determinism, run with `ICED_BACKEND=tiny-skia` in the environment so
18//! the software backend is always selected.
19
20use 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/// A capturable GUI screen.
39///
40/// The string forms accepted by [`Screen::from_str`] match the CLI
41/// `--screen` values and the website / metainfo asset basenames.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Screen {
44    /// The active profile's mod list (asset basename `mod-list`).
45    ModList,
46    /// The download queue (asset basename `downloads`).
47    Downloads,
48    /// The FOMOD installer wizard (asset basename `fomod-wizard`).
49    FomodWizard,
50    /// The tools panel (asset basename `tools`).
51    Tools,
52    /// The Nexus browse surface (asset basename `browse-nexus`).
53    BrowseNexus,
54}
55
56impl Screen {
57    /// The canonical kebab-case name for this screen — matches both the CLI
58    /// `--screen` value and the output PNG basename.
59    #[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/// Rendering options for a screenshot capture.
90#[derive(Debug, Clone)]
91pub struct ShotOptions {
92    /// Logical width of the window, in points.
93    pub width: f32,
94    /// Logical height of the window, in points.
95    pub height: f32,
96    /// HiDPI scale factor. `2.0` produces crisp output.
97    pub scale: f32,
98    /// modde theme name (e.g. `"Dark"`, `"Light"`, `"Dracula"`).
99    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/// Every screen, in priority/asset order. Used by the CLI `--all` flag.
114#[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
125/// Render `screen` offscreen and return the RGBA pixel buffer as an image.
126///
127/// # Errors
128///
129/// Returns an error if the headless renderer cannot be created (e.g. neither
130/// the GPU nor the tiny-skia backend is available) or if the produced pixel
131/// buffer does not match the requested physical dimensions.
132pub fn capture(screen: Screen, opts: &ShotOptions) -> anyhow::Result<image::RgbaImage> {
133    let app = demo_app(screen, opts);
134
135    // Mirror the iced theme the running app would select for this theme name.
136    let theme = app.active_theme();
137    let base = <iced::Theme as iced::theme::Base>::base(&theme);
138
139    // 1. Build the headless renderer, forcing the tiny-skia software backend.
140    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    // 2. Build the UI tree from the demo app's view.
157    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    // 3. Pump a redraw so layout + text shaping settle before drawing.
165    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    // 4. Draw the interface into the renderer's backbuffer.
175    ui.draw(
176        &mut renderer,
177        &theme,
178        &renderer::Style {
179            text_color: base.text_color,
180        },
181        mouse::Cursor::Unavailable,
182    );
183
184    // 5. Read back the rendered pixels at the requested physical resolution.
185    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    // 6. Wrap the buffer in an image. `from_raw` returns `None` if the buffer
192    //    length doesn't match `physical_w * physical_h * 4`.
193    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
200/// Render `screen` and write it to `out` as a PNG.
201///
202/// Parent directories are created as needed.
203///
204/// # Errors
205///
206/// Returns an error if rendering fails (see [`capture`]), if the parent
207/// directory cannot be created, or if the PNG cannot be encoded/written.
208pub 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
222// ─── Demo fixtures ──────────────────────────────────────────────────────────
223
224/// Build a demo [`Modde`] for the requested screen, fully populated so the
225/// rendered shot is non-blank.
226fn 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
238/// A baseline [`Modde`] mirroring the field set of the test harness's
239/// `test_app()`, using an in-memory DB so nothing touches disk.
240fn 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
326/// Build a realistic demo profile with a varied mod list.
327fn 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        // A couple of disabled entries so the toggle column reads naturally.
371        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    // Enqueue four tasks, then mutate each into a distinct state so the queue
460    // shows the full range of UI rows (active progress, queued, complete,
461    // failed). `track_download` would also work but only yields Queued rows.
462    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    // `queued` is left in the default Queued state from enqueue.
474    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
511/// A small but realistic FOMOD config with two install steps, several groups
512/// and plugins. No filesystem is required — images are omitted.
513const 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    // Apply the installer's default selections (mirrors update.rs StartFOMOD).
576    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    // `refresh_fomod_conflicts` is pub(in crate::app); derive the same data here.
590    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    // The view reads from `fomod_installer`; the View payload is just a marker.
597    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    // A loaded profile gives the tools view a game label context.
603    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    // The browse view renders its game-picker + search chrome even without
663    // fetched results, with a loaded profile providing the game domain.
664    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}