diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 39d1952..1fc4d6c 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -1,6 +1,6 @@ # PROJECT_MAP.md # Auto-generated project index. Update this file whenever you add, remove, or significantly change a file or feature. -# Last updated: 2026-03-31 - preview update behavior is now documented and partially centralized around shared live-apply helpers (`docs/PREVIEW_UPDATE_MAP.md`, `src/ui/pages/mod.rs`), the top config strip uses an inline target picker instead of the old switch-config dialog, per-app config creation now supports a searchable bundled game-config database (`data/game_config_db.toml`) with documented maintenance workflow, the `HUD Order` page still reorders real HUD groups with live preview sync but currently uses explicit up/down controls after drag-and-drop proved unstable, MangoTune profiles are now framed as real MangoHud `.conf` files applied into the active target, and the shell/menu/popup surfaces have been tightened and moved onto app-owned compact popover/layout hooks instead of relying on stock roomy menu chrome +# Last updated: 2026-03-31 - preview update behavior is now documented and centralized around shared page-level config-sync/live-apply helpers (`docs/PREVIEW_UPDATE_MAP.md`, `src/ui/pages/mod.rs`), the top config strip uses an inline target picker instead of the old switch-config dialog, per-app config creation now supports a searchable bundled game-config database (`data/game_config_db.toml`) with documented maintenance workflow, the `HUD Order` page still reorders real HUD groups with live preview sync but currently uses explicit up/down controls after drag-and-drop proved unstable, MangoTune profiles are now framed as real MangoHud `.conf` files applied into the active target, the shell/menu/popup surfaces have been tightened and moved onto app-owned compact popover/layout hooks instead of relying on stock roomy menu chrome, and the oversized dashboard monolith has been split into `src/ui/pages/overview/` submodules so preview runtime controls, presets, profiles, and dashboard cards now have clearer ownership boundaries --- @@ -47,7 +47,7 @@ dashboard control change -> in-memory config updates -> `src/preview/mod.rs` wri | `src/lib.rs` | Library root | Re-exports core modules for app/tests | | `src/debug_log.rs` | In-app diagnostics buffer | Shared recent-log ring buffer used by the app shell and config parser, with copy/clear support for the Debug page | | `src/app.rs` | Application bootstrap | GTK/libadwaita app setup, CSS load, startup detection | -| `src/window.rs` | Main shell | App state, header/actions, sidebar, accordion-style section navigation, async config-bar layer discovery, visible-page rebuilds with scroll-position preservation, a top config strip that now acts as an inline active-target picker plus read-only layer-stack summary, periodic layer-summary refresh with real detected-conflict counts, narrower default sizing and a slimmer sidebar so the app fits better on typical 16:9 desktops, dashboard-first startup without restoring the last visited page, a clearer header-led save/reload/revert/close flow, reset-to-defaults and restore-latest-safety-backup menu actions alongside safety backups, real target switching between writable global/per-app XDG configs with save/discard guarding, a sane MangoTune baseline for reset instead of an empty config, selective app-preference reset for preview/backup defaults, a save-menu `Auto backup on save` toggle instead of a mostly-empty Preferences screen, explicit shell styling hooks for the header/sidebar/config strip, app-owned compact popovers for the top-bar menus, and an Adwaita in-app unsaved-changes alert sheet instead of a standalone popup window | +| `src/window.rs` | Main shell | App state, header/actions, sidebar, accordion-style section navigation, async config-bar layer discovery, visible-page rebuilds with scroll-position preservation, a top config strip that now acts as an inline active-target picker plus read-only layer-stack summary, periodic layer-summary refresh with real detected-conflict counts, narrower default sizing and a slimmer sidebar so the app fits better on typical 16:9 desktops, dashboard-first startup without restoring the last visited page, a clearer header-led save/reload/revert/close flow, reset-to-defaults and restore-latest-safety-backup menu actions alongside safety backups, real target switching between writable global/per-app XDG configs with save/discard guarding, a sane MangoTune baseline for reset instead of an empty config, selective app-preference reset for preview/backup defaults, a save-menu `Auto backup on save` toggle instead of a mostly-empty Preferences screen, explicit shell styling hooks for the header/sidebar/config strip, app-owned compact popovers for the top-bar menus, an Adwaita in-app unsaved-changes alert sheet instead of a standalone popup window, and shared helpers for “config replaced” window refresh flows so reload/revert/reset/restore paths stay consistent | | `src/preview/` | Managed live preview | Preview-session state, temp preview config writing, start/reload/restart/stop flow | | `src/preview/mod.rs` | Preview controller | Uses a temporary preview config and launches the actual preview/test process directly, including persisted studio load/FPS/VSync settings, live socket-driven Studio runtime updates for load/FPS/VSync/VRAM/particle/pause controls, temp-config live-apply for ordinary Studio edits without signaling/restarting the process, safer studio restart behavior for window-size changes, legacy flag normalization before preview writes, explicit `horizontal_stretch=0` serialization for stretch-off sessions, effective HUD-width-based preview sizing, preview-only bounded widths for horizontal auto-layout sessions, per-metric auto-width estimation with separate sparse compact/non-compact tuning, preview-only right-anchor emulation for horizontal right-aligned layouts, and Studio-only post-launch window-size calibration so fractional desktop scaling does not throw off width-sensitive preview placement | | `src/config/normalize.rs` | Legacy option cleanup | Shared normalization for old flag/bool encodings like `key=0`/`key=1`, normalization of old negative offsets back to unsigned values, plus cleanup for the mistaken standalone `gpu_load` / `cpu_load` keys; reused by save/load/profile/preview flows | @@ -68,8 +68,12 @@ dashboard control change -> in-memory config updates -> `src/preview/mod.rs` wri | `src/ui/` | UI layer | Page builders and reusable widgets | | `src/ui/toast.rs` | Toast helper | Shared short-lived toast helper that dismisses older queued messages and gives errors/warnings a slightly longer lifetime | | `src/ui/widgets/tool_page.rs` | Shared page/window scaffolds | Owns the shared page frame/header, section shells, callouts, and now the reusable utility-window shell used by raw editor and small modal-style utility windows so spacing/frame rules stay consistent | -| `src/ui/pages/mod.rs` | Page registry | Sidebar structure, navigation-page factory, current-config snapshots, search-result routing, and the shared preview-update helpers that now define the two main MangoHud config refresh modes: debounced live apply and immediate live apply | -| `src/ui/pages/overview.rs` | Dashboard page | Dashboard now uses the same flat page-header scaffold and outer spacing as the rest of the app, while focusing on quick everyday tuning: `Layout & Position`, `Make It Readable`, `Show These Metrics`, a compact profile utility strip with icon actions, and a single-line status strip at the bottom. Live Preview and Presets now live on their own dedicated pages under `Start`, while the dashboard keeps MangoHud-faithful unsigned offsets, uses live apply for the common metric/appearance toggles, and separates Studio runtime controls from true preview-window restart cases | +| `src/ui/pages/mod.rs` | Page registry | Sidebar structure, navigation-page factory, current-config snapshots, search-result routing, shared preview-update helpers defining the two main MangoHud config refresh modes (debounced live apply and immediate live apply), and shared page-level config-sync helpers used by widgets/pages after in-memory config edits | +| `src/ui/pages/overview/mod.rs` | Dashboard page shell | Dashboard entry point and tests; wires together the split dashboard submodules while keeping the public `overview::build_page` / `overview::build_preview_panel` / `overview::build_presets_panel` surface stable | +| `src/ui/pages/overview/cards.rs` | Dashboard cards/helpers | Owns the dashboard’s layout/appearance/metrics/workspace cards plus the shared slider, color, metric-toggle, and config-mutation helpers they use | +| `src/ui/pages/overview/preview.rs` | Preview runtime panel | Owns the shared Studio preview panel used by both the dashboard and `Live Preview`, including window sizing, runtime controls, and preview restart/runtime-update helpers | +| `src/ui/pages/overview/presets.rs` | Dashboard presets | Owns the starter preset grid, preset mappings, and fallback profile-aware preset application logic | +| `src/ui/pages/overview/profiles.rs` | Dashboard profiles | Owns the dashboard profile strip, picker persistence, save/apply/delete flows, and active-config naming helper used in the dashboard header chips | | `src/ui/pages/live_preview.rs` | Live Preview page | Dedicated `Start` page for Studio preview launch/apply/restart/stop, preview window size, scene selection, and direct runtime tuning controls without touching the saved MangoHud config, using the same flat page-header scaffold and spacing rules as the rest of the app | | `src/ui/pages/presets_page.rs` | Presets page | Dedicated `Start` page for loading the practical starter presets/profile-backed overlay shapes without mixing them into the preview workflow, using the same flat page-header scaffold and spacing rules as the rest of the app | | `src/ui/pages/debug.rs` | Debug page | Tool-page style diagnostics surface with a live recent-log view, current in-memory config snapshot, and clipboard actions for support/debugging | @@ -91,10 +95,10 @@ dashboard control change -> in-memory config updates -> `src/preview/mod.rs` wri | `src/ui/pages/opengl_quirks.rs` | OpenGL page | OpenGL-specific workarounds | | `src/ui/pages/raw_editor.rs` | Raw editor | Tool-page style raw-config launcher with workspace stats, lighter-use guidance, and a richer editor pop-out now using the shared utility-window shell instead of hand-rolled framing | | `src/ui/pages/integrations.rs` | Integrations page | Tool-page style Steam/Lutris/Heroic/GameMode helpers with live Steam command generation | -| `src/ui/widgets/toggle_row.rs` | Schema row factory | Builds switch/combo/entry rows plus explicit compact spin-button suffix controls for numeric schema fields, applies verified friendly titles/help text, surfaces inline validation, refreshes live preview after edits, routes palette-style comma-separated color fields to richer swatch controls, treats free-text entries as commit-based preview updates that only fire after an actual edit, and filters typed/pasted junk out of numeric controls at both the key and editable layers instead of only validating later | +| `src/ui/widgets/toggle_row.rs` | Schema row factory | Builds switch/combo/entry rows plus explicit compact spin-button suffix controls for numeric schema fields, applies verified friendly titles/help text, surfaces inline validation, refreshes live preview after edits, routes palette-style comma-separated color fields to richer swatch controls, treats free-text entries as commit-based preview updates that only fire after an actual edit, filters typed/pasted junk out of numeric controls at both the key and editable layers instead of only validating later, and now leans on shared page-level config-sync helpers instead of open-coding validation/save-button refreshes | | `src/ui/widgets/hotkey_row.rs` | Hotkey row widget | Keybinding editor rows with a modal edit dialog now using the shared utility-window shell instead of a one-off padded box | | `src/ui/widgets/tool_page.rs` | Tool-page shell builder | Shared custom page hero/section/callout helpers used to move deep pages away from stock preferences layouts, including top-aligned section badges, explicit section-header wrappers, and chip rows that follow the redesign-inspired shell language | -| `src/ui/widgets/color_row.rs` | Color control widget | Hex + color-dialog row with shared live-preview refresh, plus palette-list swatches for comma-separated color fields like `fps_color`, `gpu_load_color`, and `cpu_load_color`; text editing now validates live but only refreshes preview on commit/focus-leave after a real edit, unset swatches now render in a neutral placeholder state, and palette entry fields live in a fixed editor cluster instead of resizing around current content | +| `src/ui/widgets/color_row.rs` | Color control widget | Hex + color-dialog row with shared live-preview refresh, plus palette-list swatches for comma-separated color fields like `fps_color`, `gpu_load_color`, and `cpu_load_color`; text editing now validates live but only refreshes preview on commit/focus-leave after a real edit, unset swatches now render in a neutral placeholder state, palette entry fields live in a fixed editor cluster instead of resizing around current content, and the widget now uses the shared page-level config-sync helpers for validation/save-state refresh | | `src/ui/widgets/hotkey_row.rs` | Hotkey widget | Keybinding capture/edit UI with shared live-preview refresh | | `src/ui/widgets/cascade_view.rs` | Conflict widget | Cascade/layer visualization plus filter-aware visibility helpers | | `src/ui/widgets/validation_label.rs` | Validation helper | Inline row error presentation | @@ -106,9 +110,9 @@ dashboard control change -> in-memory config updates -> `src/preview/mod.rs` wri - Add or change a major window/workflow: `src/window.rs`, `src/ui/pages/mod.rs`, affected page module(s), `data/style.css` - Change a MangoHud option’s validation/type/category: `src/config/schema.rs`, `src/config/help.rs`, `src/config/validator.rs`, affected page in `src/ui/pages/` - Change config parsing or write behavior: `src/config/parser.rs`, `src/config/types.rs`, parser tests -- Change launcher/test-preview behavior: `src/preview/mod.rs`, `src/bin/mangotune-preview.rs`, `src/launcher/runner.rs`, `src/ui/pages/overview.rs`, `data/com.mangotune.MangoTune.gschema.xml`, `Makefile` +- Change launcher/test-preview behavior: `src/preview/mod.rs`, `src/bin/mangotune-preview.rs`, `src/launcher/runner.rs`, `src/ui/pages/overview/preview.rs`, `src/ui/pages/live_preview.rs`, `data/com.mangotune.MangoTune.gschema.xml`, `Makefile` - Change direct MangoHud validation flow: `scripts/mangohud-position-lab.sh`, `scripts/mangohud-position-matrix.sh`, `docs/MANGOHUD_POSITION_LAB.md` -- Change profile behavior: `src/profiles/mod.rs`, `src/ui/pages/overview.rs` +- Change profile behavior: `src/profiles/mod.rs`, `src/ui/pages/overview/profiles.rs`, `src/ui/pages/overview/presets.rs` - Change config-layer/conflict behavior: `src/config/resolver.rs`, `src/ui/pages/conflicts.rs`, `src/window.rs` - Change desktop app settings/preferences: `data/com.mangotune.MangoTune.gschema.xml`, `build.rs`, `src/window.rs` - Change integration strings/behavior: `src/integrations/*.rs`, `src/ui/pages/integrations.rs` @@ -159,7 +163,7 @@ dashboard control change -> in-memory config updates -> `src/preview/mod.rs` wri --- ## 🧪 Test Coverage Map -- Unit tests live inline in Rust modules such as `src/config/parser.rs`, `src/launcher/runner.rs`, `src/profiles/mod.rs`, `src/integrations/steam.rs`, and `src/ui/pages/overview.rs` +- Unit tests live inline in Rust modules such as `src/config/parser.rs`, `src/launcher/runner.rs`, `src/profiles/mod.rs`, `src/integrations/steam.rs`, and `src/ui/pages/overview/mod.rs` - Parser/schema/validator/resolver changes should get module tests updated where the logic lives - Launcher/preview behavior changes usually require tests in `src/launcher/runner.rs`, `src/preview/mod.rs`, `src/bin/mangotune-preview.rs` when practical, and any overview helpers that drive preview state - UI shell/page layout currently has light automated coverage; visual/interaction changes need manual GTK testing in addition to `cargo test` @@ -170,8 +174,8 @@ dashboard control change -> in-memory config updates -> `src/preview/mod.rs` wri - **App shell and navigation**: `src/app.rs`, `src/window.rs`, `src/ui/pages/mod.rs`, `data/style.css` - **MangoHud config engine**: `src/config/` - **Runtime/system detection**: `src/system/` -- **Preview/test launching**: `src/preview/`, `src/bin/mangotune-preview.rs`, `src/launcher/`, `src/ui/pages/overview.rs` -- **Profiles**: `src/profiles/`, `src/ui/pages/overview.rs` +- **Preview/test launching**: `src/preview/`, `src/bin/mangotune-preview.rs`, `src/launcher/`, `src/ui/pages/overview/preview.rs`, `src/ui/pages/live_preview.rs` +- **Profiles**: `src/profiles/`, `src/ui/pages/overview/profiles.rs`, `src/ui/pages/overview/presets.rs` - **Desktop integrations**: `src/integrations/`, `src/ui/pages/integrations.rs` - **Page-specific UI**: `src/ui/pages/` - **Reusable controls**: `src/ui/widgets/` @@ -244,3 +248,5 @@ Do not update this file for: - 2026-03-24 Simplified preview-only right-anchor emulation by dropping fake vertical offsets and adding a small no-margin right-edge safety for horizontal right-aligned layouts - 2026-03-24 Tried preserving native MangoHud right-side positions directly, but reverted that change after it made horizontal previews disappear again; preview-side right-anchor emulation remains the last usable path while MangoHud-native behavior is being investigated separately - 2026-03-24 Added a direct MangoHud position lab and matrix runner so margin/compact/right-anchor behavior can be validated outside MangoTune on a real X11 session, including an isolated-Xorg workflow for `arch.lan` +- 2026-03-31 Split the oversized dashboard module into `src/ui/pages/overview/{cards,preview,presets,profiles}.rs` while keeping the public overview page surface and tests intact +- 2026-03-31 Added shared page-level config-sync helpers plus shared window-level “config replaced” refresh helpers, and used them to deduplicate repeated validation/save/preview refresh logic across overview widgets, shared control rows, color rows, typography/raw-editor/hud-order pages, and top-level reload/revert/reset flows diff --git a/src/ui/pages/hud_order.rs b/src/ui/pages/hud_order.rs index 089c68b..3213113 100644 --- a/src/ui/pages/hud_order.rs +++ b/src/ui/pages/hud_order.rs @@ -1,7 +1,8 @@ -use crate::ui::pages::{apply_live_preview_now, refresh_live_preview_for_key, PageBuildContext}; +use crate::ui::pages::{ + apply_live_preview_now, refresh_live_preview_for_key, sync_config_ui, PageBuildContext, +}; use crate::ui::toast::show_toast; use crate::ui::widgets::tool_page; -use crate::window::{recompute_validation, refresh_save_button}; use gtk4::prelude::*; use mangotune::config::parser::Parser; use mangotune::config::types::ConfigValue; @@ -506,8 +507,7 @@ fn move_item( return; } - recompute_validation(&ctx.state); - refresh_save_button(&ctx.state, &ctx.save_button); + sync_config_ui(ctx); if ctx.preview.running_scene().is_some() { apply_live_preview_now(ctx); } else { diff --git a/src/ui/pages/mod.rs b/src/ui/pages/mod.rs index 9f833ce..81df3cb 100644 --- a/src/ui/pages/mod.rs +++ b/src/ui/pages/mod.rs @@ -1,5 +1,5 @@ use crate::ui::widgets::validation_label; -use crate::window::AppState; +use crate::window::{recompute_validation, refresh_save_button, AppState}; use gtk4::prelude::*; use mangotune::config::help::{display_summary_for_key, display_title_for_key}; use mangotune::config::parser::{flag_defaults_to_enabled, Parser}; @@ -483,6 +483,17 @@ pub fn refresh_registered_validation_rows(ctx: &PageBuildContext) { }); } +pub fn sync_config_ui(ctx: &PageBuildContext) { + recompute_validation(&ctx.state); + refresh_save_button(&ctx.state, &ctx.save_button); +} + +pub fn sync_config_ui_with_validation_rows(ctx: &PageBuildContext) { + recompute_validation(&ctx.state); + refresh_registered_validation_rows(ctx); + refresh_save_button(&ctx.state, &ctx.save_button); +} + fn apply_preview_config_snapshot( ctx: &PageBuildContext, config: &AnnotatedConfig, @@ -544,6 +555,14 @@ pub fn apply_live_preview_now(ctx: &PageBuildContext) { update_live_preview(ctx, PreviewConfigUpdateMode::ImmediateApply); } +pub fn refresh_current_page_later(window: &libadwaita::ApplicationWindow) { + let window = window.clone(); + glib::idle_add_local_once(move || { + let _ = + gtk4::prelude::WidgetExt::activate_action(&window, "win.refresh-current-page", None); + }); +} + pub fn disable_toggle_with_dependents( ctx: &PageBuildContext, key: &str, diff --git a/src/ui/pages/overview.rs b/src/ui/pages/overview.rs deleted file mode 100644 index 4416784..0000000 --- a/src/ui/pages/overview.rs +++ /dev/null @@ -1,3317 +0,0 @@ -use crate::ui::pages::{ - apply_live_preview_now, current_config_snapshot, disable_toggle_with_dependents, - enable_toggle_with_dependencies, refresh_live_preview_for_key, PageBuildContext, -}; -use crate::ui::toast::show_toast; -use crate::ui::widgets::color_utils::{hex_to_rgba, rgba_to_hex}; -use crate::ui::widgets::toggle_row::configure_spin_button_for_option_type; -use crate::ui::widgets::tool_page; -use crate::window::{app_settings, recompute_validation, refresh_save_button}; -use gtk4::pango::EllipsizeMode; -use gtk4::prelude::*; -use libadwaita::prelude::{AlertDialogExt, AlertDialogExtManual}; -use mangotune::config::parser::{flag_defaults_to_enabled, Parser}; -use mangotune::config::schema::MANGOHUD_SCHEMA; -use mangotune::config::types::{ - AnnotatedConfig, Category, ConfigValue, OptionType, ValidationResult, -}; -use mangotune::preview::{ - effective_preview_hud_width, PreviewScene, PreviewStudioOptions, StudioScene, -}; -use mangotune::profiles; -use std::cell::{Cell, RefCell}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::rc::Rc; - -pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { - let chips_owned = [ - format!( - "MangoHud {}", - ctx.system_info - .mangohud - .version - .clone() - .unwrap_or_else(|| "unknown".to_string()) - ), - format!("Display {:?}", ctx.system_info.display_server), - format!("GPU {:?}", ctx.system_info.gpu.vendor), - format!("Config {}", active_config_name(ctx)), - ]; - let chips = chips_owned.iter().map(String::as_str).collect::>(); - let (scroll, root) = tool_page::build_start_page( - "Dashboard", - "Start", - "Start with layout, scale, colors, and the metrics most people toggle first. Start pages cover preview launch and starter presets when you need them.", - &chips, - ); - - let top_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); - top_row.add_css_class("dashboard-row"); - let position = build_position_card(ctx); - position.add_css_class("dashboard-primary-card"); - let appearance = build_appearance_card(ctx); - appearance.add_css_class("dashboard-secondary-card"); - top_row.append(&position); - top_row.append(&appearance); - root.append(&top_row); - - root.append(&build_metrics_card(ctx)); - let profiles = build_profiles_panel(ctx); - profiles.add_css_class("dashboard-footer-card"); - root.append(&profiles); - - root.append(&build_workspace_strip(ctx)); - - scroll -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DashboardPreset { - Benchmark, - Competitive, - Performance, - Streaming, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PreviewProfile { - Balanced, - ThresholdTest, - GpuStress, - CpuStress, - VramTest, -} - -impl PreviewProfile { - fn title(self) -> &'static str { - match self { - Self::Balanced => "Balanced", - Self::ThresholdTest => "Threshold Test", - Self::GpuStress => "GPU Stress", - Self::CpuStress => "CPU Stress", - Self::VramTest => "VRAM Test", - } - } - - fn tooltip(self) -> &'static str { - match self { - Self::Balanced => { - "General-purpose preview defaults with moderate motion and safe load." - } - Self::ThresholdTest => { - "Steadier FPS and mixed CPU/GPU motion for testing threshold colors." - } - Self::GpuStress => { - "Push GPU-related readouts harder with more coverage and redraw work." - } - Self::CpuStress => { - "Raise CPU-related readouts with more particles and simulation work." - } - Self::VramTest => { - "Raise VRAM usage without turning the rest of the preview into a benchmark." - } - } - } - - fn studio(self) -> PreviewStudioOptions { - match self { - Self::Balanced => PreviewStudioOptions::default(), - Self::ThresholdTest => PreviewStudioOptions { - scene: StudioScene::StaticInspection, - fps_cap: Some(60), - vsync: false, - vram_pressure_mb: 256, - particle_count: 2_000, - particle_size: 0.05, - gpu_passes: 2, - interaction_steps: 12, - paused: false, - }, - Self::GpuStress => PreviewStudioOptions { - scene: StudioScene::MotionStress, - fps_cap: None, - vsync: false, - vram_pressure_mb: 256, - particle_count: 2_000, - particle_size: 0.08, - gpu_passes: 4, - interaction_steps: 4, - paused: false, - }, - Self::CpuStress => PreviewStudioOptions { - scene: StudioScene::NoiseField, - fps_cap: Some(120), - vsync: false, - vram_pressure_mb: 128, - particle_count: 8_000, - particle_size: 0.03, - gpu_passes: 1, - interaction_steps: 32, - paused: false, - }, - Self::VramTest => PreviewStudioOptions { - scene: StudioScene::DarkArena, - fps_cap: Some(120), - vsync: false, - vram_pressure_mb: 1024, - particle_count: 1_000, - particle_size: 0.03, - gpu_passes: 1, - interaction_steps: 0, - paused: false, - }, - } - } - - fn all() -> &'static [Self] { - &[ - Self::Balanced, - Self::ThresholdTest, - Self::GpuStress, - Self::CpuStress, - Self::VramTest, - ] - } -} - -impl DashboardPreset { - fn title(self) -> &'static str { - match self { - Self::Benchmark => "Benchmark", - Self::Competitive => "Competitive", - Self::Performance => "Performance", - Self::Streaming => "Streaming", - } - } - - fn description(self) -> &'static str { - match self { - Self::Benchmark => { - "Fuller telemetry for testing runs, frame pacing checks, and API/session verification." - } - Self::Competitive => { - "Lean top-center readout with FPS thresholds and almost no visual bulk." - } - Self::Performance => { - "Centered horizontal monitoring with FPS, frametime, temps, CPU, RAM, and VRAM." - } - Self::Streaming => { - "Cleaner top-center overlay that stays readable on capture without too much noise." - } - } - } - - fn badge(self) -> &'static str { - match self { - Self::Benchmark => "stress pass", - Self::Competitive => "fast glance", - Self::Performance => "monitoring", - Self::Streaming => "capture ready", - } - } - - fn profile_name(self) -> &'static str { - match self { - Self::Benchmark => "Benchmark", - Self::Competitive => "Competitive", - Self::Performance => "Performance", - Self::Streaming => "Streaming", - } - } -} - -fn studio_scene_title(scene: StudioScene) -> &'static str { - match scene { - StudioScene::DarkArena => "Dark Arena", - StudioScene::BrightWash => "Bright Wash", - StudioScene::MotionStress => "Motion Stress", - StudioScene::StaticInspection => "Static Inspection", - StudioScene::NoiseField => "Noise Field", - } -} - -fn preview_group(title: &str, description: &str) -> (gtk4::Box, gtk4::Box) { - let group = gtk4::Box::new(gtk4::Orientation::Vertical, 8); - group.add_css_class("dashboard-status-panel"); - group.add_css_class("preview-group"); - - let heading = gtk4::Label::new(Some(title)); - heading.add_css_class("dashboard-field-label"); - heading.set_xalign(0.0); - - let subtitle = gtk4::Label::new(Some(description)); - subtitle.add_css_class("dim-label"); - subtitle.set_wrap(true); - subtitle.set_xalign(0.0); - - let body = gtk4::Box::new(gtk4::Orientation::Vertical, 8); - body.add_css_class("preview-group-body"); - - group.append(&heading); - group.append(&subtitle); - group.append(&body); - (group, body) -} - -fn configure_preview_spin( - spin: >k4::SpinButton, - option_type: &OptionType, - tooltip: &str, - digits: u32, -) { - spin.add_css_class("control-field"); - spin.add_css_class("preview-fixed-spin"); - spin.set_hexpand(false); - spin.set_halign(gtk4::Align::End); - spin.set_size_request(108, -1); - configure_spin_button_for_option_type(spin, option_type); - spin.set_digits(digits); - spin.set_tooltip_text(Some(tooltip)); - install_scroll_passthrough(spin.upcast_ref()); -} - -fn preview_vram_total_mb(ctx: &PageBuildContext) -> u32 { - ctx.system_info - .gpu - .total_vram_mb - .unwrap_or(4_096) - .clamp(512, 65_536) -} - -fn preview_vram_used_mb(ctx: &PageBuildContext) -> Option { - ctx.system_info - .gpu - .used_vram_mb - .map(|mb| mb.clamp(0, preview_vram_total_mb(ctx))) -} - -fn preview_vram_safe_max_mb(ctx: &PageBuildContext) -> u32 { - let total = preview_vram_total_mb(ctx); - let reserve_mb = ((total as f64) * 0.10).ceil() as u32; - let reserve_mb = reserve_mb.max(1_024).min(total); - - let free_budget_mb = preview_vram_used_mb(ctx) - .map(|used| total.saturating_sub(used).saturating_sub(reserve_mb)) - .unwrap_or_else(|| total.saturating_sub(reserve_mb)); - - // The preview tends to use more VRAM than the raw pressure setting alone, - // so keep Safe max deliberately conservative. - let conservative_target = free_budget_mb / 2; - let rounded = (conservative_target / 256) * 256; - rounded.clamp(0, total) -} - -fn format_mib_as_gib_text(mb: u32) -> String { - format!("{:.1} GiB", mb as f64 / 1024.0) -} - -fn default_preview_window_size() -> (i32, i32) { - (1280, 720) -} - -fn persist_studio_options(studio: &PreviewStudioOptions) { - persist_preview_studio_scene(studio.scene); - persist_studio_fps_cap(studio.fps_cap.unwrap_or(0) as i32); - persist_preview_vsync(studio.vsync); - persist_studio_vram_pressure(studio.vram_pressure_mb as i32); - persist_studio_particle_count(studio.particle_count as i32); - persist_studio_particle_size(studio.particle_size as f64); - persist_studio_gpu_passes(studio.gpu_passes as i32); - persist_studio_interaction_steps(studio.interaction_steps as i32); -} - -pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { - let panel = gtk4::Box::new(gtk4::Orientation::Vertical, 10); - panel.add_css_class("dashboard-stack"); - - let detected_vram_total_mb = preview_vram_total_mb(ctx); - let detected_vram_safe_mb = preview_vram_safe_max_mb(ctx); - let mut initial_studio = preferred_studio_options(); - initial_studio.vram_pressure_mb = initial_studio.vram_pressure_mb.min(detected_vram_total_mb); - let studio_defaults = Rc::new(RefCell::new(initial_studio)); - let control_sync = Rc::new(Cell::new(false)); - - let (session_group, session_body) = preview_group( - "Preview session", - "Start, update, restart, or stop the built-in Studio preview. These changes stay isolated from your saved MangoHud config.", - ); - let status_label = gtk4::Label::new(None); - status_label.set_xalign(0.0); - status_label.set_wrap(true); - status_label.add_css_class("preview-status"); - let status_hint = gtk4::Label::new(Some( - "The preview uses your current unsaved workspace state. Save only when you want to update the real config.", - )); - status_hint.add_css_class("dim-label"); - status_hint.set_wrap(true); - status_hint.set_xalign(0.0); - session_body.append(&status_label); - session_body.append(&status_hint); - - let buttons = gtk4::Grid::new(); - buttons.add_css_class("dashboard-preview-actions"); - buttons.set_column_spacing(8); - buttons.set_column_homogeneous(true); - buttons.set_hexpand(true); - let start_button = gtk4::Button::with_label("Start"); - start_button.add_css_class("suggested-action"); - let reload_button = gtk4::Button::with_label("Apply"); - let restart_button = gtk4::Button::with_label("Restart"); - let stop_button = gtk4::Button::with_label("Stop"); - start_button.set_tooltip_text(Some( - "Launch the Studio preview window with your current unsaved MangoHud workspace and the current Studio runtime controls.", - )); - reload_button.set_tooltip_text(Some( - "Push your current unsaved workspace and current Studio runtime controls into the already running preview without restarting it.", - )); - restart_button.set_tooltip_text(Some( - "Restart the Studio preview window so settings that need a fresh launch, like window size, take effect.", - )); - stop_button.set_tooltip_text(Some("Stop the Studio preview window.")); - start_button.set_hexpand(true); - reload_button.set_hexpand(true); - restart_button.set_hexpand(true); - stop_button.set_hexpand(true); - buttons.attach(&start_button, 0, 0, 1, 1); - buttons.attach(&reload_button, 1, 0, 1, 1); - buttons.attach(&restart_button, 2, 0, 1, 1); - buttons.attach(&stop_button, 3, 0, 1, 1); - session_body.append(&buttons); - - let (window_group, window_body) = preview_group( - "Window", - "Used for the built-in Studio preview window. Size changes still require a restart.", - ); - let (preferred_width, preferred_height) = preferred_preview_window_size(); - let size_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - size_controls.add_css_class("dashboard-inline-row"); - size_controls.add_css_class("preview-size-row"); - size_controls.set_valign(gtk4::Align::Center); - let size_label = gtk4::Label::new(Some("Size")); - size_label.add_css_class("dashboard-field-label"); - size_label.set_xalign(0.0); - size_label.set_hexpand(true); - let width_adjustment = - gtk4::Adjustment::new(preferred_width as f64, 640.0, 3840.0, 20.0, 100.0, 0.0); - let width_spin = gtk4::SpinButton::new(Some(&width_adjustment), 1.0, 0); - configure_preview_spin( - &width_spin, - &OptionType::Int { - min: 640, - max: 3840, - }, - "Preview window width in pixels.", - 0, - ); - let size_separator = gtk4::Label::new(Some("×")); - size_separator.add_css_class("dashboard-value-label"); - let height_adjustment = - gtk4::Adjustment::new(preferred_height as f64, 360.0, 2160.0, 20.0, 100.0, 0.0); - let height_spin = gtk4::SpinButton::new(Some(&height_adjustment), 1.0, 0); - configure_preview_spin( - &height_spin, - &OptionType::Int { - min: 360, - max: 2160, - }, - "Preview window height in pixels.", - 0, - ); - size_controls.append(&size_label); - size_controls.append(&width_spin); - size_controls.append(&size_separator); - size_controls.append(&height_spin); - window_body.append(&size_controls); - - let (backdrop_group, backdrop_body) = preview_group( - "Backdrop and flow", - "Choose the scene feel and whether the preview should stay animated or sync to your display.", - ); - let scene_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - scene_row.add_css_class("dashboard-preview-subsection"); - let scene_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - scene_controls.add_css_class("dashboard-inline-row"); - scene_controls.set_valign(gtk4::Align::Center); - let scene_label = gtk4::Label::new(Some("Scene")); - scene_label.add_css_class("dashboard-field-label"); - scene_label.set_xalign(0.0); - scene_label.set_hexpand(true); - let scene_labels = StudioScene::all() - .iter() - .map(|scene| studio_scene_title(*scene)) - .collect::>(); - let scene_dropdown = gtk4::DropDown::from_strings(&scene_labels); - scene_dropdown.add_css_class("control-field"); - scene_dropdown.add_css_class("preview-scene-control"); - scene_dropdown.set_hexpand(false); - scene_dropdown.set_halign(gtk4::Align::End); - scene_dropdown.set_size_request(180, -1); - let initial_scene_index = StudioScene::all() - .iter() - .position(|scene| *scene == studio_defaults.borrow().scene) - .unwrap_or(0) as u32; - scene_dropdown.set_selected(initial_scene_index); - scene_dropdown.set_tooltip_text(Some( - "Pick the kind of test backdrop you want. Dark Arena is the general default, Bright Wash is for contrast checks, Motion Stress is the busiest, Static Inspection stays calmer, and Noise Field spreads more motion across the screen.", - )); - let scene_hint = gtk4::Label::new(Some( - "Changes the preview backdrop and camera feel without touching your MangoHud config.", - )); - scene_hint.add_css_class("dim-label"); - scene_hint.set_xalign(0.0); - scene_controls.append(&scene_label); - scene_controls.append(&scene_dropdown); - scene_row.append(&scene_controls); - scene_row.append(&scene_hint); - backdrop_body.append(&scene_row); - - let vsync_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - vsync_row.add_css_class("dashboard-inline-row"); - let vsync_label = gtk4::Label::new(Some("VSync")); - vsync_label.add_css_class("dashboard-field-label"); - vsync_label.set_xalign(0.0); - vsync_label.set_hexpand(true); - let vsync_switch = gtk4::Switch::new(); - vsync_switch.set_active(studio_defaults.borrow().vsync); - vsync_switch.set_tooltip_text(Some( - "Match the preview to your display refresh. Turning this on can lower FPS and GPU usage, which is useful when you want steadier readouts.", - )); - vsync_row.append(&vsync_label); - vsync_row.append(&vsync_switch); - backdrop_body.append(&vsync_row); - - let pause_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - pause_row.add_css_class("dashboard-inline-row"); - let pause_label = gtk4::Label::new(Some("Pause motion")); - pause_label.add_css_class("dashboard-field-label"); - pause_label.set_xalign(0.0); - pause_label.set_hexpand(true); - let pause_switch = gtk4::Switch::new(); - pause_switch.set_active(studio_defaults.borrow().paused); - pause_switch.set_tooltip_text(Some( - "Freeze the motion so you can inspect the HUD without the scene moving underneath it.", - )); - pause_row.append(&pause_label); - pause_row.append(&pause_switch); - backdrop_body.append(&pause_row); - - let (gpu_group, gpu_body) = preview_group( - "GPU and frame load", - "These controls mostly affect GPU usage, redraw cost, FPS, and overall screen coverage.", - ); - let fps_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - fps_row.add_css_class("dashboard-preview-subsection"); - let fps_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - fps_controls.add_css_class("dashboard-inline-row"); - fps_controls.set_valign(gtk4::Align::Center); - let fps_label = gtk4::Label::new(Some("FPS cap")); - fps_label.add_css_class("dashboard-field-label"); - fps_label.set_xalign(0.0); - fps_label.set_hexpand(true); - let fps_adjustment = gtk4::Adjustment::new( - studio_defaults.borrow().fps_cap.unwrap_or(0) as f64, - 0.0, - 1000.0, - 25.0, - 50.0, - 0.0, - ); - let fps_spin = gtk4::SpinButton::new(Some(&fps_adjustment), 1.0, 0); - configure_preview_spin( - &fps_spin, - &OptionType::Int { min: 0, max: 1000 }, - "Caps how fast the preview runs. Lower values make threshold testing easier. Set 0 only if you want the preview to run flat-out.", - 0, - ); - let fps_hint = gtk4::Label::new(Some("0 = uncapped and will usually max the GPU")); - fps_hint.add_css_class("dim-label"); - fps_hint.set_xalign(0.0); - fps_controls.append(&fps_label); - fps_controls.append(&fps_spin); - fps_row.append(&fps_controls); - fps_row.append(&fps_hint); - gpu_body.append(&fps_row); - - let particle_size_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - particle_size_row.add_css_class("dashboard-preview-subsection"); - let particle_size_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - particle_size_controls.add_css_class("dashboard-inline-row"); - particle_size_controls.set_valign(gtk4::Align::Center); - let particle_size_label = gtk4::Label::new(Some("Particle size")); - particle_size_label.add_css_class("dashboard-field-label"); - particle_size_label.set_xalign(0.0); - particle_size_label.set_hexpand(true); - let particle_size_adjustment = gtk4::Adjustment::new( - studio_defaults.borrow().particle_size as f64, - 0.01, - 5.0, - 0.01, - 0.10, - 0.0, - ); - let particle_size_spin = gtk4::SpinButton::new(Some(&particle_size_adjustment), 0.01, 2); - configure_preview_spin( - &particle_size_spin, - &OptionType::Float { min: 0.01, max: 5.0 }, - "How large each particle appears on screen. Bigger particles cover more of the window and usually raise GPU cost the fastest.", - 2, - ); - particle_size_controls.append(&particle_size_label); - particle_size_controls.append(&particle_size_spin); - particle_size_row.append(&particle_size_controls); - gpu_body.append(&particle_size_row); - - let gpu_passes_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - gpu_passes_row.add_css_class("dashboard-preview-subsection"); - let gpu_passes_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - gpu_passes_controls.add_css_class("dashboard-inline-row"); - gpu_passes_controls.set_valign(gtk4::Align::Center); - let gpu_passes_label = gtk4::Label::new(Some("GPU passes")); - gpu_passes_label.add_css_class("dashboard-field-label"); - gpu_passes_label.set_xalign(0.0); - gpu_passes_label.set_hexpand(true); - let gpu_passes_adjustment = gtk4::Adjustment::new( - studio_defaults.borrow().gpu_passes as f64, - 1.0, - 64.0, - 1.0, - 4.0, - 0.0, - ); - let gpu_passes_spin = gtk4::SpinButton::new(Some(&gpu_passes_adjustment), 1.0, 0); - configure_preview_spin( - &gpu_passes_spin, - &OptionType::Int { min: 1, max: 64 }, - "How many extra times the preview redraws the scene each frame. This is the most direct way to push GPU load higher.", - 0, - ); - gpu_passes_controls.append(&gpu_passes_label); - gpu_passes_controls.append(&gpu_passes_spin); - gpu_passes_row.append(&gpu_passes_controls); - gpu_body.append(&gpu_passes_row); - - let vram_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - vram_row.add_css_class("dashboard-preview-subsection"); - let vram_controls = gtk4::Grid::new(); - vram_controls.add_css_class("dashboard-inline-grid"); - vram_controls.set_column_spacing(12); - vram_controls.set_row_spacing(6); - let vram_label = gtk4::Label::new(Some("VRAM pressure")); - vram_label.add_css_class("dashboard-field-label"); - vram_label.set_xalign(0.0); - let vram_hint_text = - match (ctx.system_info.gpu.total_vram_mb, ctx.system_info.gpu.used_vram_mb) { - (Some(total), Some(used)) => format!( - "Detected VRAM: {} total, {} already in use. Safe max: {}.", - format_mib_as_gib_text(total), - format_mib_as_gib_text(used.min(total)), - format_mib_as_gib_text(detected_vram_safe_mb) - ), - (Some(total), None) => format!( - "Detected VRAM: {} total. Safe max: {}. Current VRAM use could not be read, so this uses a conservative fallback.", - format_mib_as_gib_text(total), - format_mib_as_gib_text(detected_vram_safe_mb) - ), - (None, _) => format!( - "GPU memory could not be detected. Safe max: {} using a conservative fallback.", - format_mib_as_gib_text(detected_vram_safe_mb) - ), - }; - let vram_hint = gtk4::Label::new(Some(&vram_hint_text)); - vram_hint.add_css_class("dim-label"); - vram_hint.set_xalign(0.0); - let vram_label_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2); - vram_label_box.set_hexpand(true); - vram_label_box.append(&vram_label); - vram_label_box.append(&vram_hint); - let vram_adjustment = gtk4::Adjustment::new( - studio_defaults.borrow().vram_pressure_mb as f64, - 0.0, - detected_vram_total_mb as f64, - 16.0, - 64.0, - 0.0, - ); - let vram_spin = gtk4::SpinButton::new(Some(&vram_adjustment), 1.0, 0); - configure_preview_spin( - &vram_spin, - &OptionType::Int { - min: 0, - max: detected_vram_total_mb as i64, - }, - "Reserves GPU memory so you can see VRAM readouts change. Use Safe max if you want a high value without risking the preview.", - 0, - ); - let safe_vram_button = gtk4::Button::with_label("Safe max"); - safe_vram_button.add_css_class("shell-strip-button"); - safe_vram_button.set_tooltip_text(Some( - "Set VRAM pressure to a conservative high value based on detected total VRAM, current VRAM use, and reserved safety headroom.", - )); - { - let vram_spin = vram_spin.clone(); - safe_vram_button.connect_clicked(move |_| { - vram_spin.set_value(detected_vram_safe_mb as f64); - }); - } - vram_controls.attach(&vram_label_box, 0, 0, 1, 2); - vram_controls.attach(&vram_spin, 1, 0, 1, 1); - vram_controls.attach(&safe_vram_button, 1, 1, 1, 1); - vram_row.append(&vram_controls); - gpu_body.append(&vram_row); - - let (cpu_group, cpu_body) = preview_group( - "CPU and density", - "These controls mostly affect particle count, simulation work, and how busy the preview feels over time.", - ); - let particle_count_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - particle_count_row.add_css_class("dashboard-preview-subsection"); - let particle_count_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - particle_count_controls.add_css_class("dashboard-inline-row"); - particle_count_controls.set_valign(gtk4::Align::Center); - let particle_count_label = gtk4::Label::new(Some("Particle count")); - particle_count_label.add_css_class("dashboard-field-label"); - particle_count_label.set_xalign(0.0); - particle_count_label.set_hexpand(true); - let particle_count_adjustment = gtk4::Adjustment::new( - studio_defaults.borrow().particle_count as f64, - 100.0, - 500_000.0, - 100.0, - 5_000.0, - 0.0, - ); - let particle_count_spin = gtk4::SpinButton::new(Some(&particle_count_adjustment), 1.0, 0); - configure_preview_spin( - &particle_count_spin, - &OptionType::Int { - min: 100, - max: 500_000, - }, - "How many particles the preview simulates. More particles usually raise CPU work first, then GPU cost as the scene gets denser.", - 0, - ); - let particle_count_hint = - gtk4::Label::new(Some("Lower this first if the preview is too heavy.")); - particle_count_hint.add_css_class("dim-label"); - particle_count_hint.set_xalign(0.0); - particle_count_controls.append(&particle_count_label); - particle_count_controls.append(&particle_count_spin); - particle_count_row.append(&particle_count_controls); - particle_count_row.append(&particle_count_hint); - cpu_body.append(&particle_count_row); - - let interaction_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - interaction_row.add_css_class("dashboard-preview-subsection"); - let interaction_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - interaction_controls.add_css_class("dashboard-inline-row"); - interaction_controls.set_valign(gtk4::Align::Center); - let interaction_label = gtk4::Label::new(Some("CPU interaction")); - interaction_label.add_css_class("dashboard-field-label"); - interaction_label.set_xalign(0.0); - interaction_label.set_hexpand(true); - let interaction_adjustment = gtk4::Adjustment::new( - studio_defaults.borrow().interaction_steps as f64, - 0.0, - 256.0, - 1.0, - 8.0, - 0.0, - ); - let interaction_spin = gtk4::SpinButton::new(Some(&interaction_adjustment), 1.0, 0); - configure_preview_spin( - &interaction_spin, - &OptionType::Int { min: 0, max: 256 }, - "Adds extra simulation work on the CPU. Raise this when you want CPU-related MangoHud colors and graphs to move more.", - 0, - ); - interaction_controls.append(&interaction_label); - interaction_controls.append(&interaction_spin); - interaction_row.append(&interaction_controls); - cpu_body.append(&interaction_row); - - let (setup_group, setup_body) = preview_group( - "Preview setups", - "Apply a built-in workload shape quickly, or reset everything back to the Studio defaults.", - ); - let profile_rows = gtk4::Grid::new(); - profile_rows.add_css_class("preview-profile-grid"); - profile_rows.set_column_spacing(6); - profile_rows.set_row_spacing(6); - for (index, profile) in PreviewProfile::all().iter().enumerate() { - let button = gtk4::Button::with_label(profile.title()); - button.add_css_class("dashboard-preset-button"); - button.add_css_class("preview-profile-button"); - button.set_hexpand(true); - button.set_tooltip_text(Some(profile.tooltip())); - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - let studio_defaults = studio_defaults.clone(); - let control_sync = control_sync.clone(); - let scene_dropdown = scene_dropdown.clone(); - let fps_spin = fps_spin.clone(); - let particle_count_spin = particle_count_spin.clone(); - let particle_size_spin = particle_size_spin.clone(); - let gpu_passes_spin = gpu_passes_spin.clone(); - let interaction_spin = interaction_spin.clone(); - let vram_spin = vram_spin.clone(); - let vsync_switch = vsync_switch.clone(); - let pause_switch = pause_switch.clone(); - button.connect_clicked(move |_| { - let studio = profile.studio(); - studio_defaults.replace(studio.clone()); - persist_studio_options(&studio); - control_sync.set(true); - if let Some(index) = StudioScene::all() - .iter() - .position(|scene| *scene == studio.scene) - { - scene_dropdown.set_selected(index as u32); - } - fps_spin.set_value(studio.fps_cap.unwrap_or(0) as f64); - particle_count_spin.set_value(studio.particle_count as f64); - particle_size_spin.set_value(studio.particle_size as f64); - gpu_passes_spin.set_value(studio.gpu_passes as f64); - interaction_spin.set_value(studio.interaction_steps as f64); - vram_spin.set_value(studio.vram_pressure_mb as f64); - vsync_switch.set_active(studio.vsync); - pause_switch.set_active(studio.paused); - control_sync.set(false); - maybe_apply_studio_preview_runtime( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio, - ); - }); - profile_rows.attach(&button, (index % 2) as i32, (index / 2) as i32, 1, 1); - } - let reset_preview_button = gtk4::Button::with_label("Reset Preview Defaults"); - reset_preview_button.add_css_class("dashboard-preset-button"); - let ctx_reset = ctx.clone(); - let start_button_reset = start_button.clone(); - let status_label_reset = status_label.clone(); - let reload_button_reset = reload_button.clone(); - let restart_button_reset = restart_button.clone(); - let stop_button_reset = stop_button.clone(); - let studio_defaults_reset = studio_defaults.clone(); - let control_sync_reset = control_sync.clone(); - let scene_dropdown_reset = scene_dropdown.clone(); - let fps_spin_reset = fps_spin.clone(); - let particle_count_spin_reset = particle_count_spin.clone(); - let particle_size_spin_reset = particle_size_spin.clone(); - let gpu_passes_spin_reset = gpu_passes_spin.clone(); - let interaction_spin_reset = interaction_spin.clone(); - let vram_spin_reset = vram_spin.clone(); - let vsync_switch_reset = vsync_switch.clone(); - let pause_switch_reset = pause_switch.clone(); - let width_spin_reset = width_spin.clone(); - let height_spin_reset = height_spin.clone(); - reset_preview_button.connect_clicked(move |_| { - let studio = PreviewStudioOptions::default(); - let (default_width, default_height) = default_preview_window_size(); - studio_defaults_reset.replace(studio.clone()); - persist_studio_options(&studio); - persist_preview_window_width(default_width); - persist_preview_window_height(default_height); - control_sync_reset.set(true); - if let Some(index) = StudioScene::all() - .iter() - .position(|scene| *scene == studio.scene) - { - scene_dropdown_reset.set_selected(index as u32); - } - fps_spin_reset.set_value(studio.fps_cap.unwrap_or(0) as f64); - particle_count_spin_reset.set_value(studio.particle_count as f64); - particle_size_spin_reset.set_value(studio.particle_size as f64); - gpu_passes_spin_reset.set_value(studio.gpu_passes as f64); - interaction_spin_reset.set_value(studio.interaction_steps as f64); - vram_spin_reset.set_value(studio.vram_pressure_mb as f64); - vsync_switch_reset.set_active(studio.vsync); - pause_switch_reset.set_active(studio.paused); - width_spin_reset.set_value(default_width as f64); - height_spin_reset.set_value(default_height as f64); - control_sync_reset.set(false); - maybe_restart_active_preview( - &ctx_reset, - &start_button_reset, - &status_label_reset, - &reload_button_reset, - &restart_button_reset, - &stop_button_reset, - studio, - ); - }); - setup_body.append(&profile_rows); - setup_body.append(&reset_preview_button); - - panel.append(&session_group); - panel.append(&window_group); - panel.append(&setup_group); - panel.append(&backdrop_group); - panel.append(&gpu_group); - panel.append(&cpu_group); - - refresh_preview_widgets( - ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - ); - - { - let studio_defaults = studio_defaults.clone(); - let control_sync = control_sync.clone(); - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - scene_dropdown.connect_selected_notify(move |dropdown| { - if control_sync.get() { - return; - } - let Some(scene) = StudioScene::all() - .get(dropdown.selected() as usize) - .copied() - else { - return; - }; - let mut studio = studio_defaults.borrow_mut(); - studio.scene = scene; - persist_preview_studio_scene(scene); - maybe_apply_studio_preview_runtime( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio.clone(), - ); - }); - } - - { - let studio_defaults = studio_defaults.clone(); - let control_sync = control_sync.clone(); - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - fps_spin.connect_value_changed(move |spin| { - if control_sync.get() { - return; - } - let value = spin.value().round().clamp(0.0, 1000.0) as i32; - let mut studio = studio_defaults.borrow_mut(); - studio.fps_cap = if value <= 0 { None } else { Some(value as u32) }; - persist_studio_fps_cap(value); - maybe_apply_studio_preview_runtime( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio.clone(), - ); - }); - } - - { - let studio_defaults = studio_defaults.clone(); - let control_sync = control_sync.clone(); - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - particle_count_spin.connect_value_changed(move |spin| { - if control_sync.get() { - return; - } - let count = spin.value().round().clamp(100.0, 500_000.0) as i32; - let mut studio = studio_defaults.borrow_mut(); - studio.particle_count = count as u32; - persist_studio_particle_count(count); - maybe_apply_studio_preview_runtime( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio.clone(), - ); - }); - } - - { - let studio_defaults = studio_defaults.clone(); - let control_sync = control_sync.clone(); - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - particle_size_spin.connect_value_changed(move |spin| { - if control_sync.get() { - return; - } - let size = spin.value().clamp(0.01, 5.0); - let mut studio = studio_defaults.borrow_mut(); - studio.particle_size = size as f32; - persist_studio_particle_size(size); - maybe_apply_studio_preview_runtime( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio.clone(), - ); - }); - } - - { - let studio_defaults = studio_defaults.clone(); - let control_sync = control_sync.clone(); - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - gpu_passes_spin.connect_value_changed(move |spin| { - if control_sync.get() { - return; - } - let passes = spin.value().round().clamp(1.0, 64.0) as i32; - let mut studio = studio_defaults.borrow_mut(); - studio.gpu_passes = passes as u32; - persist_studio_gpu_passes(passes); - maybe_apply_studio_preview_runtime( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio.clone(), - ); - }); - } - - { - let studio_defaults = studio_defaults.clone(); - let control_sync = control_sync.clone(); - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - interaction_spin.connect_value_changed(move |spin| { - if control_sync.get() { - return; - } - let steps = spin.value().round().clamp(0.0, 256.0) as i32; - let mut studio = studio_defaults.borrow_mut(); - studio.interaction_steps = steps as u32; - persist_studio_interaction_steps(steps); - maybe_apply_studio_preview_runtime( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio.clone(), - ); - }); - } - - { - let studio_defaults = studio_defaults.clone(); - let control_sync = control_sync.clone(); - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - vram_spin.connect_value_changed(move |spin| { - if control_sync.get() { - return; - } - let mb = spin.value().round().clamp(0.0, 4096.0) as i32; - let mut studio = studio_defaults.borrow_mut(); - studio.vram_pressure_mb = mb as u32; - persist_studio_vram_pressure(mb); - maybe_apply_studio_preview_runtime( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio.clone(), - ); - }); - } - - { - let studio_defaults = studio_defaults.clone(); - let control_sync = control_sync.clone(); - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - vsync_switch.connect_active_notify(move |switch| { - if control_sync.get() { - return; - } - let mut studio = studio_defaults.borrow_mut(); - studio.vsync = switch.is_active(); - persist_preview_vsync(switch.is_active()); - maybe_apply_studio_preview_runtime( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio.clone(), - ); - }); - } - - { - let studio_defaults = studio_defaults.clone(); - let control_sync = control_sync.clone(); - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - pause_switch.connect_active_notify(move |switch| { - if control_sync.get() { - return; - } - let mut studio = studio_defaults.borrow_mut(); - studio.paused = switch.is_active(); - maybe_apply_studio_preview_runtime( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio.clone(), - ); - }); - } - - { - let ctx = ctx.clone(); - let control_sync = control_sync.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - let studio_defaults = studio_defaults.clone(); - width_spin.connect_value_changed(move |spin| { - if control_sync.get() { - return; - } - let width = spin.value().round().clamp(640.0, 3840.0) as i32; - persist_preview_window_width(width); - maybe_restart_active_preview( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio_defaults.borrow().clone(), - ); - }); - } - - { - let ctx = ctx.clone(); - let control_sync = control_sync.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - let studio_defaults = studio_defaults.clone(); - height_spin.connect_value_changed(move |spin| { - if control_sync.get() { - return; - } - let height = spin.value().round().clamp(360.0, 2160.0) as i32; - persist_preview_window_height(height); - maybe_restart_active_preview( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - studio_defaults.borrow().clone(), - ); - }); - } - - { - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - let studio_defaults = studio_defaults.clone(); - start_button.clone().connect_clicked(move |_| { - let config = current_config_snapshot(&ctx); - let (width, height) = preview_window_settings(PreviewScene::Studio, &config); - let studio = studio_defaults.borrow().clone(); - - match ctx - .preview - .start(PreviewScene::Studio, &config, width, height, false, studio) - { - Ok(pid) => { - show_toast( - &ctx.toast_overlay, - &format!("Started live preview (pid {pid})"), - ); - refresh_preview_widgets( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - ); - } - Err(err) => show_toast(&ctx.toast_overlay, &format!("Preview failed: {err}")), - } - }); - } - - { - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - reload_button.clone().connect_clicked(move |_| { - let config = current_config_snapshot(&ctx); - match ctx.preview.apply_live_config(&config) { - Ok(pid) => { - show_toast( - &ctx.toast_overlay, - &format!("Applied current config to live preview (pid {pid})"), - ); - refresh_preview_widgets( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - ); - } - Err(err) => show_toast(&ctx.toast_overlay, &format!("Apply failed: {err}")), - } - }); - } - - { - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - restart_button.clone().connect_clicked(move |_| { - let config = current_config_snapshot(&ctx); - match ctx.preview.restart(&config) { - Ok(pid) => { - show_toast( - &ctx.toast_overlay, - &format!("Restarted live preview (pid {pid})"), - ); - refresh_preview_widgets( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - ); - } - Err(err) => show_toast(&ctx.toast_overlay, &format!("Restart failed: {err}")), - } - }); - } - - { - let ctx = ctx.clone(); - let start_button = start_button.clone(); - let status_label = status_label.clone(); - let reload_button = reload_button.clone(); - let restart_button = restart_button.clone(); - let stop_button = stop_button.clone(); - stop_button.clone().connect_clicked(move |_| { - match ctx.preview.stop() { - Ok(true) => show_toast(&ctx.toast_overlay, "Stopped live preview"), - Ok(false) => show_toast(&ctx.toast_overlay, "Preview was not running"), - Err(err) => show_toast(&ctx.toast_overlay, &format!("Stop failed: {err}")), - } - refresh_preview_widgets( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - ); - }); - } - - { - let ctx = ctx.clone(); - let start_button = start_button.downgrade(); - let status_label = status_label.downgrade(); - let reload_button = reload_button.downgrade(); - let restart_button = restart_button.downgrade(); - let stop_button = stop_button.downgrade(); - glib::timeout_add_seconds_local(1, move || { - let ( - Some(start_button), - Some(status_label), - Some(reload_button), - Some(restart_button), - Some(stop_button), - ) = ( - start_button.upgrade(), - status_label.upgrade(), - reload_button.upgrade(), - restart_button.upgrade(), - stop_button.upgrade(), - ) - else { - return glib::ControlFlow::Break; - }; - refresh_preview_widgets( - &ctx, - &start_button, - &status_label, - &reload_button, - &restart_button, - &stop_button, - ); - glib::ControlFlow::Continue - }); - } - - panel -} - -fn build_position_card(ctx: &PageBuildContext) -> gtk4::Box { - let card = dashboard_card(); - card.add_css_class("dashboard-position-card"); - card.append(&card_header( - "Layout & Position", - "Click where the overlay should anchor, then nudge offsets until it sits exactly where you want.", - )); - - let monitor = gtk4::Grid::new(); - monitor.add_css_class("position-grid"); - monitor.add_css_class("position-grid-compact"); - monitor.set_row_spacing(8); - monitor.set_column_spacing(8); - monitor.set_halign(gtk4::Align::Center); - - let current = current_string_value(ctx, "position").unwrap_or_else(|| "top-left".to_string()); - let buttons: Rc>> = Rc::new(RefCell::new(Vec::new())); - for (label, position, column, row) in [ - ("↖", "top-left", 0, 0), - ("↑", "top-center", 1, 0), - ("↗", "top-right", 2, 0), - ("←", "middle-left", 0, 1), - ("→", "middle-right", 2, 1), - ("↙", "bottom-left", 0, 2), - ("↓", "bottom-center", 1, 2), - ("↘", "bottom-right", 2, 2), - ] { - let button = gtk4::Button::with_label(label); - button.add_css_class("position-node"); - button.set_tooltip_text(Some(position)); - if current == position { - button.add_css_class("position-node-active"); - } - monitor.attach(&button, column, row, 1, 1); - buttons - .borrow_mut() - .push((position.to_string(), button.clone())); - - let ctx = ctx.clone(); - let buttons = buttons.clone(); - let selected = position.to_string(); - button.connect_clicked(move |_| { - set_config_value(&ctx, "position", ConfigValue::Value(selected.clone())); - update_position_buttons(&buttons.borrow(), &selected); - maybe_reload_preview_for_key(&ctx, "position"); - }); - } - - let center = gtk4::Label::new(Some("HUD")); - center.add_css_class("position-center"); - center.set_halign(gtk4::Align::Center); - center.set_valign(gtk4::Align::Center); - monitor.attach(¢er, 1, 1, 1, 1); - - card.append(&monitor); - card.append(&build_scale_control( - "Horizontal offset", - "offset_x", - 0.0, - 500.0, - 1.0, - 0, - ctx, - OffsetAxis::Horizontal, - )); - card.append(&build_scale_control( - "Vertical offset", - "offset_y", - 0.0, - 500.0, - 1.0, - 0, - ctx, - OffsetAxis::Vertical, - )); - card.append(&build_width_control(ctx)); - card.append(&build_scale_control( - "Table columns", - "table_columns", - 1.0, - 12.0, - 1.0, - 0, - ctx, - OffsetAxis::Raw, - )); - card -} - -fn build_appearance_card(ctx: &PageBuildContext) -> gtk4::Box { - let card = dashboard_card(); - card.append(&card_header( - "Make It Readable", - "Keep the overlay legible at a glance. The sidebar still exposes every detailed MangoHud option when you need more control.", - )); - - card.append(&build_scale_control( - "Font size", - "font_size", - 8.0, - 36.0, - 1.0, - 0, - ctx, - OffsetAxis::Raw, - )); - card.append(&build_scale_control( - "Font scale", - "font_scale", - 0.5, - 2.0, - 0.05, - 2, - ctx, - OffsetAxis::Raw, - )); - card.append(&build_scale_control( - "Background opacity", - "background_alpha", - 0.0, - 1.0, - 0.01, - 2, - ctx, - OffsetAxis::Raw, - )); - card.append(&build_scale_control( - "Overall alpha", - "alpha", - 0.0, - 1.0, - 0.01, - 2, - ctx, - OffsetAxis::Raw, - )); - card.append(&build_scale_control( - "Corner radius", - "round_corners", - 0.0, - 50.0, - 1.0, - 0, - ctx, - OffsetAxis::Raw, - )); - - let toggles = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - toggles.append(&build_flag_toggle("Compact", "hud_compact", ctx)); - toggles.append(&build_flag_toggle("No margin", "hud_no_margin", ctx)); - toggles.append(&build_flag_toggle("Horizontal", "horizontal", ctx)); - toggles.append(&build_flag_toggle("Outline", "text_outline", ctx)); - card.append(&toggles); - - let colors_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - colors_row.append(&build_color_control("Text", "text_color", ctx)); - colors_row.append(&build_color_control("Background", "background_color", ctx)); - colors_row.append(&build_color_control("GPU", "gpu_color", ctx)); - colors_row.append(&build_color_control("CPU", "cpu_color", ctx)); - card.append(&colors_row); - card -} - -pub(crate) fn build_profiles_panel(ctx: &PageBuildContext) -> gtk4::Box { - let card = dashboard_card(); - card.append(&card_header( - "Profiles", - "Save the active config target as a real MangoHud profile, apply one back into the current target, or open the profile folder when you need it.", - )); - - let controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - controls.add_css_class("dashboard-profiles-strip"); - let name_entry = gtk4::Entry::new(); - name_entry.set_placeholder_text(Some("competitive, quality, streaming")); - name_entry.set_hexpand(true); - name_entry.set_width_chars(20); - name_entry.add_css_class("control-field"); - - let (names, paths) = profile_choices(); - let name_refs = names.iter().map(String::as_str).collect::>(); - let restore_dropdown = gtk4::DropDown::from_strings(&name_refs); - restore_dropdown.set_hexpand(true); - restore_dropdown.set_halign(gtk4::Align::Fill); - restore_dropdown.set_size_request(230, -1); - install_profile_dropdown_factory(&restore_dropdown); - install_scroll_passthrough(restore_dropdown.upcast_ref()); - restore_dropdown.add_css_class("control-field"); - - let save_button = icon_action_button( - "document-save-symbolic", - "Save the active config target as a profile", - ); - let restore_button = icon_action_button( - "document-revert-symbolic", - "Apply the selected profile into the active config target", - ); - let delete_button = icon_action_button("user-trash-symbolic", "Delete the selected profile"); - let open_button = icon_action_button("folder-open-symbolic", "Open the profile storage folder"); - - restore_button.set_sensitive(!paths.is_empty()); - delete_button.set_sensitive(!paths.is_empty()); - controls.append(&name_entry); - controls.append(&save_button); - controls.append(&restore_dropdown); - controls.append(&restore_button); - controls.append(&delete_button); - controls.append(&open_button); - - let profile_paths = Rc::new(RefCell::new(paths)); - let initial_selected = refresh_profile_picker( - &restore_dropdown, - &restore_button, - &delete_button, - &profile_paths, - last_selected_profile_name().as_deref(), - ); - if let Some(name) = initial_selected { - name_entry.set_text(&name); - } - - { - let name_entry = name_entry.clone(); - let profile_paths = profile_paths.clone(); - restore_dropdown.connect_selected_notify(move |dropdown| { - if profile_paths.borrow().is_empty() { - name_entry.set_text(""); - persist_last_selected_profile_name(None); - return; - } - let selected = dropdown_selected_profile_name(dropdown); - if let Some(name) = selected.as_deref() { - name_entry.set_text(name); - } - persist_last_selected_profile_name(selected.as_deref()); - }); - } - - { - let ctx = ctx.clone(); - let name_entry = name_entry.clone(); - let restore_dropdown = restore_dropdown.clone(); - let restore_button = restore_button.clone(); - let delete_button = delete_button.clone(); - let profile_paths = profile_paths.clone(); - save_button.connect_clicked(move |_| { - let profile_name = name_entry.text().to_string(); - if profile_name.trim().is_empty() { - show_toast(&ctx.toast_overlay, "Enter a profile name first"); - return; - } - let exists = match profiles::profile_exists(&profile_name) { - Ok(exists) => exists, - Err(err) => { - show_toast( - &ctx.toast_overlay, - &format!("Profile save failed: {err}"), - ); - return; - } - }; - - if exists { - let ctx_clone = ctx.clone(); - let name_entry = name_entry.clone(); - let restore_dropdown = restore_dropdown.clone(); - let restore_button = restore_button.clone(); - let delete_button = delete_button.clone(); - let profile_paths = profile_paths.clone(); - let profile_name = profile_name.clone(); - glib::MainContext::default().spawn_local(async move { - let confirmed = confirm_profile_action( - &ctx_clone.parent_window, - "Overwrite profile?", - &format!( - "A profile named \"{profile_name}\" already exists. Replace it with the current active config target?" - ), - "Overwrite", - true, - ) - .await; - if confirmed { - save_profile_from_dashboard( - &ctx_clone, - &name_entry, - &restore_dropdown, - &restore_button, - &delete_button, - &profile_paths, - &profile_name, - ); - } - }); - } else { - save_profile_from_dashboard( - &ctx, - &name_entry, - &restore_dropdown, - &restore_button, - &delete_button, - &profile_paths, - &profile_name, - ); - } - }); - } - - { - let ctx = ctx.clone(); - let profile_paths = profile_paths.clone(); - let restore_dropdown = restore_dropdown.clone(); - let restore_button = restore_button.clone(); - let delete_button = delete_button.clone(); - restore_button.clone().connect_clicked(move |_| { - let idx = restore_dropdown.selected() as usize; - let Some(path) = profile_paths.borrow().get(idx).cloned() else { - show_toast(&ctx.toast_overlay, "No profile selected"); - return; - }; - - let target_path = current_config_snapshot(&ctx).path; - match profiles::load_profile_from_path(&path, target_path) { - Ok(loaded) => { - if let Ok(mut state) = ctx.state.lock() { - state.config = loaded; - state.dirty = true; - } - recompute_validation(&ctx.state); - refresh_save_button(&ctx.state, &ctx.save_button); - apply_live_preview_now(&ctx); - let _ = gtk4::prelude::WidgetExt::activate_action( - &ctx.parent_window, - "win.refresh-current-page", - None, - ); - show_toast( - &ctx.toast_overlay, - "Applied profile into the active config target", - ); - let selected_name = dropdown_selected_profile_name(&restore_dropdown); - let selected = refresh_profile_picker( - &restore_dropdown, - &restore_button, - &delete_button, - &profile_paths, - selected_name.as_deref(), - ); - if let Some(selected) = selected { - persist_last_selected_profile_name(Some(&selected)); - } - } - Err(err) => show_toast(&ctx.toast_overlay, &format!("Profile apply failed: {err}")), - } - }); - } - - { - let ctx = ctx.clone(); - let profile_paths = profile_paths.clone(); - let restore_dropdown = restore_dropdown.clone(); - let restore_button = restore_button.clone(); - let delete_button = delete_button.clone(); - let name_entry = name_entry.clone(); - delete_button.clone().connect_clicked(move |_| { - let idx = restore_dropdown.selected() as usize; - let Some(path) = profile_paths.borrow().get(idx).cloned() else { - show_toast(&ctx.toast_overlay, "No profile selected"); - return; - }; - let profile_name = path - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or("profile") - .to_string(); - let ctx_clone = ctx.clone(); - let restore_dropdown = restore_dropdown.clone(); - let restore_button = restore_button.clone(); - let delete_button = delete_button.clone(); - let profile_paths = profile_paths.clone(); - let name_entry = name_entry.clone(); - glib::MainContext::default().spawn_local(async move { - let confirmed = confirm_profile_action( - &ctx_clone.parent_window, - "Delete profile?", - &format!("Delete the profile \"{profile_name}\"? This cannot be undone."), - "Delete", - true, - ) - .await; - if !confirmed { - return; - } - - match profiles::delete_profile_path(&path) { - Ok(()) => { - let selected = refresh_profile_picker( - &restore_dropdown, - &restore_button, - &delete_button, - &profile_paths, - None, - ); - if let Some(selected) = selected { - name_entry.set_text(&selected); - } else { - name_entry.set_text(""); - } - show_toast( - &ctx_clone.toast_overlay, - &format!("Deleted profile {profile_name}"), - ); - } - Err(err) => show_toast( - &ctx_clone.toast_overlay, - &format!("Profile delete failed: {err}"), - ), - } - }); - }); - } - - { - let overlay = ctx.toast_overlay.clone(); - open_button.connect_clicked(move |_| { - let has_graphical_session = std::env::var("DISPLAY") - .ok() - .is_some_and(|value| !value.is_empty()) - || std::env::var("WAYLAND_DISPLAY") - .ok() - .is_some_and(|value| !value.is_empty()); - if !has_graphical_session { - show_toast( - &overlay, - "No graphical session is available to open the profiles folder", - ); - return; - } - - match std::process::Command::new("xdg-open") - .arg(profiles::profiles_dir()) - .spawn() - { - Ok(_) => {} - Err(error) => show_toast( - &overlay, - &format!("Failed to open profiles folder: {error}"), - ), - } - }); - } - - card.append(&controls); - card -} - -fn save_profile_from_dashboard( - ctx: &PageBuildContext, - name_entry: >k4::Entry, - restore_dropdown: >k4::DropDown, - restore_button: >k4::Button, - delete_button: >k4::Button, - profile_paths: &Rc>>, - profile_name: &str, -) { - let config = current_config_snapshot(ctx); - match profiles::save_profile(profile_name, &config) { - Ok(path) => { - let selected = refresh_profile_picker( - restore_dropdown, - restore_button, - delete_button, - profile_paths, - Some(profile_name), - ); - if let Some(selected) = selected { - name_entry.set_text(&selected); - } - show_toast( - &ctx.toast_overlay, - &format!("Saved active config target as profile {}", path.display()), - ); - } - Err(err) => show_toast(&ctx.toast_overlay, &format!("Profile save failed: {err}")), - } -} - -async fn confirm_profile_action( - parent: &libadwaita::ApplicationWindow, - heading: &str, - body: &str, - confirm_label: &str, - destructive: bool, -) -> bool { - let dialog = libadwaita::AlertDialog::builder() - .heading(heading) - .body(body) - .build(); - dialog.add_response("cancel", "Cancel"); - dialog.add_response("confirm", confirm_label); - dialog.set_default_response(Some("confirm")); - dialog.set_close_response("cancel"); - dialog.set_response_appearance( - "confirm", - if destructive { - libadwaita::ResponseAppearance::Destructive - } else { - libadwaita::ResponseAppearance::Suggested - }, - ); - dialog.choose_future(parent).await.as_str() == "confirm" -} - -pub(crate) fn build_presets_panel(ctx: &PageBuildContext) -> gtk4::Box { - let card = dashboard_card(); - let grid = gtk4::Grid::new(); - grid.set_row_spacing(8); - grid.set_column_spacing(8); - grid.attach( - &build_preset_button(DashboardPreset::Benchmark, ctx), - 0, - 0, - 1, - 1, - ); - grid.attach( - &build_preset_button(DashboardPreset::Competitive, ctx), - 1, - 0, - 1, - 1, - ); - grid.attach( - &build_preset_button(DashboardPreset::Performance, ctx), - 0, - 1, - 1, - 1, - ); - grid.attach( - &build_preset_button(DashboardPreset::Streaming, ctx), - 1, - 1, - 1, - 1, - ); - - let note = gtk4::Label::new(Some( - "Presets update the current in-memory config and refresh live preview if it is running. If a matching profile is missing, MangoTune falls back to its built-in mapping.", - )); - note.add_css_class("dim-label"); - note.set_wrap(true); - note.set_xalign(0.0); - - card.append(&grid); - card.append(¬e); - card -} - -fn build_metrics_card(ctx: &PageBuildContext) -> gtk4::Box { - let card = dashboard_card(); - card.add_css_class("dashboard-full-width-card"); - card.append(&card_header( - "Show These Metrics", - "Turn on the HUD items most Linux gamers usually care about first, then go deeper in the sidebar when you need exact formatting and thresholds.", - )); - - card.append(&metric_group( - "Frame & pacing", - &[ - ("FPS", "fps"), - ("Frametime", "frametime"), - ("Frame timing", "frame_timing"), - ], - ctx, - )); - card.append(&metric_group( - "GPU", - &[ - ("GPU stats", "gpu_stats"), - ("GPU temp", "gpu_temp"), - ("GPU clock", "gpu_core_clock"), - ("VRAM", "vram"), - ], - ctx, - )); - card.append(&metric_group( - "CPU & memory", - &[ - ("CPU stats", "cpu_stats"), - ("CPU temp", "cpu_temp"), - ("RAM", "ram"), - ], - ctx, - )); - card.append(&metric_group( - "Extra", - &[ - ("Read I/O", "io_read"), - ("Write I/O", "io_write"), - ("Battery", "battery"), - ("Media", "media_player"), - ], - ctx, - )); - - let hint = gtk4::Label::new(Some( - "Use the GPU, CPU, Memory, Battery, and Performance pages for graphs, labels, thresholds, and the rest of MangoHud’s detailed metric formatting.", - )); - hint.add_css_class("dim-label"); - hint.set_xalign(0.0); - hint.set_wrap(true); - card.append(&hint); - card -} - -fn build_workspace_strip(ctx: &PageBuildContext) -> gtk4::Box { - let strip = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - strip.add_css_class("dashboard-status-strip"); - - let summary = gtk4::Label::new(None); - summary.set_xalign(0.0); - summary.set_wrap(true); - summary.add_css_class("dashboard-status-line"); - summary.add_css_class("dashboard-status-summary"); - strip.append(&summary); - - refresh_workspace_status(ctx, &summary); - - { - let ctx = ctx.clone(); - let summary = summary.downgrade(); - glib::timeout_add_seconds_local(1, move || { - let Some(summary) = summary.upgrade() else { - return glib::ControlFlow::Break; - }; - refresh_workspace_status(&ctx, &summary); - glib::ControlFlow::Continue - }); - } - - strip -} - -fn dashboard_card() -> gtk4::Box { - let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8); - card.add_css_class("dashboard-card"); - card.add_css_class("dashboard-card-compact"); - card.set_hexpand(true); - card.set_margin_top(0); - card.set_margin_bottom(0); - card.set_margin_start(0); - card.set_margin_end(0); - card -} - -fn card_header(title: &str, subtitle: &str) -> gtk4::Box { - let box_ = gtk4::Box::new(gtk4::Orientation::Vertical, 2); - - let title_label = gtk4::Label::new(Some(title)); - title_label.add_css_class("dashboard-card-title"); - title_label.set_xalign(0.0); - - let subtitle_label = gtk4::Label::new(Some(subtitle)); - subtitle_label.add_css_class("dashboard-card-subtitle"); - subtitle_label.set_wrap(true); - subtitle_label.set_xalign(0.0); - - box_.append(&title_label); - box_.append(&subtitle_label); - box_ -} - -#[allow(clippy::too_many_arguments)] -fn build_scale_control( - label: &str, - key: &str, - min: f64, - max: f64, - step: f64, - digits: u32, - ctx: &PageBuildContext, - axis: OffsetAxis, -) -> gtk4::Box { - let row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - - let title = gtk4::Label::new(Some(label)); - title.add_css_class("dashboard-field-label"); - title.set_xalign(0.0); - title.set_hexpand(true); - - let value_label = gtk4::Label::new(None); - value_label.add_css_class("dashboard-value-label"); - - top.append(&title); - top.append(&value_label); - - let scale = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); - scale.set_hexpand(true); - scale.set_draw_value(false); - scale.add_css_class("dashboard-scale"); - install_scroll_passthrough(scale.upcast_ref()); - - let initial = display_offset_value(ctx, key, axis) - .unwrap_or(min) - .clamp(min, max); - scale.set_value(initial); - update_scale_value_label(&value_label, initial, digits); - - let key_owned = key.to_string(); - let ctx = ctx.clone(); - let value_label_clone = value_label.clone(); - scale.connect_value_changed(move |scale| { - let value = scale.value(); - update_scale_value_label(&value_label_clone, value, digits); - let config_value = offset_config_value(&ctx, axis, value, digits); - set_config_value(&ctx, &key_owned, config_value); - maybe_reload_preview_for_key(&ctx, &key_owned); - }); - - row.append(&top); - row.append(&scale); - row -} - -#[derive(Clone, Copy)] -enum OffsetAxis { - Raw, - Horizontal, - Vertical, -} - -fn build_width_control(ctx: &PageBuildContext) -> gtk4::Box { - let row = gtk4::Box::new(gtk4::Orientation::Vertical, 8); - let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - - let title = gtk4::Label::new(Some("HUD width")); - title.add_css_class("dashboard-field-label"); - title.set_xalign(0.0); - title.set_hexpand(true); - - let auto_toggle = gtk4::Switch::new(); - let auto_width = current_numeric_value(ctx, "width").unwrap_or(0.0) <= 0.0; - auto_toggle.set_active(auto_width); - - let auto_label = gtk4::Label::new(Some("Auto")); - auto_label.add_css_class("dashboard-field-label"); - top.append(&title); - top.append(&auto_label); - top.append(&auto_toggle); - - let scale_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - let scale = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, 1.0, 1200.0, 10.0); - scale.set_hexpand(true); - scale.set_draw_value(false); - scale.add_css_class("dashboard-scale"); - install_scroll_passthrough(scale.upcast_ref()); - - let value_label = gtk4::Label::new(None); - value_label.add_css_class("dashboard-value-label"); - - let initial_width = current_numeric_value(ctx, "width") - .unwrap_or(300.0) - .clamp(1.0, 1200.0); - scale.set_value(initial_width); - update_scale_value_label(&value_label, initial_width, 0); - scale.set_sensitive(!auto_width); - if auto_width { - value_label.set_text("auto"); - } - - scale_row.append(&scale); - scale_row.append(&value_label); - - { - let ctx = ctx.clone(); - let value_label = value_label.clone(); - scale.connect_value_changed(move |scale| { - let value = scale.value().round().clamp(1.0, 1200.0); - update_scale_value_label(&value_label, value, 0); - set_config_value( - &ctx, - "width", - ConfigValue::Value((value as i64).to_string()), - ); - maybe_reload_preview_for_key(&ctx, "width"); - }); - } - - { - let ctx = ctx.clone(); - let scale = scale.clone(); - let value_label = value_label.clone(); - auto_toggle.connect_active_notify(move |toggle| { - let enabled = toggle.is_active(); - scale.set_sensitive(!enabled); - if enabled { - value_label.set_text("auto"); - set_config_value(&ctx, "width", ConfigValue::Value("0".to_string())); - } else { - let value = scale.value().round().clamp(1.0, 1200.0); - update_scale_value_label(&value_label, value, 0); - set_config_value( - &ctx, - "width", - ConfigValue::Value((value as i64).to_string()), - ); - } - maybe_reload_preview_for_key(&ctx, "width"); - }); - } - - row.append(&top); - row.append(&scale_row); - row -} - -fn build_flag_toggle(label: &str, key: &str, ctx: &PageBuildContext) -> gtk4::ToggleButton { - let button = gtk4::ToggleButton::with_label(label); - button.add_css_class("dashboard-toggle"); - button.set_active(is_flag_enabled(ctx, key)); - - let key_owned = key.to_string(); - let ctx = ctx.clone(); - button.connect_toggled(move |button| { - let dependency_state_changed = if button.is_active() { - enable_toggle_with_dependencies(&ctx, &key_owned, &OptionType::Flag) - } else { - disable_toggle_with_dependents(&ctx, &key_owned, &OptionType::Flag) - }; - recompute_validation(&ctx.state); - refresh_save_button(&ctx.state, &ctx.save_button); - apply_live_preview_now(&ctx); - if dependency_state_changed { - let _ = gtk4::prelude::WidgetExt::activate_action( - &ctx.parent_window, - "win.refresh-current-page", - None, - ); - } - }); - - button -} - -fn build_color_control(label: &str, key: &str, ctx: &PageBuildContext) -> gtk4::Box { - let group = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - group.add_css_class("color-control"); - - let title = gtk4::Label::new(Some(label)); - title.add_css_class("dashboard-field-label"); - title.set_xalign(0.0); - - let dialog = gtk4::ColorDialog::builder() - .title(label) - .modal(true) - .build(); - let swatch = gtk4::ColorDialogButton::new(Some(dialog)); - swatch.add_css_class("color-swatch-button"); - swatch.add_css_class("dashboard-color-button"); - - let entry = gtk4::Entry::new(); - entry.set_width_chars(8); - entry.set_max_length(6); - entry.set_placeholder_text(Some("RRGGBB")); - - if let Some(color) = current_string_value(ctx, key) { - entry.set_text(&color); - if let Some(rgba) = hex_to_rgba(&color) { - swatch.set_rgba(&rgba); - } - } - - { - let ctx = ctx.clone(); - let ctx_for_change = ctx.clone(); - let key_owned = key.to_string(); - let swatch_for_entry = swatch.clone(); - let pending_preview_refresh = Rc::new(Cell::new(false)); - let pending_refresh_for_change = pending_preview_refresh.clone(); - let syncing = Rc::new(Cell::new(false)); - let syncing_for_entry = syncing.clone(); - entry.connect_changed(move |entry| { - if syncing_for_entry.get() { - return; - } - let text = entry.text().to_string().to_ascii_uppercase(); - set_config_value( - &ctx_for_change, - &key_owned, - ConfigValue::Value(text.clone()), - ); - if let Some(rgba) = hex_to_rgba(&text) { - syncing_for_entry.set(true); - swatch_for_entry.set_rgba(&rgba); - syncing_for_entry.set(false); - } - pending_refresh_for_change.set(true); - }); - - connect_dashboard_entry_preview_commit(&entry, &ctx, key, pending_preview_refresh); - let ctx = ctx.clone(); - let key_owned = key.to_string(); - let entry = entry.clone(); - let syncing_for_swatch = syncing.clone(); - swatch.connect_rgba_notify(move |button| { - if syncing_for_swatch.get() { - return; - } - let hex = rgba_to_hex(&button.rgba()); - syncing_for_swatch.set(true); - entry.set_text(&hex); - syncing_for_swatch.set(false); - set_config_value(&ctx, &key_owned, ConfigValue::Value(hex)); - maybe_reload_preview_for_key(&ctx, &key_owned); - }); - } - - group.append(&title); - group.append(&swatch); - group.append(&entry); - group -} - -fn connect_dashboard_entry_preview_commit( - entry: >k4::Entry, - ctx: &PageBuildContext, - key: &str, - pending_refresh: Rc>, -) { - let ctx_clone = ctx.clone(); - entry.connect_activate(move |_| { - gtk4::prelude::GtkWindowExt::set_focus( - &ctx_clone.parent_window, - Option::<>k4::Widget>::None, - ); - }); - - let focus = gtk4::EventControllerFocus::new(); - let key_owned = key.to_string(); - let ctx_clone = ctx.clone(); - let pending_refresh_clone = pending_refresh.clone(); - focus.connect_leave(move |_| { - if pending_refresh_clone.replace(false) { - maybe_reload_preview_for_key(&ctx_clone, &key_owned); - } - }); - entry.add_controller(focus); -} - -fn build_preset_button(preset: DashboardPreset, ctx: &PageBuildContext) -> gtk4::Button { - let button = gtk4::Button::new(); - button.add_css_class("dashboard-preset-button"); - button.set_hexpand(true); - button.set_halign(gtk4::Align::Fill); - - let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - content.set_halign(gtk4::Align::Start); - - let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - let title = gtk4::Label::new(Some(preset.title())); - title.add_css_class("dashboard-card-title"); - title.set_xalign(0.0); - title.set_hexpand(true); - - let badge = gtk4::Label::new(Some(preset.badge())); - badge.add_css_class("dashboard-preset-badge"); - - top.append(&title); - top.append(&badge); - - let description = gtk4::Label::new(Some(preset.description())); - description.add_css_class("dashboard-card-subtitle"); - description.set_wrap(true); - description.set_xalign(0.0); - - content.append(&top); - content.append(&description); - button.set_child(Some(&content)); - - let ctx = ctx.clone(); - button.connect_clicked(move |_| { - apply_dashboard_preset(&ctx, preset); - }); - - button -} - -fn icon_action_button(icon_name: &str, tooltip: &str) -> gtk4::Button { - let button = gtk4::Button::new(); - button.add_css_class("shell-strip-button"); - button.add_css_class("profile-action-button"); - button.set_tooltip_text(Some(tooltip)); - let icon = gtk4::Image::from_icon_name(icon_name); - icon.set_pixel_size(16); - button.set_child(Some(&icon)); - button -} - -fn metric_group(title: &str, items: &[(&str, &str)], ctx: &PageBuildContext) -> gtk4::Box { - let group = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); - group.add_css_class("metric-group"); - group.set_valign(gtk4::Align::Start); - - let heading = gtk4::Label::new(Some(title)); - heading.add_css_class("dashboard-field-label"); - heading.add_css_class("metric-group-title"); - heading.set_xalign(0.0); - heading.set_yalign(0.2); - heading.set_width_chars(12); - heading.set_halign(gtk4::Align::Start); - heading.set_valign(gtk4::Align::Start); - group.append(&heading); - - let wrap = gtk4::FlowBox::new(); - wrap.set_selection_mode(gtk4::SelectionMode::None); - wrap.set_max_children_per_line(4); - wrap.set_row_spacing(4); - wrap.set_column_spacing(6); - wrap.add_css_class("metric-group-flow"); - wrap.set_hexpand(true); - - for (label, key) in items { - let chip = build_metric_toggle(label, key, ctx); - wrap.insert(&chip, -1); - } - - group.append(&wrap); - group -} - -fn build_metric_toggle(label: &str, key: &str, ctx: &PageBuildContext) -> gtk4::ToggleButton { - let button = gtk4::ToggleButton::with_label(label); - button.add_css_class("dashboard-toggle"); - button.add_css_class("metric-toggle"); - button.set_active(is_flag_enabled(ctx, key)); - - let ctx = ctx.clone(); - let key_owned = key.to_string(); - button.connect_toggled(move |button| { - let dependency_state_changed = if button.is_active() { - enable_toggle_with_dependencies(&ctx, &key_owned, &OptionType::Flag) - } else { - disable_toggle_with_dependents(&ctx, &key_owned, &OptionType::Flag) - }; - recompute_validation(&ctx.state); - refresh_save_button(&ctx.state, &ctx.save_button); - apply_live_preview_now(&ctx); - if dependency_state_changed { - let _ = gtk4::prelude::WidgetExt::activate_action( - &ctx.parent_window, - "win.refresh-current-page", - None, - ); - } - }); - - button -} - -fn refresh_preview_widgets( - ctx: &PageBuildContext, - start_button: >k4::Button, - status_label: >k4::Label, - reload_button: >k4::Button, - restart_button: >k4::Button, - stop_button: >k4::Button, -) { - let snapshot = ctx.preview.snapshot(); - status_label.set_text(&snapshot.status); - status_label.remove_css_class("preview-status-live"); - status_label.remove_css_class("preview-status-idle"); - if snapshot.running { - status_label.add_css_class("preview-status-live"); - } else { - status_label.add_css_class("preview-status-idle"); - } - - start_button.set_sensitive(!snapshot.running); - reload_button.set_sensitive(snapshot.running); - restart_button.set_sensitive(snapshot.can_restart); - stop_button.set_sensitive(snapshot.running); -} - -fn refresh_workspace_status(ctx: &PageBuildContext, summary: >k4::Label) { - let Ok(state) = ctx.state.lock() else { - summary.set_text("State unavailable"); - return; - }; - - let (errors, warnings) = validation_counts(&state.validation); - let validation_text = match (errors, warnings) { - (0, 0) => "Validation clear".to_string(), - (0, warnings) => format!("{warnings} warning(s)"), - (errors, warnings) => format!("{errors} error(s), {warnings} warning(s)"), - }; - let dirty_text = if state.dirty { - "Unsaved changes" - } else { - "Saved" - }; - let config_target = state - .config - .path - .as_ref() - .and_then(|path| path.file_name()) - .and_then(|name| name.to_str()) - .unwrap_or("(unsaved config)"); - - summary.set_text(&format!( - "{dirty_text} • {validation_text} • Target: {config_target} • MangoHud {} • {:?} • GPU {:?}", - ctx.system_info - .mangohud - .version - .clone() - .unwrap_or_else(|| "unknown".to_string()), - ctx.system_info.display_server, - ctx.system_info.gpu.vendor - )); -} - -fn validation_counts(validation: &HashMap) -> (usize, usize) { - let mut errors = 0; - let mut warnings = 0; - for result in validation.values() { - match result { - ValidationResult::Error(_) => errors += 1, - ValidationResult::Warning(_) => warnings += 1, - ValidationResult::Ok => {} - } - } - (errors, warnings) -} - -fn preferred_studio_options() -> PreviewStudioOptions { - let settings = app_settings(); - let scene = settings - .as_ref() - .and_then(|settings| { - mangotune::preview::StudioScene::from_label(&settings.string("preview-studio-scene")) - }) - .unwrap_or(mangotune::preview::StudioScene::DarkArena); - let fps_cap = settings - .as_ref() - .map(|settings| settings.int("preview-fps-cap")) - .unwrap_or(120) - .clamp(0, 1000); - let vsync = settings - .as_ref() - .map(|settings| settings.boolean("preview-vsync")) - .unwrap_or(false); - let vram_pressure_mb = settings - .as_ref() - .map(|settings| settings.int("preview-vram-pressure")) - .unwrap_or(0) - .clamp(0, 65_536); - let particle_count = settings - .as_ref() - .map(|settings| settings.int("preview-particle-count")) - .unwrap_or(1_000) - .clamp(100, 500_000); - let particle_size = settings - .as_ref() - .map(|settings| settings.double("preview-particle-size")) - .unwrap_or(0.03) - .clamp(0.01, 5.0); - let gpu_passes = settings - .as_ref() - .map(|settings| settings.int("preview-gpu-passes")) - .unwrap_or(1) - .clamp(1, 64); - let interaction_steps = settings - .as_ref() - .map(|settings| settings.int("preview-interaction-steps")) - .unwrap_or(0) - .clamp(0, 256); - PreviewStudioOptions { - scene, - fps_cap: if fps_cap <= 0 { - None - } else { - Some(fps_cap as u32) - }, - vsync, - vram_pressure_mb: vram_pressure_mb as u32, - particle_count: particle_count as u32, - particle_size: particle_size as f32, - gpu_passes: gpu_passes as u32, - interaction_steps: interaction_steps as u32, - paused: false, - } -} - -fn preferred_preview_window_size() -> (i32, i32) { - let settings = app_settings(); - let width = settings - .as_ref() - .map(|settings| settings.int("test-window-width")) - .unwrap_or(1280) - .clamp(640, 3840); - let height = settings - .as_ref() - .map(|settings| settings.int("test-window-height")) - .unwrap_or(720) - .clamp(360, 2160); - (width, height) -} - -fn persist_preview_window_width(width: i32) { - if let Some(settings) = app_settings() { - let _ = settings.set_int("test-window-width", width.clamp(640, 3840)); - } -} - -fn persist_preview_window_height(height: i32) { - if let Some(settings) = app_settings() { - let _ = settings.set_int("test-window-height", height.clamp(360, 2160)); - } -} - -fn persist_preview_studio_scene(scene: StudioScene) { - if let Some(settings) = app_settings() { - let _ = settings.set_string("preview-studio-scene", scene.label()); - } -} - -fn persist_studio_fps_cap(fps_cap: i32) { - if let Some(settings) = app_settings() { - let _ = settings.set_int("preview-fps-cap", fps_cap.clamp(0, 1000)); - } -} - -fn persist_studio_vram_pressure(mb: i32) { - if let Some(settings) = app_settings() { - let _ = settings.set_int("preview-vram-pressure", mb.clamp(0, 65_536)); - } -} - -fn persist_studio_particle_count(count: i32) { - if let Some(settings) = app_settings() { - let _ = settings.set_int("preview-particle-count", count.clamp(100, 500_000)); - } -} - -fn persist_studio_particle_size(size: f64) { - if let Some(settings) = app_settings() { - let _ = settings.set_double("preview-particle-size", size.clamp(0.01, 5.0)); - } -} - -fn persist_studio_gpu_passes(passes: i32) { - if let Some(settings) = app_settings() { - let _ = settings.set_int("preview-gpu-passes", passes.clamp(1, 64)); - } -} - -fn persist_studio_interaction_steps(steps: i32) { - if let Some(settings) = app_settings() { - let _ = settings.set_int("preview-interaction-steps", steps.clamp(0, 256)); - } -} - -fn persist_preview_vsync(enabled: bool) { - if let Some(settings) = app_settings() { - let _ = settings.set_boolean("preview-vsync", enabled); - } -} - -fn preview_window_settings(_scene: PreviewScene, config: &AnnotatedConfig) -> (i32, i32) { - let (mut width, height) = preferred_preview_window_size(); - - if let Some(hud_width) = effective_preview_hud_width(config, width) { - width = width - .max(hud_width + extra_preview_margin(config)) - .clamp(720, 3840); - } - - (width, height) -} - -fn extra_preview_margin(config: &AnnotatedConfig) -> i32 { - if is_right_aligned_position(config) { - if is_horizontal_layout(config) { - 980 - } else { - 480 - } - } else { - 220 - } -} - -fn is_horizontal_layout(config: &AnnotatedConfig) -> bool { - match config.options.get("horizontal").map(|(_, value)| value) { - Some(ConfigValue::Flag) => true, - Some(ConfigValue::Value(value)) => { - let normalized = value.trim().to_ascii_lowercase(); - !matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off") - } - _ => false, - } -} - -fn is_right_aligned_position(config: &AnnotatedConfig) -> bool { - matches!( - current_config_position(config).as_deref(), - Some("top-right" | "middle-right" | "bottom-right") - ) -} - -fn current_config_position(config: &AnnotatedConfig) -> Option { - config - .options - .get("position") - .and_then(|(_, value)| match value { - ConfigValue::Value(value) => Some(value.clone()), - _ => None, - }) -} - -fn set_config_value(ctx: &PageBuildContext, key: &str, value: ConfigValue) { - let Ok(mut state) = ctx.state.lock() else { - return; - }; - Parser::set_value(&mut state.config, key, value); - state.dirty = state.config.dirty; - drop(state); - recompute_validation(&ctx.state); - refresh_save_button(&ctx.state, &ctx.save_button); -} - -fn maybe_reload_preview_for_key(ctx: &PageBuildContext, key: &str) { - refresh_live_preview_for_key(ctx, Some(key)); -} - -fn apply_dashboard_preset(ctx: &PageBuildContext, preset: DashboardPreset) { - let applied_from_profile = apply_dashboard_preset_profile(ctx, preset); - if !applied_from_profile { - let updates = preset_updates(preset); - apply_config_updates(ctx, &updates); - } - apply_live_preview_now(ctx); - let _ = gtk4::prelude::WidgetExt::activate_action( - &ctx.parent_window, - "win.refresh-current-page", - None, - ); - show_toast( - &ctx.toast_overlay, - &format!( - "Applied {} preset{}", - preset.title(), - if applied_from_profile { - "" - } else { - " (fallback mapping)" - } - ), - ); -} - -fn apply_dashboard_preset_profile(ctx: &PageBuildContext, preset: DashboardPreset) -> bool { - let target_path = current_config_snapshot(ctx).path; - let Ok(loaded) = profiles::load_profile(preset.profile_name(), target_path) else { - return false; - }; - - let Ok(mut state) = ctx.state.lock() else { - return false; - }; - state.config = loaded; - state.dirty = true; - drop(state); - recompute_validation(&ctx.state); - refresh_save_button(&ctx.state, &ctx.save_button); - true -} - -fn apply_config_updates(ctx: &PageBuildContext, updates: &[(&str, ConfigValue)]) { - let Ok(mut state) = ctx.state.lock() else { - return; - }; - for (key, value) in updates { - Parser::set_value(&mut state.config, key, value.clone()); - } - state.dirty = state.config.dirty; - drop(state); - recompute_validation(&ctx.state); - refresh_save_button(&ctx.state, &ctx.save_button); -} - -fn preset_updates(preset: DashboardPreset) -> Vec<(&'static str, ConfigValue)> { - let mut updates = base_preset_updates(); - match preset { - DashboardPreset::Benchmark => { - updates.extend([ - ("fps", ConfigValue::Flag), - ("frametime", ConfigValue::Flag), - ("frame_timing", ConfigValue::Flag), - ("gpu_stats", ConfigValue::Flag), - ("gpu_temp", ConfigValue::Flag), - ("gpu_core_clock", ConfigValue::Flag), - ("gpu_mem_clock", ConfigValue::Flag), - ("gpu_power", ConfigValue::Flag), - ("cpu_stats", ConfigValue::Flag), - ("ram", ConfigValue::Flag), - ("vram", ConfigValue::Flag), - ("position", ConfigValue::Value("top-left".to_string())), - ("table_columns", ConfigValue::Value("6".to_string())), - ("hud_compact", ConfigValue::Flag), - ("font_size", ConfigValue::Value("22".to_string())), - ("font_scale", ConfigValue::Value("1.00".to_string())), - ("background_alpha", ConfigValue::Value("0.75".to_string())), - ("alpha", ConfigValue::Value("0.95".to_string())), - ("text_outline", ConfigValue::Flag), - ( - "text_outline_color", - ConfigValue::Value("000000".to_string()), - ), - ( - "text_outline_thickness", - ConfigValue::Value("2".to_string()), - ), - ("fps_sampling_period", ConfigValue::Value("500".to_string())), - ("gamemode", ConfigValue::Flag), - ("vkbasalt", ConfigValue::Flag), - ("display_server", ConfigValue::Flag), - ("dx_api", ConfigValue::Flag), - ("resolution", ConfigValue::Flag), - ("version", ConfigValue::Flag), - ("fsr", ConfigValue::Flag), - ("hdr", ConfigValue::Flag), - ]); - } - DashboardPreset::Competitive => { - updates.extend([ - ("fps", ConfigValue::Flag), - ("frametime", ConfigValue::Flag), - ("frame_timing", ConfigValue::Disabled), - ("gpu_stats", ConfigValue::Disabled), - ("gpu_temp", ConfigValue::Disabled), - ("cpu_stats", ConfigValue::Disabled), - ("cpu_temp", ConfigValue::Disabled), - ("ram", ConfigValue::Disabled), - ("vram", ConfigValue::Disabled), - ("position", ConfigValue::Value("top-center".to_string())), - ("table_columns", ConfigValue::Value("6".to_string())), - ("horizontal", ConfigValue::Flag), - ("hud_no_margin", ConfigValue::Flag), - ("font_size", ConfigValue::Value("26".to_string())), - ("font_scale", ConfigValue::Value("1.00".to_string())), - ("background_alpha", ConfigValue::Value("0.00".to_string())), - ("alpha", ConfigValue::Value("1.00".to_string())), - ("text_outline", ConfigValue::Flag), - ( - "text_outline_color", - ConfigValue::Value("000000".to_string()), - ), - ( - "text_outline_thickness", - ConfigValue::Value("2".to_string()), - ), - ("fps_color_change", ConfigValue::Flag), - ("fps_value", ConfigValue::Value("60,90".to_string())), - ( - "fps_color", - ConfigValue::Value("ff3333,ffaa00,00ff6a".to_string()), - ), - ("width", ConfigValue::Value("0".to_string())), - ]); - } - DashboardPreset::Performance => { - updates.extend([ - ("fps", ConfigValue::Flag), - ("frametime", ConfigValue::Flag), - ("frame_timing", ConfigValue::Flag), - ("gpu_stats", ConfigValue::Flag), - ("gpu_temp", ConfigValue::Flag), - ("cpu_stats", ConfigValue::Flag), - ("cpu_temp", ConfigValue::Flag), - ("ram", ConfigValue::Flag), - ("vram", ConfigValue::Flag), - ("position", ConfigValue::Value("top-center".to_string())), - ("table_columns", ConfigValue::Value("6".to_string())), - ("horizontal", ConfigValue::Flag), - ("hud_no_margin", ConfigValue::Flag), - ("font_size", ConfigValue::Value("20".to_string())), - ("font_scale", ConfigValue::Value("1.00".to_string())), - ("background_alpha", ConfigValue::Value("0.60".to_string())), - ("alpha", ConfigValue::Value("0.95".to_string())), - ("text_outline", ConfigValue::Flag), - ( - "text_outline_color", - ConfigValue::Value("000000".to_string()), - ), - ( - "text_outline_thickness", - ConfigValue::Value("2".to_string()), - ), - ("width", ConfigValue::Value("0".to_string())), - ]); - } - DashboardPreset::Streaming => { - updates.extend([ - ("fps", ConfigValue::Flag), - ("frametime", ConfigValue::Disabled), - ("frame_timing", ConfigValue::Disabled), - ("gpu_stats", ConfigValue::Flag), - ("gpu_temp", ConfigValue::Disabled), - ("cpu_stats", ConfigValue::Flag), - ("cpu_temp", ConfigValue::Disabled), - ("ram", ConfigValue::Flag), - ("vram", ConfigValue::Disabled), - ("position", ConfigValue::Value("top-center".to_string())), - ("table_columns", ConfigValue::Value("6".to_string())), - ("horizontal", ConfigValue::Flag), - ("hud_no_margin", ConfigValue::Flag), - ("font_size", ConfigValue::Value("22".to_string())), - ("font_scale", ConfigValue::Value("1.00".to_string())), - ("background_alpha", ConfigValue::Value("0.50".to_string())), - ("alpha", ConfigValue::Value("0.90".to_string())), - ("round_corners", ConfigValue::Value("10".to_string())), - ("text_outline", ConfigValue::Flag), - ( - "text_outline_color", - ConfigValue::Value("000000".to_string()), - ), - ( - "text_outline_thickness", - ConfigValue::Value("2".to_string()), - ), - ("width", ConfigValue::Value("0".to_string())), - ]); - } - } - updates -} - -fn base_preset_updates() -> Vec<(&'static str, ConfigValue)> { - let mut updates = MANGOHUD_SCHEMA - .iter() - .filter(|entry| is_display_category(&entry.category)) - .map(|entry| (entry.key, ConfigValue::Disabled)) - .collect::>(); - updates.extend([ - ("hud_compact", ConfigValue::Flag), - ("hud_no_margin", ConfigValue::Disabled), - ("horizontal", ConfigValue::Disabled), - ("horizontal_stretch", ConfigValue::Disabled), - ("text_outline", ConfigValue::Disabled), - ("position", ConfigValue::Value("top-left".to_string())), - ("offset_x", ConfigValue::Value("0".to_string())), - ("offset_y", ConfigValue::Value("0".to_string())), - ("round_corners", ConfigValue::Value("12".to_string())), - ("background_alpha", ConfigValue::Value("0.28".to_string())), - ("alpha", ConfigValue::Value("1.00".to_string())), - ("font_size", ConfigValue::Value("24".to_string())), - ("font_scale", ConfigValue::Value("1.00".to_string())), - ("width", ConfigValue::Value("300".to_string())), - ]); - updates -} - -fn is_display_category(category: &Category) -> bool { - matches!( - category, - Category::DisplayFps - | Category::DisplayGpu - | Category::DisplayCpu - | Category::DisplayMemory - | Category::DisplayIoNetwork - | Category::DisplayMisc - | Category::DisplayGraphs - | Category::DisplayBattery - | Category::DisplayMediaPlayer - | Category::DisplayGamescope - | Category::DisplaySteamDeck - | Category::DisplayTimeText - ) -} - -#[allow(clippy::too_many_arguments)] -fn maybe_apply_studio_preview_runtime( - ctx: &PageBuildContext, - start_button: >k4::Button, - status_label: >k4::Label, - reload_button: >k4::Button, - restart_button: >k4::Button, - stop_button: >k4::Button, - studio: PreviewStudioOptions, -) { - if ctx.preview.running_scene() != Some(PreviewScene::Studio) { - return; - } - - match ctx.preview.update_studio_runtime(studio) { - Ok(()) => { - refresh_preview_widgets( - ctx, - start_button, - status_label, - reload_button, - restart_button, - stop_button, - ); - } - Err(err) => { - show_toast(&ctx.toast_overlay, &format!("Studio update failed: {err}")); - } - } -} - -#[allow(clippy::too_many_arguments)] -fn maybe_restart_active_preview( - ctx: &PageBuildContext, - start_button: >k4::Button, - status_label: >k4::Label, - reload_button: >k4::Button, - restart_button: >k4::Button, - stop_button: >k4::Button, - studio: PreviewStudioOptions, -) { - let Some(scene) = ctx.preview.running_scene() else { - return; - }; - - let config = current_config_snapshot(ctx); - let (width, height) = preview_window_settings(scene, &config); - if let Ok(pid) = ctx - .preview - .start(scene, &config, width, height, false, studio) - { - show_toast( - &ctx.toast_overlay, - &format!("Updated {} preview (pid {pid})", scene.label()), - ); - refresh_preview_widgets( - ctx, - start_button, - status_label, - reload_button, - restart_button, - stop_button, - ); - } -} - -fn current_numeric_value(ctx: &PageBuildContext, key: &str) -> Option { - current_string_value(ctx, key)?.parse::().ok() -} - -fn display_offset_value(ctx: &PageBuildContext, key: &str, _axis: OffsetAxis) -> Option { - let raw = current_numeric_value(ctx, key)?; - Some(raw.abs().max(0.0)) -} - -fn offset_config_value( - _ctx: &PageBuildContext, - _axis: OffsetAxis, - value: f64, - digits: u32, -) -> ConfigValue { - let stored = value.abs(); - if digits == 0 { - ConfigValue::Value((stored.round() as i64).to_string()) - } else { - ConfigValue::Value(format!("{stored:.precision$}", precision = digits as usize)) - } -} - -fn install_scroll_passthrough(widget: >k4::Widget) { - let controller = gtk4::EventControllerScroll::new( - gtk4::EventControllerScrollFlags::VERTICAL | gtk4::EventControllerScrollFlags::DISCRETE, - ); - controller.connect_scroll(move |controller, _dx, dy| { - let Some(widget) = controller.widget() else { - return glib::Propagation::Proceed; - }; - let Some(ancestor) = widget.ancestor(gtk4::ScrolledWindow::static_type()) else { - return glib::Propagation::Proceed; - }; - let Ok(scrolled) = ancestor.downcast::() else { - return glib::Propagation::Proceed; - }; - let adjustment = scrolled.vadjustment(); - let page = adjustment.page_size().max(120.0); - let next = (adjustment.value() + dy * (page * 0.22)).clamp( - adjustment.lower(), - (adjustment.upper() - adjustment.page_size()).max(0.0), - ); - adjustment.set_value(next); - glib::Propagation::Stop - }); - widget.add_controller(controller); -} - -fn current_string_value(ctx: &PageBuildContext, key: &str) -> Option { - let Ok(state) = ctx.state.lock() else { - return None; - }; - state - .config - .options - .get(key) - .and_then(|(_, value)| match value { - ConfigValue::Value(value) => Some(value.clone()), - ConfigValue::Flag => Some("1".to_string()), - ConfigValue::Absent | ConfigValue::Disabled => None, - }) -} - -fn is_flag_enabled(ctx: &PageBuildContext, key: &str) -> bool { - let Ok(state) = ctx.state.lock() else { - return false; - }; - state - .config - .options - .get(key) - .map(|(_, value)| match value { - ConfigValue::Flag => true, - ConfigValue::Value(raw) => { - let normalized = raw.trim().to_ascii_lowercase(); - !matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off") - } - ConfigValue::Disabled | ConfigValue::Absent => false, - }) - .unwrap_or_else(|| flag_defaults_to_enabled(key)) -} - -fn update_scale_value_label(label: >k4::Label, value: f64, digits: u32) { - if digits == 0 { - label.set_text(&(value.round() as i64).to_string()); - } else { - label.set_text(&format!("{value:.precision$}", precision = digits as usize)); - } -} - -fn update_position_buttons(buttons: &[(String, gtk4::Button)], selected: &str) { - for (position, button) in buttons { - if position == selected { - button.add_css_class("position-node-active"); - } else { - button.remove_css_class("position-node-active"); - } - } -} - -fn profile_choices() -> (Vec, Vec) { - let Ok(profiles) = profiles::list_profiles() else { - return (vec!["No profiles found".to_string()], Vec::new()); - }; - - if profiles.is_empty() { - return (vec!["No profiles found".to_string()], Vec::new()); - } - - let names = profiles - .iter() - .map(|profile| profile.name.clone()) - .collect(); - let paths = profiles - .iter() - .map(|profile| profile.path.clone()) - .collect(); - (names, paths) -} - -fn refresh_profile_picker( - dropdown: >k4::DropDown, - restore_button: >k4::Button, - delete_button: >k4::Button, - profile_paths: &Rc>>, - preferred_name: Option<&str>, -) -> Option { - let (names, paths) = profile_choices(); - *profile_paths.borrow_mut() = paths; - let refs = names.iter().map(String::as_str).collect::>(); - let model = gtk4::StringList::new(&refs); - dropdown.set_model(Some(&model)); - let has_profiles = !profile_paths.borrow().is_empty(); - restore_button.set_sensitive(has_profiles); - delete_button.set_sensitive(has_profiles); - if !has_profiles { - dropdown.set_selected(0); - persist_last_selected_profile_name(None); - return None; - } - - let selected_index = preferred_name - .and_then(|preferred| names.iter().position(|name| name == preferred)) - .unwrap_or(0); - dropdown.set_selected(selected_index as u32); - let selected = names.get(selected_index).cloned(); - persist_last_selected_profile_name(selected.as_deref()); - selected -} - -fn last_selected_profile_name() -> Option { - app_settings() - .map(|settings| settings.string("last-profile-name").to_string()) - .filter(|value| !value.trim().is_empty()) -} - -fn persist_last_selected_profile_name(name: Option<&str>) { - if let Some(settings) = app_settings() { - let _ = settings.set_string("last-profile-name", name.unwrap_or("").trim()); - } -} - -fn dropdown_selected_profile_name(dropdown: >k4::DropDown) -> Option { - dropdown - .selected_item() - .and_then(|item| item.downcast::().ok()) - .map(|item| item.string().to_string()) - .filter(|value| value != "No profiles found") -} - -fn install_profile_dropdown_factory(dropdown: >k4::DropDown) { - let factory = gtk4::SignalListItemFactory::new(); - factory.connect_setup(|_, item| { - let Some(list_item) = item.downcast_ref::() else { - return; - }; - let label = gtk4::Label::new(None); - label.set_xalign(0.0); - label.set_hexpand(true); - label.set_ellipsize(EllipsizeMode::End); - label.set_max_width_chars(28); - list_item.set_child(Some(&label)); - }); - factory.connect_bind(|_, item| { - let Some(list_item) = item.downcast_ref::() else { - return; - }; - let Some(label) = list_item - .child() - .and_then(|child| child.downcast::().ok()) - else { - return; - }; - let text = list_item - .item() - .and_then(|obj| obj.downcast::().ok()) - .map(|obj| obj.string().to_string()) - .unwrap_or_default(); - label.set_label(&text); - label.set_tooltip_text(Some(&text)); - }); - - dropdown.set_factory(Some(&factory)); - dropdown.set_list_factory(Some(&factory)); -} - -fn active_config_name(ctx: &PageBuildContext) -> String { - let Ok(state) = ctx.state.lock() else { - return "current config".to_string(); - }; - - state - .config - .path - .as_ref() - .and_then(|path| path.file_name()) - .and_then(|name| name.to_str()) - .map(ToString::to_string) - .unwrap_or_else(|| "current config".to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - use std::path::PathBuf; - - fn preview_sizing_config(position: &str, width: &str) -> AnnotatedConfig { - let mut options = indexmap::IndexMap::new(); - options.insert( - "position".to_string(), - (0, ConfigValue::Value(position.to_string())), - ); - options.insert( - "width".to_string(), - (1, ConfigValue::Value(width.to_string())), - ); - AnnotatedConfig { - lines: Vec::new(), - options, - path: Some(PathBuf::from("/tmp/MangoHud.conf")), - dirty: false, - } - } - - fn preset_map(preset: DashboardPreset) -> HashMap<&'static str, ConfigValue> { - preset_updates(preset).into_iter().collect() - } - - #[test] - fn benchmark_preset_is_built_for_stress_visibility() { - let preset = preset_map(DashboardPreset::Benchmark); - assert_eq!(preset.get("frame_timing"), Some(&ConfigValue::Flag)); - assert_eq!(preset.get("gpu_power"), Some(&ConfigValue::Flag)); - assert_eq!( - preset.get("position"), - Some(&ConfigValue::Value("top-left".to_string())) - ); - } - - #[test] - fn performance_preset_enables_common_metrics() { - let preset = preset_map(DashboardPreset::Performance); - assert_eq!(preset.get("gpu_stats"), Some(&ConfigValue::Flag)); - assert_eq!(preset.get("cpu_stats"), Some(&ConfigValue::Flag)); - assert_eq!(preset.get("ram"), Some(&ConfigValue::Flag)); - assert_eq!(preset.get("vram"), Some(&ConfigValue::Flag)); - } - - #[test] - fn competitive_preset_prefers_auto_width_for_side_anchors() { - let preset = preset_map(DashboardPreset::Competitive); - assert_eq!(preset.get("horizontal"), Some(&ConfigValue::Flag)); - assert_eq!( - preset.get("width"), - Some(&ConfigValue::Value("0".to_string())) - ); - } - - #[test] - fn streaming_preset_stays_clean_for_capture() { - let preset = preset_map(DashboardPreset::Streaming); - assert_eq!(preset.get("frametime"), Some(&ConfigValue::Disabled)); - assert_eq!(preset.get("text_outline"), Some(&ConfigValue::Flag)); - assert_eq!( - preset.get("position"), - Some(&ConfigValue::Value("top-center".to_string())) - ); - } - - #[test] - fn validation_counts_splits_errors_and_warnings() { - let validation = HashMap::from([ - ("a".to_string(), ValidationResult::Ok), - ( - "b".to_string(), - ValidationResult::Warning("warn".to_string()), - ), - ("c".to_string(), ValidationResult::Error("err".to_string())), - ]); - assert_eq!(validation_counts(&validation), (1, 1)); - } - - #[test] - fn right_aligned_hud_gets_extra_preview_margin() { - let left = preview_sizing_config("top-left", "1400"); - let right = preview_sizing_config("top-right", "1400"); - - let (left_width, _) = preview_window_settings(PreviewScene::Studio, &left); - let (right_width, _) = preview_window_settings(PreviewScene::Studio, &right); - - assert_eq!(right_width - left_width, 260); - assert!(right_width > left_width); - } - - #[test] - fn right_aligned_horizontal_layout_gets_much_larger_margin() { - let mut right = preview_sizing_config("top-right", "1400"); - right - .options - .insert("horizontal".to_string(), (2, ConfigValue::Flag)); - - let (right_width, _) = preview_window_settings(PreviewScene::Studio, &right); - assert_eq!(right_width, 2380); - } -} diff --git a/src/ui/pages/overview/cards.rs b/src/ui/pages/overview/cards.rs new file mode 100644 index 0000000..34efc25 --- /dev/null +++ b/src/ui/pages/overview/cards.rs @@ -0,0 +1,798 @@ +use super::*; + +pub(super) fn build_position_card(ctx: &PageBuildContext) -> gtk4::Box { + let card = dashboard_card(); + card.add_css_class("dashboard-position-card"); + card.append(&card_header( + "Layout & Position", + "Click where the overlay should anchor, then nudge offsets until it sits exactly where you want.", + )); + + let monitor = gtk4::Grid::new(); + monitor.add_css_class("position-grid"); + monitor.add_css_class("position-grid-compact"); + monitor.set_row_spacing(8); + monitor.set_column_spacing(8); + monitor.set_halign(gtk4::Align::Center); + + let current = current_string_value(ctx, "position").unwrap_or_else(|| "top-left".to_string()); + let buttons: Rc>> = Rc::new(RefCell::new(Vec::new())); + for (label, position, column, row) in [ + ("↖", "top-left", 0, 0), + ("↑", "top-center", 1, 0), + ("↗", "top-right", 2, 0), + ("←", "middle-left", 0, 1), + ("→", "middle-right", 2, 1), + ("↙", "bottom-left", 0, 2), + ("↓", "bottom-center", 1, 2), + ("↘", "bottom-right", 2, 2), + ] { + let button = gtk4::Button::with_label(label); + button.add_css_class("position-node"); + button.set_tooltip_text(Some(position)); + if current == position { + button.add_css_class("position-node-active"); + } + monitor.attach(&button, column, row, 1, 1); + buttons + .borrow_mut() + .push((position.to_string(), button.clone())); + + let ctx = ctx.clone(); + let buttons = buttons.clone(); + let selected = position.to_string(); + button.connect_clicked(move |_| { + set_config_value(&ctx, "position", ConfigValue::Value(selected.clone())); + update_position_buttons(&buttons.borrow(), &selected); + maybe_reload_preview_for_key(&ctx, "position"); + }); + } + + let center = gtk4::Label::new(Some("HUD")); + center.add_css_class("position-center"); + center.set_halign(gtk4::Align::Center); + center.set_valign(gtk4::Align::Center); + monitor.attach(¢er, 1, 1, 1, 1); + + card.append(&monitor); + card.append(&build_scale_control( + "Horizontal offset", + "offset_x", + 0.0, + 500.0, + 1.0, + 0, + ctx, + OffsetAxis::Horizontal, + )); + card.append(&build_scale_control( + "Vertical offset", + "offset_y", + 0.0, + 500.0, + 1.0, + 0, + ctx, + OffsetAxis::Vertical, + )); + card.append(&build_width_control(ctx)); + card.append(&build_scale_control( + "Table columns", + "table_columns", + 1.0, + 12.0, + 1.0, + 0, + ctx, + OffsetAxis::Raw, + )); + card +} + +pub(super) fn build_appearance_card(ctx: &PageBuildContext) -> gtk4::Box { + let card = dashboard_card(); + card.append(&card_header( + "Make It Readable", + "Keep the overlay legible at a glance. The sidebar still exposes every detailed MangoHud option when you need more control.", + )); + + card.append(&build_scale_control( + "Font size", + "font_size", + 8.0, + 36.0, + 1.0, + 0, + ctx, + OffsetAxis::Raw, + )); + card.append(&build_scale_control( + "Font scale", + "font_scale", + 0.5, + 2.0, + 0.05, + 2, + ctx, + OffsetAxis::Raw, + )); + card.append(&build_scale_control( + "Background opacity", + "background_alpha", + 0.0, + 1.0, + 0.01, + 2, + ctx, + OffsetAxis::Raw, + )); + card.append(&build_scale_control( + "Overall alpha", + "alpha", + 0.0, + 1.0, + 0.01, + 2, + ctx, + OffsetAxis::Raw, + )); + card.append(&build_scale_control( + "Corner radius", + "round_corners", + 0.0, + 50.0, + 1.0, + 0, + ctx, + OffsetAxis::Raw, + )); + + let toggles = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + toggles.append(&build_flag_toggle("Compact", "hud_compact", ctx)); + toggles.append(&build_flag_toggle("No margin", "hud_no_margin", ctx)); + toggles.append(&build_flag_toggle("Horizontal", "horizontal", ctx)); + toggles.append(&build_flag_toggle("Outline", "text_outline", ctx)); + card.append(&toggles); + + let colors_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + colors_row.append(&build_color_control("Text", "text_color", ctx)); + colors_row.append(&build_color_control("Background", "background_color", ctx)); + colors_row.append(&build_color_control("GPU", "gpu_color", ctx)); + colors_row.append(&build_color_control("CPU", "cpu_color", ctx)); + card.append(&colors_row); + card +} + +fn build_dashboard_toggle( + label: &str, + key: &str, + ctx: &PageBuildContext, + extra_classes: &[&str], +) -> gtk4::ToggleButton { + let button = gtk4::ToggleButton::with_label(label); + button.add_css_class("dashboard-toggle"); + for class_name in extra_classes { + button.add_css_class(class_name); + } + button.set_active(is_flag_enabled(ctx, key)); + + let ctx = ctx.clone(); + let key_owned = key.to_string(); + button.connect_toggled(move |button| { + let dependency_state_changed = if button.is_active() { + enable_toggle_with_dependencies(&ctx, &key_owned, &OptionType::Flag) + } else { + disable_toggle_with_dependents(&ctx, &key_owned, &OptionType::Flag) + }; + sync_config_ui(&ctx); + apply_live_preview_now(&ctx); + if dependency_state_changed { + refresh_current_page_later(&ctx.parent_window); + } + }); + + button +} + +pub(super) fn build_metrics_card(ctx: &PageBuildContext) -> gtk4::Box { + let card = dashboard_card(); + card.add_css_class("dashboard-full-width-card"); + card.append(&card_header( + "Show These Metrics", + "Turn on the HUD items most Linux gamers usually care about first, then go deeper in the sidebar when you need exact formatting and thresholds.", + )); + + card.append(&metric_group( + "Frame & pacing", + &[ + ("FPS", "fps"), + ("Frametime", "frametime"), + ("Frame timing", "frame_timing"), + ], + ctx, + )); + card.append(&metric_group( + "GPU", + &[ + ("GPU stats", "gpu_stats"), + ("GPU temp", "gpu_temp"), + ("GPU clock", "gpu_core_clock"), + ("VRAM", "vram"), + ], + ctx, + )); + card.append(&metric_group( + "CPU & memory", + &[ + ("CPU stats", "cpu_stats"), + ("CPU temp", "cpu_temp"), + ("RAM", "ram"), + ], + ctx, + )); + card.append(&metric_group( + "Extra", + &[ + ("Read I/O", "io_read"), + ("Write I/O", "io_write"), + ("Battery", "battery"), + ("Media", "media_player"), + ], + ctx, + )); + + let hint = gtk4::Label::new(Some( + "Use the GPU, CPU, Memory, Battery, and Performance pages for graphs, labels, thresholds, and the rest of MangoHud’s detailed metric formatting.", + )); + hint.add_css_class("dim-label"); + hint.set_xalign(0.0); + hint.set_wrap(true); + card.append(&hint); + card +} + +pub(super) fn build_workspace_strip(ctx: &PageBuildContext) -> gtk4::Box { + let strip = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + strip.add_css_class("dashboard-status-strip"); + + let summary = gtk4::Label::new(None); + summary.set_xalign(0.0); + summary.set_wrap(true); + summary.add_css_class("dashboard-status-line"); + summary.add_css_class("dashboard-status-summary"); + strip.append(&summary); + + refresh_workspace_status(ctx, &summary); + + { + let ctx = ctx.clone(); + let summary = summary.downgrade(); + glib::timeout_add_seconds_local(1, move || { + let Some(summary) = summary.upgrade() else { + return glib::ControlFlow::Break; + }; + refresh_workspace_status(&ctx, &summary); + glib::ControlFlow::Continue + }); + } + + strip +} + +pub(super) fn dashboard_card() -> gtk4::Box { + let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + card.add_css_class("dashboard-card"); + card.add_css_class("dashboard-card-compact"); + card.set_hexpand(true); + card.set_margin_top(0); + card.set_margin_bottom(0); + card.set_margin_start(0); + card.set_margin_end(0); + card +} + +pub(super) fn card_header(title: &str, subtitle: &str) -> gtk4::Box { + let box_ = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + + let title_label = gtk4::Label::new(Some(title)); + title_label.add_css_class("dashboard-card-title"); + title_label.set_xalign(0.0); + + let subtitle_label = gtk4::Label::new(Some(subtitle)); + subtitle_label.add_css_class("dashboard-card-subtitle"); + subtitle_label.set_wrap(true); + subtitle_label.set_xalign(0.0); + + box_.append(&title_label); + box_.append(&subtitle_label); + box_ +} + +#[allow(clippy::too_many_arguments)] +fn build_scale_control( + label: &str, + key: &str, + min: f64, + max: f64, + step: f64, + digits: u32, + ctx: &PageBuildContext, + axis: OffsetAxis, +) -> gtk4::Box { + let row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + + let title = gtk4::Label::new(Some(label)); + title.add_css_class("dashboard-field-label"); + title.set_xalign(0.0); + title.set_hexpand(true); + + let value_label = gtk4::Label::new(None); + value_label.add_css_class("dashboard-value-label"); + + top.append(&title); + top.append(&value_label); + + let scale = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); + scale.set_hexpand(true); + scale.set_draw_value(false); + scale.add_css_class("dashboard-scale"); + install_scroll_passthrough(scale.upcast_ref()); + + let initial = display_offset_value(ctx, key, axis) + .unwrap_or(min) + .clamp(min, max); + scale.set_value(initial); + update_scale_value_label(&value_label, initial, digits); + + let key_owned = key.to_string(); + let ctx = ctx.clone(); + let value_label_clone = value_label.clone(); + scale.connect_value_changed(move |scale| { + let value = scale.value(); + update_scale_value_label(&value_label_clone, value, digits); + let config_value = offset_config_value(&ctx, axis, value, digits); + set_config_value(&ctx, &key_owned, config_value); + maybe_reload_preview_for_key(&ctx, &key_owned); + }); + + row.append(&top); + row.append(&scale); + row +} + +#[derive(Clone, Copy)] +enum OffsetAxis { + Raw, + Horizontal, + Vertical, +} + +fn build_width_control(ctx: &PageBuildContext) -> gtk4::Box { + let row = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + + let title = gtk4::Label::new(Some("HUD width")); + title.add_css_class("dashboard-field-label"); + title.set_xalign(0.0); + title.set_hexpand(true); + + let auto_toggle = gtk4::Switch::new(); + let auto_width = current_numeric_value(ctx, "width").unwrap_or(0.0) <= 0.0; + auto_toggle.set_active(auto_width); + + let auto_label = gtk4::Label::new(Some("Auto")); + auto_label.add_css_class("dashboard-field-label"); + top.append(&title); + top.append(&auto_label); + top.append(&auto_toggle); + + let scale_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + let scale = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, 1.0, 1200.0, 10.0); + scale.set_hexpand(true); + scale.set_draw_value(false); + scale.add_css_class("dashboard-scale"); + install_scroll_passthrough(scale.upcast_ref()); + + let value_label = gtk4::Label::new(None); + value_label.add_css_class("dashboard-value-label"); + + let initial_width = current_numeric_value(ctx, "width") + .unwrap_or(300.0) + .clamp(1.0, 1200.0); + scale.set_value(initial_width); + update_scale_value_label(&value_label, initial_width, 0); + scale.set_sensitive(!auto_width); + if auto_width { + value_label.set_text("auto"); + } + + scale_row.append(&scale); + scale_row.append(&value_label); + + { + let ctx = ctx.clone(); + let value_label = value_label.clone(); + scale.connect_value_changed(move |scale| { + let value = scale.value().round().clamp(1.0, 1200.0); + update_scale_value_label(&value_label, value, 0); + set_config_value( + &ctx, + "width", + ConfigValue::Value((value as i64).to_string()), + ); + maybe_reload_preview_for_key(&ctx, "width"); + }); + } + + { + let ctx = ctx.clone(); + let scale = scale.clone(); + let value_label = value_label.clone(); + auto_toggle.connect_active_notify(move |toggle| { + let enabled = toggle.is_active(); + scale.set_sensitive(!enabled); + if enabled { + value_label.set_text("auto"); + set_config_value(&ctx, "width", ConfigValue::Value("0".to_string())); + } else { + let value = scale.value().round().clamp(1.0, 1200.0); + update_scale_value_label(&value_label, value, 0); + set_config_value( + &ctx, + "width", + ConfigValue::Value((value as i64).to_string()), + ); + } + maybe_reload_preview_for_key(&ctx, "width"); + }); + } + + row.append(&top); + row.append(&scale_row); + row +} + +fn build_flag_toggle(label: &str, key: &str, ctx: &PageBuildContext) -> gtk4::ToggleButton { + build_dashboard_toggle(label, key, ctx, &[]) +} + +fn build_color_control(label: &str, key: &str, ctx: &PageBuildContext) -> gtk4::Box { + let group = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + group.add_css_class("color-control"); + + let title = gtk4::Label::new(Some(label)); + title.add_css_class("dashboard-field-label"); + title.set_xalign(0.0); + + let dialog = gtk4::ColorDialog::builder() + .title(label) + .modal(true) + .build(); + let swatch = gtk4::ColorDialogButton::new(Some(dialog)); + swatch.add_css_class("color-swatch-button"); + swatch.add_css_class("dashboard-color-button"); + + let entry = gtk4::Entry::new(); + entry.set_width_chars(8); + entry.set_max_length(6); + entry.set_placeholder_text(Some("RRGGBB")); + + if let Some(color) = current_string_value(ctx, key) { + entry.set_text(&color); + if let Some(rgba) = hex_to_rgba(&color) { + swatch.set_rgba(&rgba); + } + } + + { + let ctx = ctx.clone(); + let ctx_for_change = ctx.clone(); + let key_owned = key.to_string(); + let swatch_for_entry = swatch.clone(); + let pending_preview_refresh = Rc::new(Cell::new(false)); + let pending_refresh_for_change = pending_preview_refresh.clone(); + let syncing = Rc::new(Cell::new(false)); + let syncing_for_entry = syncing.clone(); + entry.connect_changed(move |entry| { + if syncing_for_entry.get() { + return; + } + let text = entry.text().to_string().to_ascii_uppercase(); + set_config_value( + &ctx_for_change, + &key_owned, + ConfigValue::Value(text.clone()), + ); + if let Some(rgba) = hex_to_rgba(&text) { + syncing_for_entry.set(true); + swatch_for_entry.set_rgba(&rgba); + syncing_for_entry.set(false); + } + pending_refresh_for_change.set(true); + }); + + connect_dashboard_entry_preview_commit(&entry, &ctx, key, pending_preview_refresh); + let ctx = ctx.clone(); + let key_owned = key.to_string(); + let entry = entry.clone(); + let syncing_for_swatch = syncing.clone(); + swatch.connect_rgba_notify(move |button| { + if syncing_for_swatch.get() { + return; + } + let hex = rgba_to_hex(&button.rgba()); + syncing_for_swatch.set(true); + entry.set_text(&hex); + syncing_for_swatch.set(false); + set_config_value(&ctx, &key_owned, ConfigValue::Value(hex)); + maybe_reload_preview_for_key(&ctx, &key_owned); + }); + } + + group.append(&title); + group.append(&swatch); + group.append(&entry); + group +} + +fn connect_dashboard_entry_preview_commit( + entry: >k4::Entry, + ctx: &PageBuildContext, + key: &str, + pending_refresh: Rc>, +) { + let ctx_clone = ctx.clone(); + entry.connect_activate(move |_| { + gtk4::prelude::GtkWindowExt::set_focus( + &ctx_clone.parent_window, + Option::<>k4::Widget>::None, + ); + }); + + let focus = gtk4::EventControllerFocus::new(); + let key_owned = key.to_string(); + let ctx_clone = ctx.clone(); + let pending_refresh_clone = pending_refresh.clone(); + focus.connect_leave(move |_| { + if pending_refresh_clone.replace(false) { + maybe_reload_preview_for_key(&ctx_clone, &key_owned); + } + }); + entry.add_controller(focus); +} + +fn metric_group(title: &str, items: &[(&str, &str)], ctx: &PageBuildContext) -> gtk4::Box { + let group = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + group.add_css_class("metric-group"); + group.set_valign(gtk4::Align::Start); + + let heading = gtk4::Label::new(Some(title)); + heading.add_css_class("dashboard-field-label"); + heading.add_css_class("metric-group-title"); + heading.set_xalign(0.0); + heading.set_yalign(0.2); + heading.set_width_chars(12); + heading.set_halign(gtk4::Align::Start); + heading.set_valign(gtk4::Align::Start); + group.append(&heading); + + let wrap = gtk4::FlowBox::new(); + wrap.set_selection_mode(gtk4::SelectionMode::None); + wrap.set_max_children_per_line(4); + wrap.set_row_spacing(4); + wrap.set_column_spacing(6); + wrap.add_css_class("metric-group-flow"); + wrap.set_hexpand(true); + + for (label, key) in items { + let chip = build_metric_toggle(label, key, ctx); + wrap.insert(&chip, -1); + } + + group.append(&wrap); + group +} + +fn build_metric_toggle(label: &str, key: &str, ctx: &PageBuildContext) -> gtk4::ToggleButton { + build_dashboard_toggle(label, key, ctx, &["metric-toggle"]) +} + +pub(super) fn refresh_preview_widgets( + ctx: &PageBuildContext, + start_button: >k4::Button, + status_label: >k4::Label, + reload_button: >k4::Button, + restart_button: >k4::Button, + stop_button: >k4::Button, +) { + let snapshot = ctx.preview.snapshot(); + status_label.set_text(&snapshot.status); + status_label.remove_css_class("preview-status-live"); + status_label.remove_css_class("preview-status-idle"); + if snapshot.running { + status_label.add_css_class("preview-status-live"); + } else { + status_label.add_css_class("preview-status-idle"); + } + + start_button.set_sensitive(!snapshot.running); + reload_button.set_sensitive(snapshot.running); + restart_button.set_sensitive(snapshot.running && snapshot.can_restart); + stop_button.set_sensitive(snapshot.running); +} + +fn refresh_workspace_status(ctx: &PageBuildContext, summary: >k4::Label) { + let Ok(state) = ctx.state.lock() else { + summary.set_text("State unavailable"); + return; + }; + + let (errors, warnings) = validation_counts(&state.validation); + let validation_text = match (errors, warnings) { + (0, 0) => "Validation clear".to_string(), + (0, warnings) => format!("{warnings} warning(s)"), + (errors, warnings) => format!("{errors} error(s), {warnings} warning(s)"), + }; + let dirty_text = if state.dirty { + "Unsaved changes" + } else { + "Saved" + }; + let config_target = state + .config + .path + .as_ref() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .unwrap_or("(unsaved config)"); + + summary.set_text(&format!( + "{dirty_text} • {validation_text} • Target: {config_target} • MangoHud {} • {:?} • GPU {:?}", + ctx.system_info + .mangohud + .version + .clone() + .unwrap_or_else(|| "unknown".to_string()), + ctx.system_info.display_server, + ctx.system_info.gpu.vendor + )); +} + +pub(super) fn validation_counts(validation: &HashMap) -> (usize, usize) { + let mut errors = 0; + let mut warnings = 0; + for result in validation.values() { + match result { + ValidationResult::Error(_) => errors += 1, + ValidationResult::Warning(_) => warnings += 1, + ValidationResult::Ok => {} + } + } + (errors, warnings) +} + +pub(super) fn current_config_position(config: &AnnotatedConfig) -> Option { + config + .options + .get("position") + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => Some(value.clone()), + _ => None, + }) +} + +pub(super) fn set_config_value(ctx: &PageBuildContext, key: &str, value: ConfigValue) { + let Ok(mut state) = ctx.state.lock() else { + return; + }; + Parser::set_value(&mut state.config, key, value); + state.dirty = state.config.dirty; + drop(state); + sync_config_ui(ctx); +} + +pub(super) fn maybe_reload_preview_for_key(ctx: &PageBuildContext, key: &str) { + refresh_live_preview_for_key(ctx, Some(key)); +} + +fn current_numeric_value(ctx: &PageBuildContext, key: &str) -> Option { + current_string_value(ctx, key)?.parse::().ok() +} + +fn display_offset_value(ctx: &PageBuildContext, key: &str, _axis: OffsetAxis) -> Option { + let raw = current_numeric_value(ctx, key)?; + Some(raw.abs().max(0.0)) +} + +fn offset_config_value( + _ctx: &PageBuildContext, + _axis: OffsetAxis, + value: f64, + digits: u32, +) -> ConfigValue { + let stored = value.abs(); + if digits == 0 { + ConfigValue::Value((stored.round() as i64).to_string()) + } else { + ConfigValue::Value(format!("{stored:.precision$}", precision = digits as usize)) + } +} + +pub(super) fn install_scroll_passthrough(widget: >k4::Widget) { + let controller = gtk4::EventControllerScroll::new( + gtk4::EventControllerScrollFlags::VERTICAL | gtk4::EventControllerScrollFlags::DISCRETE, + ); + controller.connect_scroll(move |controller, _dx, dy| { + let Some(widget) = controller.widget() else { + return glib::Propagation::Proceed; + }; + let Some(ancestor) = widget.ancestor(gtk4::ScrolledWindow::static_type()) else { + return glib::Propagation::Proceed; + }; + let Ok(scrolled) = ancestor.downcast::() else { + return glib::Propagation::Proceed; + }; + let adjustment = scrolled.vadjustment(); + let page = adjustment.page_size().max(120.0); + let next = (adjustment.value() + dy * (page * 0.22)).clamp( + adjustment.lower(), + (adjustment.upper() - adjustment.page_size()).max(0.0), + ); + adjustment.set_value(next); + glib::Propagation::Stop + }); + widget.add_controller(controller); +} + +fn current_string_value(ctx: &PageBuildContext, key: &str) -> Option { + let Ok(state) = ctx.state.lock() else { + return None; + }; + state + .config + .options + .get(key) + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => Some(value.clone()), + ConfigValue::Flag => Some("1".to_string()), + ConfigValue::Absent | ConfigValue::Disabled => None, + }) +} + +fn is_flag_enabled(ctx: &PageBuildContext, key: &str) -> bool { + let Ok(state) = ctx.state.lock() else { + return false; + }; + state + .config + .options + .get(key) + .map(|(_, value)| match value { + ConfigValue::Flag => true, + ConfigValue::Value(raw) => { + let normalized = raw.trim().to_ascii_lowercase(); + !matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off") + } + ConfigValue::Disabled | ConfigValue::Absent => false, + }) + .unwrap_or_else(|| flag_defaults_to_enabled(key)) +} + +fn update_scale_value_label(label: >k4::Label, value: f64, digits: u32) { + if digits == 0 { + label.set_text(&(value.round() as i64).to_string()); + } else { + label.set_text(&format!("{value:.precision$}", precision = digits as usize)); + } +} + +fn update_position_buttons(buttons: &[(String, gtk4::Button)], selected: &str) { + for (position, button) in buttons { + if position == selected { + button.add_css_class("position-node-active"); + } else { + button.remove_css_class("position-node-active"); + } + } +} diff --git a/src/ui/pages/overview/mod.rs b/src/ui/pages/overview/mod.rs new file mode 100644 index 0000000..e9cfee1 --- /dev/null +++ b/src/ui/pages/overview/mod.rs @@ -0,0 +1,186 @@ +use crate::ui::pages::{ + apply_live_preview_now, current_config_snapshot, disable_toggle_with_dependents, + enable_toggle_with_dependencies, refresh_current_page_later, refresh_live_preview_for_key, + sync_config_ui, PageBuildContext, +}; +use crate::ui::toast::show_toast; +use crate::ui::widgets::color_utils::{hex_to_rgba, rgba_to_hex}; +use crate::ui::widgets::toggle_row::configure_spin_button_for_option_type; +use crate::ui::widgets::tool_page; +use crate::window::app_settings; +use gtk4::pango::EllipsizeMode; +use gtk4::prelude::*; +use libadwaita::prelude::{AlertDialogExt, AlertDialogExtManual}; +use mangotune::config::parser::{flag_defaults_to_enabled, Parser}; +use mangotune::config::schema::MANGOHUD_SCHEMA; +use mangotune::config::types::{ + AnnotatedConfig, Category, ConfigValue, OptionType, ValidationResult, +}; +use mangotune::preview::{ + effective_preview_hud_width, PreviewScene, PreviewStudioOptions, StudioScene, +}; +use mangotune::profiles as stored_profiles; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::rc::Rc; + +mod cards; +mod presets; +mod preview; +mod profiles; + +use cards::{ + build_appearance_card, build_metrics_card, build_position_card, build_workspace_strip, +}; +pub(crate) use presets::build_presets_panel; +pub(crate) use preview::build_preview_panel; +use profiles::{active_config_name, build_profiles_panel}; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let chips_owned = [ + format!( + "MangoHud {}", + ctx.system_info + .mangohud + .version + .clone() + .unwrap_or_else(|| "unknown".to_string()) + ), + format!("Display {:?}", ctx.system_info.display_server), + format!("GPU {:?}", ctx.system_info.gpu.vendor), + format!("Config {}", active_config_name(ctx)), + ]; + let chips = chips_owned.iter().map(String::as_str).collect::>(); + let (scroll, root) = tool_page::build_start_page( + "Dashboard", + "Start", + "Start with layout, scale, colors, and the metrics most people toggle first. Start pages cover preview launch and starter presets when you need them.", + &chips, + ); + + let top_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + top_row.add_css_class("dashboard-row"); + let position = build_position_card(ctx); + position.add_css_class("dashboard-primary-card"); + let appearance = build_appearance_card(ctx); + appearance.add_css_class("dashboard-secondary-card"); + top_row.append(&position); + top_row.append(&appearance); + root.append(&top_row); + + root.append(&build_metrics_card(ctx)); + let profiles = build_profiles_panel(ctx); + profiles.add_css_class("dashboard-footer-card"); + root.append(&profiles); + + root.append(&build_workspace_strip(ctx)); + + scroll +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + + fn preview_sizing_config(position: &str, width: &str) -> AnnotatedConfig { + let mut options = indexmap::IndexMap::new(); + options.insert( + "position".to_string(), + (0, ConfigValue::Value(position.to_string())), + ); + options.insert( + "width".to_string(), + (1, ConfigValue::Value(width.to_string())), + ); + AnnotatedConfig { + lines: Vec::new(), + options, + path: Some(PathBuf::from("/tmp/MangoHud.conf")), + dirty: false, + } + } + + fn preset_map(preset: presets::DashboardPreset) -> HashMap<&'static str, ConfigValue> { + presets::preset_updates(preset).into_iter().collect() + } + + #[test] + fn benchmark_preset_is_built_for_stress_visibility() { + let preset = preset_map(presets::DashboardPreset::Benchmark); + assert_eq!(preset.get("frame_timing"), Some(&ConfigValue::Flag)); + assert_eq!(preset.get("gpu_power"), Some(&ConfigValue::Flag)); + assert_eq!( + preset.get("position"), + Some(&ConfigValue::Value("top-left".to_string())) + ); + } + + #[test] + fn performance_preset_enables_common_metrics() { + let preset = preset_map(presets::DashboardPreset::Performance); + assert_eq!(preset.get("gpu_stats"), Some(&ConfigValue::Flag)); + assert_eq!(preset.get("cpu_stats"), Some(&ConfigValue::Flag)); + assert_eq!(preset.get("ram"), Some(&ConfigValue::Flag)); + assert_eq!(preset.get("vram"), Some(&ConfigValue::Flag)); + } + + #[test] + fn competitive_preset_prefers_auto_width_for_side_anchors() { + let preset = preset_map(presets::DashboardPreset::Competitive); + assert_eq!(preset.get("horizontal"), Some(&ConfigValue::Flag)); + assert_eq!( + preset.get("width"), + Some(&ConfigValue::Value("0".to_string())) + ); + } + + #[test] + fn streaming_preset_stays_clean_for_capture() { + let preset = preset_map(presets::DashboardPreset::Streaming); + assert_eq!(preset.get("frametime"), Some(&ConfigValue::Disabled)); + assert_eq!(preset.get("text_outline"), Some(&ConfigValue::Flag)); + assert_eq!( + preset.get("position"), + Some(&ConfigValue::Value("top-center".to_string())) + ); + } + + #[test] + fn validation_counts_splits_errors_and_warnings() { + let validation = HashMap::from([ + ("a".to_string(), ValidationResult::Ok), + ( + "b".to_string(), + ValidationResult::Warning("warn".to_string()), + ), + ("c".to_string(), ValidationResult::Error("err".to_string())), + ]); + assert_eq!(cards::validation_counts(&validation), (1, 1)); + } + + #[test] + fn right_aligned_hud_gets_extra_preview_margin() { + let left = preview_sizing_config("top-left", "1400"); + let right = preview_sizing_config("top-right", "1400"); + + let (left_width, _) = preview::preview_window_settings(PreviewScene::Studio, &left); + let (right_width, _) = preview::preview_window_settings(PreviewScene::Studio, &right); + + assert_eq!(right_width - left_width, 260); + assert!(right_width > left_width); + } + + #[test] + fn right_aligned_horizontal_layout_gets_much_larger_margin() { + let mut right = preview_sizing_config("top-right", "1400"); + right + .options + .insert("horizontal".to_string(), (2, ConfigValue::Flag)); + + let (right_width, _) = preview::preview_window_settings(PreviewScene::Studio, &right); + assert_eq!(right_width, 2380); + } +} diff --git a/src/ui/pages/overview/presets.rs b/src/ui/pages/overview/presets.rs new file mode 100644 index 0000000..6900aed --- /dev/null +++ b/src/ui/pages/overview/presets.rs @@ -0,0 +1,380 @@ +use super::cards::dashboard_card; +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum DashboardPreset { + Benchmark, + Competitive, + Performance, + Streaming, +} + +impl DashboardPreset { + fn title(self) -> &'static str { + match self { + Self::Benchmark => "Benchmark", + Self::Competitive => "Competitive", + Self::Performance => "Performance", + Self::Streaming => "Streaming", + } + } + + fn description(self) -> &'static str { + match self { + Self::Benchmark => { + "Fuller telemetry for testing runs, frame pacing checks, and API/session verification." + } + Self::Competitive => { + "Lean top-center readout with FPS thresholds and almost no visual bulk." + } + Self::Performance => { + "Centered horizontal monitoring with FPS, frametime, temps, CPU, RAM, and VRAM." + } + Self::Streaming => { + "Cleaner top-center overlay that stays readable on capture without too much noise." + } + } + } + + fn badge(self) -> &'static str { + match self { + Self::Benchmark => "stress pass", + Self::Competitive => "fast glance", + Self::Performance => "monitoring", + Self::Streaming => "capture ready", + } + } + + fn profile_name(self) -> &'static str { + match self { + Self::Benchmark => "Benchmark", + Self::Competitive => "Competitive", + Self::Performance => "Performance", + Self::Streaming => "Streaming", + } + } +} + +pub(crate) fn build_presets_panel(ctx: &PageBuildContext) -> gtk4::Box { + let card = dashboard_card(); + let grid = gtk4::Grid::new(); + grid.set_row_spacing(8); + grid.set_column_spacing(8); + grid.attach( + &build_preset_button(DashboardPreset::Benchmark, ctx), + 0, + 0, + 1, + 1, + ); + grid.attach( + &build_preset_button(DashboardPreset::Competitive, ctx), + 1, + 0, + 1, + 1, + ); + grid.attach( + &build_preset_button(DashboardPreset::Performance, ctx), + 0, + 1, + 1, + 1, + ); + grid.attach( + &build_preset_button(DashboardPreset::Streaming, ctx), + 1, + 1, + 1, + 1, + ); + + let note = gtk4::Label::new(Some( + "Presets update the current in-memory config and refresh live preview if it is running. If a matching profile is missing, MangoTune falls back to its built-in mapping.", + )); + note.add_css_class("dim-label"); + note.set_wrap(true); + note.set_xalign(0.0); + + card.append(&grid); + card.append(¬e); + card +} + +fn build_preset_button(preset: DashboardPreset, ctx: &PageBuildContext) -> gtk4::Button { + let button = gtk4::Button::new(); + button.add_css_class("dashboard-preset-button"); + button.set_hexpand(true); + button.set_halign(gtk4::Align::Fill); + + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + content.set_halign(gtk4::Align::Start); + + let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let title = gtk4::Label::new(Some(preset.title())); + title.add_css_class("dashboard-card-title"); + title.set_xalign(0.0); + title.set_hexpand(true); + + let badge = gtk4::Label::new(Some(preset.badge())); + badge.add_css_class("dashboard-preset-badge"); + + top.append(&title); + top.append(&badge); + + let description = gtk4::Label::new(Some(preset.description())); + description.add_css_class("dashboard-card-subtitle"); + description.set_wrap(true); + description.set_xalign(0.0); + + content.append(&top); + content.append(&description); + button.set_child(Some(&content)); + + let ctx = ctx.clone(); + button.connect_clicked(move |_| { + apply_dashboard_preset(&ctx, preset); + }); + + button +} + +fn apply_dashboard_preset(ctx: &PageBuildContext, preset: DashboardPreset) { + let applied_from_profile = apply_dashboard_preset_profile(ctx, preset); + if !applied_from_profile { + let updates = preset_updates(preset); + apply_config_updates(ctx, &updates); + } + apply_live_preview_now(ctx); + refresh_current_page_later(&ctx.parent_window); + show_toast( + &ctx.toast_overlay, + &format!( + "Applied {} preset{}", + preset.title(), + if applied_from_profile { + "" + } else { + " (fallback mapping)" + } + ), + ); +} + +fn apply_dashboard_preset_profile(ctx: &PageBuildContext, preset: DashboardPreset) -> bool { + let target_path = current_config_snapshot(ctx).path; + let Ok(loaded) = stored_profiles::load_profile(preset.profile_name(), target_path) else { + return false; + }; + + let Ok(mut state) = ctx.state.lock() else { + return false; + }; + state.config = loaded; + state.dirty = true; + drop(state); + sync_config_ui(ctx); + true +} + +fn apply_config_updates(ctx: &PageBuildContext, updates: &[(&str, ConfigValue)]) { + let Ok(mut state) = ctx.state.lock() else { + return; + }; + for (key, value) in updates { + Parser::set_value(&mut state.config, key, value.clone()); + } + state.dirty = state.config.dirty; + drop(state); + sync_config_ui(ctx); +} + +pub(super) fn preset_updates(preset: DashboardPreset) -> Vec<(&'static str, ConfigValue)> { + let mut updates = base_preset_updates(); + match preset { + DashboardPreset::Benchmark => { + updates.extend([ + ("fps", ConfigValue::Flag), + ("frametime", ConfigValue::Flag), + ("frame_timing", ConfigValue::Flag), + ("gpu_stats", ConfigValue::Flag), + ("gpu_temp", ConfigValue::Flag), + ("gpu_core_clock", ConfigValue::Flag), + ("gpu_mem_clock", ConfigValue::Flag), + ("gpu_power", ConfigValue::Flag), + ("cpu_stats", ConfigValue::Flag), + ("ram", ConfigValue::Flag), + ("vram", ConfigValue::Flag), + ("position", ConfigValue::Value("top-left".to_string())), + ("table_columns", ConfigValue::Value("6".to_string())), + ("hud_compact", ConfigValue::Flag), + ("font_size", ConfigValue::Value("22".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("background_alpha", ConfigValue::Value("0.75".to_string())), + ("alpha", ConfigValue::Value("0.95".to_string())), + ("text_outline", ConfigValue::Flag), + ( + "text_outline_color", + ConfigValue::Value("000000".to_string()), + ), + ( + "text_outline_thickness", + ConfigValue::Value("2".to_string()), + ), + ("fps_sampling_period", ConfigValue::Value("500".to_string())), + ("gamemode", ConfigValue::Flag), + ("vkbasalt", ConfigValue::Flag), + ("display_server", ConfigValue::Flag), + ("dx_api", ConfigValue::Flag), + ("resolution", ConfigValue::Flag), + ("version", ConfigValue::Flag), + ("fsr", ConfigValue::Flag), + ("hdr", ConfigValue::Flag), + ]); + } + DashboardPreset::Competitive => { + updates.extend([ + ("fps", ConfigValue::Flag), + ("frametime", ConfigValue::Flag), + ("frame_timing", ConfigValue::Disabled), + ("gpu_stats", ConfigValue::Disabled), + ("gpu_temp", ConfigValue::Disabled), + ("cpu_stats", ConfigValue::Disabled), + ("cpu_temp", ConfigValue::Disabled), + ("ram", ConfigValue::Disabled), + ("vram", ConfigValue::Disabled), + ("position", ConfigValue::Value("top-center".to_string())), + ("table_columns", ConfigValue::Value("6".to_string())), + ("horizontal", ConfigValue::Flag), + ("hud_no_margin", ConfigValue::Flag), + ("font_size", ConfigValue::Value("26".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("background_alpha", ConfigValue::Value("0.00".to_string())), + ("alpha", ConfigValue::Value("1.00".to_string())), + ("text_outline", ConfigValue::Flag), + ( + "text_outline_color", + ConfigValue::Value("000000".to_string()), + ), + ( + "text_outline_thickness", + ConfigValue::Value("2".to_string()), + ), + ("fps_color_change", ConfigValue::Flag), + ("fps_value", ConfigValue::Value("60,90".to_string())), + ( + "fps_color", + ConfigValue::Value("ff3333,ffaa00,00ff6a".to_string()), + ), + ("width", ConfigValue::Value("0".to_string())), + ]); + } + DashboardPreset::Performance => { + updates.extend([ + ("fps", ConfigValue::Flag), + ("frametime", ConfigValue::Flag), + ("frame_timing", ConfigValue::Flag), + ("gpu_stats", ConfigValue::Flag), + ("gpu_temp", ConfigValue::Flag), + ("cpu_stats", ConfigValue::Flag), + ("cpu_temp", ConfigValue::Flag), + ("ram", ConfigValue::Flag), + ("vram", ConfigValue::Flag), + ("position", ConfigValue::Value("top-center".to_string())), + ("table_columns", ConfigValue::Value("6".to_string())), + ("horizontal", ConfigValue::Flag), + ("hud_no_margin", ConfigValue::Flag), + ("font_size", ConfigValue::Value("20".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("background_alpha", ConfigValue::Value("0.60".to_string())), + ("alpha", ConfigValue::Value("0.95".to_string())), + ("text_outline", ConfigValue::Flag), + ( + "text_outline_color", + ConfigValue::Value("000000".to_string()), + ), + ( + "text_outline_thickness", + ConfigValue::Value("2".to_string()), + ), + ("width", ConfigValue::Value("0".to_string())), + ]); + } + DashboardPreset::Streaming => { + updates.extend([ + ("fps", ConfigValue::Flag), + ("frametime", ConfigValue::Disabled), + ("frame_timing", ConfigValue::Disabled), + ("gpu_stats", ConfigValue::Flag), + ("gpu_temp", ConfigValue::Disabled), + ("cpu_stats", ConfigValue::Flag), + ("cpu_temp", ConfigValue::Disabled), + ("ram", ConfigValue::Flag), + ("vram", ConfigValue::Disabled), + ("position", ConfigValue::Value("top-center".to_string())), + ("table_columns", ConfigValue::Value("6".to_string())), + ("horizontal", ConfigValue::Flag), + ("hud_no_margin", ConfigValue::Flag), + ("font_size", ConfigValue::Value("22".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("background_alpha", ConfigValue::Value("0.50".to_string())), + ("alpha", ConfigValue::Value("0.90".to_string())), + ("round_corners", ConfigValue::Value("10".to_string())), + ("text_outline", ConfigValue::Flag), + ( + "text_outline_color", + ConfigValue::Value("000000".to_string()), + ), + ( + "text_outline_thickness", + ConfigValue::Value("2".to_string()), + ), + ("width", ConfigValue::Value("0".to_string())), + ]); + } + } + updates +} + +fn base_preset_updates() -> Vec<(&'static str, ConfigValue)> { + let mut updates = MANGOHUD_SCHEMA + .iter() + .filter(|entry| is_display_category(&entry.category)) + .map(|entry| (entry.key, ConfigValue::Disabled)) + .collect::>(); + updates.extend([ + ("hud_compact", ConfigValue::Flag), + ("hud_no_margin", ConfigValue::Disabled), + ("horizontal", ConfigValue::Disabled), + ("horizontal_stretch", ConfigValue::Disabled), + ("text_outline", ConfigValue::Disabled), + ("position", ConfigValue::Value("top-left".to_string())), + ("offset_x", ConfigValue::Value("0".to_string())), + ("offset_y", ConfigValue::Value("0".to_string())), + ("round_corners", ConfigValue::Value("12".to_string())), + ("background_alpha", ConfigValue::Value("0.28".to_string())), + ("alpha", ConfigValue::Value("1.00".to_string())), + ("font_size", ConfigValue::Value("24".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("width", ConfigValue::Value("300".to_string())), + ]); + updates +} + +fn is_display_category(category: &Category) -> bool { + matches!( + category, + Category::DisplayFps + | Category::DisplayGpu + | Category::DisplayCpu + | Category::DisplayMemory + | Category::DisplayIoNetwork + | Category::DisplayMisc + | Category::DisplayGraphs + | Category::DisplayBattery + | Category::DisplayMediaPlayer + | Category::DisplayGamescope + | Category::DisplaySteamDeck + | Category::DisplayTimeText + ) +} diff --git a/src/ui/pages/overview/preview.rs b/src/ui/pages/overview/preview.rs new file mode 100644 index 0000000..43bda28 --- /dev/null +++ b/src/ui/pages/overview/preview.rs @@ -0,0 +1,1281 @@ +use super::cards::{current_config_position, install_scroll_passthrough, refresh_preview_widgets}; +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PreviewProfile { + Balanced, + ThresholdTest, + GpuStress, + CpuStress, + VramTest, +} + +impl PreviewProfile { + fn title(self) -> &'static str { + match self { + Self::Balanced => "Balanced", + Self::ThresholdTest => "Threshold Test", + Self::GpuStress => "GPU Stress", + Self::CpuStress => "CPU Stress", + Self::VramTest => "VRAM Test", + } + } + + fn tooltip(self) -> &'static str { + match self { + Self::Balanced => { + "General-purpose preview defaults with moderate motion and safe load." + } + Self::ThresholdTest => { + "Steadier FPS and mixed CPU/GPU motion for testing threshold colors." + } + Self::GpuStress => { + "Push GPU-related readouts harder with more coverage and redraw work." + } + Self::CpuStress => { + "Raise CPU-related readouts with more particles and simulation work." + } + Self::VramTest => { + "Raise VRAM usage without turning the rest of the preview into a benchmark." + } + } + } + + fn studio(self) -> PreviewStudioOptions { + match self { + Self::Balanced => PreviewStudioOptions::default(), + Self::ThresholdTest => PreviewStudioOptions { + scene: StudioScene::StaticInspection, + fps_cap: Some(60), + vsync: false, + vram_pressure_mb: 256, + particle_count: 2_000, + particle_size: 0.05, + gpu_passes: 2, + interaction_steps: 12, + paused: false, + }, + Self::GpuStress => PreviewStudioOptions { + scene: StudioScene::MotionStress, + fps_cap: None, + vsync: false, + vram_pressure_mb: 256, + particle_count: 2_000, + particle_size: 0.08, + gpu_passes: 4, + interaction_steps: 4, + paused: false, + }, + Self::CpuStress => PreviewStudioOptions { + scene: StudioScene::NoiseField, + fps_cap: Some(120), + vsync: false, + vram_pressure_mb: 128, + particle_count: 8_000, + particle_size: 0.03, + gpu_passes: 1, + interaction_steps: 32, + paused: false, + }, + Self::VramTest => PreviewStudioOptions { + scene: StudioScene::DarkArena, + fps_cap: Some(120), + vsync: false, + vram_pressure_mb: 1024, + particle_count: 1_000, + particle_size: 0.03, + gpu_passes: 1, + interaction_steps: 0, + paused: false, + }, + } + } + + fn all() -> &'static [Self] { + &[ + Self::Balanced, + Self::ThresholdTest, + Self::GpuStress, + Self::CpuStress, + Self::VramTest, + ] + } +} + +fn studio_scene_title(scene: StudioScene) -> &'static str { + match scene { + StudioScene::DarkArena => "Dark Arena", + StudioScene::BrightWash => "Bright Wash", + StudioScene::MotionStress => "Motion Stress", + StudioScene::StaticInspection => "Static Inspection", + StudioScene::NoiseField => "Noise Field", + } +} + +fn preview_group(title: &str, description: &str) -> (gtk4::Box, gtk4::Box) { + let group = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + group.add_css_class("dashboard-status-panel"); + group.add_css_class("preview-group"); + + let heading = gtk4::Label::new(Some(title)); + heading.add_css_class("dashboard-field-label"); + heading.set_xalign(0.0); + + let subtitle = gtk4::Label::new(Some(description)); + subtitle.add_css_class("dim-label"); + subtitle.set_wrap(true); + subtitle.set_xalign(0.0); + + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + body.add_css_class("preview-group-body"); + + group.append(&heading); + group.append(&subtitle); + group.append(&body); + (group, body) +} + +fn configure_preview_spin( + spin: >k4::SpinButton, + option_type: &OptionType, + tooltip: &str, + digits: u32, +) { + spin.add_css_class("control-field"); + spin.add_css_class("preview-fixed-spin"); + spin.set_hexpand(false); + spin.set_halign(gtk4::Align::End); + spin.set_size_request(108, -1); + configure_spin_button_for_option_type(spin, option_type); + spin.set_digits(digits); + spin.set_tooltip_text(Some(tooltip)); + install_scroll_passthrough(spin.upcast_ref()); +} + +fn preview_vram_total_mb(ctx: &PageBuildContext) -> u32 { + ctx.system_info + .gpu + .total_vram_mb + .unwrap_or(4_096) + .clamp(512, 65_536) +} + +fn preview_vram_used_mb(ctx: &PageBuildContext) -> Option { + ctx.system_info + .gpu + .used_vram_mb + .map(|mb| mb.clamp(0, preview_vram_total_mb(ctx))) +} + +fn preview_vram_safe_max_mb(ctx: &PageBuildContext) -> u32 { + let total = preview_vram_total_mb(ctx); + let reserve_mb = ((total as f64) * 0.10).ceil() as u32; + let reserve_mb = reserve_mb.max(1_024).min(total); + + let free_budget_mb = preview_vram_used_mb(ctx) + .map(|used| total.saturating_sub(used).saturating_sub(reserve_mb)) + .unwrap_or_else(|| total.saturating_sub(reserve_mb)); + + // The preview tends to use more VRAM than the raw pressure setting alone, + // so keep Safe max deliberately conservative. + let conservative_target = free_budget_mb / 2; + let rounded = (conservative_target / 256) * 256; + rounded.clamp(0, total) +} + +fn format_mib_as_gib_text(mb: u32) -> String { + format!("{:.1} GiB", mb as f64 / 1024.0) +} + +fn default_preview_window_size() -> (i32, i32) { + (1280, 720) +} + +fn persist_studio_options(studio: &PreviewStudioOptions) { + persist_preview_studio_scene(studio.scene); + persist_studio_fps_cap(studio.fps_cap.unwrap_or(0) as i32); + persist_preview_vsync(studio.vsync); + persist_studio_vram_pressure(studio.vram_pressure_mb as i32); + persist_studio_particle_count(studio.particle_count as i32); + persist_studio_particle_size(studio.particle_size as f64); + persist_studio_gpu_passes(studio.gpu_passes as i32); + persist_studio_interaction_steps(studio.interaction_steps as i32); +} + +#[derive(Clone)] +struct PreviewSessionWidgets { + start_button: gtk4::Button, + status_label: gtk4::Label, + reload_button: gtk4::Button, + restart_button: gtk4::Button, + stop_button: gtk4::Button, +} + +impl PreviewSessionWidgets { + fn refresh(&self, ctx: &PageBuildContext) { + refresh_preview_widgets( + ctx, + &self.start_button, + &self.status_label, + &self.reload_button, + &self.restart_button, + &self.stop_button, + ); + } +} + +pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { + let panel = gtk4::Box::new(gtk4::Orientation::Vertical, 10); + panel.add_css_class("dashboard-stack"); + + let detected_vram_total_mb = preview_vram_total_mb(ctx); + let detected_vram_safe_mb = preview_vram_safe_max_mb(ctx); + let mut initial_studio = preferred_studio_options(); + initial_studio.vram_pressure_mb = initial_studio.vram_pressure_mb.min(detected_vram_total_mb); + let studio_defaults = Rc::new(RefCell::new(initial_studio)); + let control_sync = Rc::new(Cell::new(false)); + + let (session_group, session_body) = preview_group( + "Preview session", + "Start, update, restart, or stop the built-in Studio preview. These changes stay isolated from your saved MangoHud config.", + ); + let status_label = gtk4::Label::new(None); + status_label.set_xalign(0.0); + status_label.set_wrap(true); + status_label.add_css_class("preview-status"); + let status_hint = gtk4::Label::new(Some( + "The preview uses your current unsaved workspace state. Save only when you want to update the real config.", + )); + status_hint.add_css_class("dim-label"); + status_hint.set_wrap(true); + status_hint.set_xalign(0.0); + session_body.append(&status_label); + session_body.append(&status_hint); + + let buttons = gtk4::Grid::new(); + buttons.add_css_class("dashboard-preview-actions"); + buttons.set_column_spacing(8); + buttons.set_column_homogeneous(true); + buttons.set_hexpand(true); + let start_button = gtk4::Button::with_label("Start"); + start_button.add_css_class("suggested-action"); + let reload_button = gtk4::Button::with_label("Apply"); + let restart_button = gtk4::Button::with_label("Restart"); + let stop_button = gtk4::Button::with_label("Stop"); + start_button.set_tooltip_text(Some( + "Launch the Studio preview window with your current unsaved MangoHud workspace and the current Studio runtime controls.", + )); + reload_button.set_tooltip_text(Some( + "Push your current unsaved workspace and current Studio runtime controls into the already running preview without restarting it.", + )); + restart_button.set_tooltip_text(Some( + "Restart the Studio preview window so settings that need a fresh launch, like window size, take effect.", + )); + stop_button.set_tooltip_text(Some("Stop the Studio preview window.")); + start_button.set_hexpand(true); + reload_button.set_hexpand(true); + restart_button.set_hexpand(true); + stop_button.set_hexpand(true); + buttons.attach(&start_button, 0, 0, 1, 1); + buttons.attach(&reload_button, 1, 0, 1, 1); + buttons.attach(&restart_button, 2, 0, 1, 1); + buttons.attach(&stop_button, 3, 0, 1, 1); + session_body.append(&buttons); + let preview_controls = PreviewSessionWidgets { + start_button: start_button.clone(), + status_label: status_label.clone(), + reload_button: reload_button.clone(), + restart_button: restart_button.clone(), + stop_button: stop_button.clone(), + }; + + let (window_group, window_body) = preview_group( + "Window", + "Used for the built-in Studio preview window. Size changes still require a restart.", + ); + let (preferred_width, preferred_height) = preferred_preview_window_size(); + let size_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + size_controls.add_css_class("dashboard-inline-row"); + size_controls.add_css_class("preview-size-row"); + size_controls.set_valign(gtk4::Align::Center); + let size_label = gtk4::Label::new(Some("Size")); + size_label.add_css_class("dashboard-field-label"); + size_label.set_xalign(0.0); + size_label.set_hexpand(true); + let width_adjustment = + gtk4::Adjustment::new(preferred_width as f64, 640.0, 3840.0, 20.0, 100.0, 0.0); + let width_spin = gtk4::SpinButton::new(Some(&width_adjustment), 1.0, 0); + configure_preview_spin( + &width_spin, + &OptionType::Int { + min: 640, + max: 3840, + }, + "Preview window width in pixels.", + 0, + ); + let size_separator = gtk4::Label::new(Some("×")); + size_separator.add_css_class("dashboard-value-label"); + let height_adjustment = + gtk4::Adjustment::new(preferred_height as f64, 360.0, 2160.0, 20.0, 100.0, 0.0); + let height_spin = gtk4::SpinButton::new(Some(&height_adjustment), 1.0, 0); + configure_preview_spin( + &height_spin, + &OptionType::Int { + min: 360, + max: 2160, + }, + "Preview window height in pixels.", + 0, + ); + size_controls.append(&size_label); + size_controls.append(&width_spin); + size_controls.append(&size_separator); + size_controls.append(&height_spin); + window_body.append(&size_controls); + + let (backdrop_group, backdrop_body) = preview_group( + "Backdrop and flow", + "Choose the scene feel and whether the preview should stay animated or sync to your display.", + ); + let scene_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + scene_row.add_css_class("dashboard-preview-subsection"); + let scene_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + scene_controls.add_css_class("dashboard-inline-row"); + scene_controls.set_valign(gtk4::Align::Center); + let scene_label = gtk4::Label::new(Some("Scene")); + scene_label.add_css_class("dashboard-field-label"); + scene_label.set_xalign(0.0); + scene_label.set_hexpand(true); + let scene_labels = StudioScene::all() + .iter() + .map(|scene| studio_scene_title(*scene)) + .collect::>(); + let scene_dropdown = gtk4::DropDown::from_strings(&scene_labels); + scene_dropdown.add_css_class("control-field"); + scene_dropdown.add_css_class("preview-scene-control"); + scene_dropdown.set_hexpand(false); + scene_dropdown.set_halign(gtk4::Align::End); + scene_dropdown.set_size_request(180, -1); + let initial_scene_index = StudioScene::all() + .iter() + .position(|scene| *scene == studio_defaults.borrow().scene) + .unwrap_or(0) as u32; + scene_dropdown.set_selected(initial_scene_index); + scene_dropdown.set_tooltip_text(Some( + "Pick the kind of test backdrop you want. Dark Arena is the general default, Bright Wash is for contrast checks, Motion Stress is the busiest, Static Inspection stays calmer, and Noise Field spreads more motion across the screen.", + )); + let scene_hint = gtk4::Label::new(Some( + "Changes the preview backdrop and camera feel without touching your MangoHud config.", + )); + scene_hint.add_css_class("dim-label"); + scene_hint.set_xalign(0.0); + scene_controls.append(&scene_label); + scene_controls.append(&scene_dropdown); + scene_row.append(&scene_controls); + scene_row.append(&scene_hint); + backdrop_body.append(&scene_row); + + let vsync_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + vsync_row.add_css_class("dashboard-inline-row"); + let vsync_label = gtk4::Label::new(Some("VSync")); + vsync_label.add_css_class("dashboard-field-label"); + vsync_label.set_xalign(0.0); + vsync_label.set_hexpand(true); + let vsync_switch = gtk4::Switch::new(); + vsync_switch.set_active(studio_defaults.borrow().vsync); + vsync_switch.set_tooltip_text(Some( + "Match the preview to your display refresh. Turning this on can lower FPS and GPU usage, which is useful when you want steadier readouts.", + )); + vsync_row.append(&vsync_label); + vsync_row.append(&vsync_switch); + backdrop_body.append(&vsync_row); + + let pause_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + pause_row.add_css_class("dashboard-inline-row"); + let pause_label = gtk4::Label::new(Some("Pause motion")); + pause_label.add_css_class("dashboard-field-label"); + pause_label.set_xalign(0.0); + pause_label.set_hexpand(true); + let pause_switch = gtk4::Switch::new(); + pause_switch.set_active(studio_defaults.borrow().paused); + pause_switch.set_tooltip_text(Some( + "Freeze the motion so you can inspect the HUD without the scene moving underneath it.", + )); + pause_row.append(&pause_label); + pause_row.append(&pause_switch); + backdrop_body.append(&pause_row); + + let (gpu_group, gpu_body) = preview_group( + "GPU and frame load", + "These controls mostly affect GPU usage, redraw cost, FPS, and overall screen coverage.", + ); + let fps_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + fps_row.add_css_class("dashboard-preview-subsection"); + let fps_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + fps_controls.add_css_class("dashboard-inline-row"); + fps_controls.set_valign(gtk4::Align::Center); + let fps_label = gtk4::Label::new(Some("FPS cap")); + fps_label.add_css_class("dashboard-field-label"); + fps_label.set_xalign(0.0); + fps_label.set_hexpand(true); + let fps_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().fps_cap.unwrap_or(0) as f64, + 0.0, + 1000.0, + 25.0, + 50.0, + 0.0, + ); + let fps_spin = gtk4::SpinButton::new(Some(&fps_adjustment), 1.0, 0); + configure_preview_spin( + &fps_spin, + &OptionType::Int { min: 0, max: 1000 }, + "Caps how fast the preview runs. Lower values make threshold testing easier. Set 0 only if you want the preview to run flat-out.", + 0, + ); + let fps_hint = gtk4::Label::new(Some("0 = uncapped and will usually max the GPU")); + fps_hint.add_css_class("dim-label"); + fps_hint.set_xalign(0.0); + fps_controls.append(&fps_label); + fps_controls.append(&fps_spin); + fps_row.append(&fps_controls); + fps_row.append(&fps_hint); + gpu_body.append(&fps_row); + + let particle_size_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + particle_size_row.add_css_class("dashboard-preview-subsection"); + let particle_size_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + particle_size_controls.add_css_class("dashboard-inline-row"); + particle_size_controls.set_valign(gtk4::Align::Center); + let particle_size_label = gtk4::Label::new(Some("Particle size")); + particle_size_label.add_css_class("dashboard-field-label"); + particle_size_label.set_xalign(0.0); + particle_size_label.set_hexpand(true); + let particle_size_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().particle_size as f64, + 0.01, + 5.0, + 0.01, + 0.10, + 0.0, + ); + let particle_size_spin = gtk4::SpinButton::new(Some(&particle_size_adjustment), 0.01, 2); + configure_preview_spin( + &particle_size_spin, + &OptionType::Float { min: 0.01, max: 5.0 }, + "How large each particle appears on screen. Bigger particles cover more of the window and usually raise GPU cost the fastest.", + 2, + ); + particle_size_controls.append(&particle_size_label); + particle_size_controls.append(&particle_size_spin); + particle_size_row.append(&particle_size_controls); + gpu_body.append(&particle_size_row); + + let gpu_passes_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + gpu_passes_row.add_css_class("dashboard-preview-subsection"); + let gpu_passes_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + gpu_passes_controls.add_css_class("dashboard-inline-row"); + gpu_passes_controls.set_valign(gtk4::Align::Center); + let gpu_passes_label = gtk4::Label::new(Some("GPU passes")); + gpu_passes_label.add_css_class("dashboard-field-label"); + gpu_passes_label.set_xalign(0.0); + gpu_passes_label.set_hexpand(true); + let gpu_passes_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().gpu_passes as f64, + 1.0, + 64.0, + 1.0, + 4.0, + 0.0, + ); + let gpu_passes_spin = gtk4::SpinButton::new(Some(&gpu_passes_adjustment), 1.0, 0); + configure_preview_spin( + &gpu_passes_spin, + &OptionType::Int { min: 1, max: 64 }, + "How many extra times the preview redraws the scene each frame. This is the most direct way to push GPU load higher.", + 0, + ); + gpu_passes_controls.append(&gpu_passes_label); + gpu_passes_controls.append(&gpu_passes_spin); + gpu_passes_row.append(&gpu_passes_controls); + gpu_body.append(&gpu_passes_row); + + let vram_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + vram_row.add_css_class("dashboard-preview-subsection"); + let vram_controls = gtk4::Grid::new(); + vram_controls.add_css_class("dashboard-inline-grid"); + vram_controls.set_column_spacing(12); + vram_controls.set_row_spacing(6); + let vram_label = gtk4::Label::new(Some("VRAM pressure")); + vram_label.add_css_class("dashboard-field-label"); + vram_label.set_xalign(0.0); + let vram_hint_text = + match (ctx.system_info.gpu.total_vram_mb, ctx.system_info.gpu.used_vram_mb) { + (Some(total), Some(used)) => format!( + "Detected VRAM: {} total, {} already in use. Safe max: {}.", + format_mib_as_gib_text(total), + format_mib_as_gib_text(used.min(total)), + format_mib_as_gib_text(detected_vram_safe_mb) + ), + (Some(total), None) => format!( + "Detected VRAM: {} total. Safe max: {}. Current VRAM use could not be read, so this uses a conservative fallback.", + format_mib_as_gib_text(total), + format_mib_as_gib_text(detected_vram_safe_mb) + ), + (None, _) => format!( + "GPU memory could not be detected. Safe max: {} using a conservative fallback.", + format_mib_as_gib_text(detected_vram_safe_mb) + ), + }; + let vram_hint = gtk4::Label::new(Some(&vram_hint_text)); + vram_hint.add_css_class("dim-label"); + vram_hint.set_xalign(0.0); + let vram_label_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + vram_label_box.set_hexpand(true); + vram_label_box.append(&vram_label); + vram_label_box.append(&vram_hint); + let vram_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().vram_pressure_mb as f64, + 0.0, + detected_vram_total_mb as f64, + 16.0, + 64.0, + 0.0, + ); + let vram_spin = gtk4::SpinButton::new(Some(&vram_adjustment), 1.0, 0); + configure_preview_spin( + &vram_spin, + &OptionType::Int { + min: 0, + max: detected_vram_total_mb as i64, + }, + "Reserves GPU memory so you can see VRAM readouts change. Use Safe max if you want a high value without risking the preview.", + 0, + ); + let safe_vram_button = gtk4::Button::with_label("Safe max"); + safe_vram_button.add_css_class("shell-strip-button"); + safe_vram_button.set_tooltip_text(Some( + "Set VRAM pressure to a conservative high value based on detected total VRAM, current VRAM use, and reserved safety headroom.", + )); + { + let vram_spin = vram_spin.clone(); + safe_vram_button.connect_clicked(move |_| { + vram_spin.set_value(detected_vram_safe_mb as f64); + }); + } + vram_controls.attach(&vram_label_box, 0, 0, 1, 2); + vram_controls.attach(&vram_spin, 1, 0, 1, 1); + vram_controls.attach(&safe_vram_button, 1, 1, 1, 1); + vram_row.append(&vram_controls); + gpu_body.append(&vram_row); + + let (cpu_group, cpu_body) = preview_group( + "CPU and density", + "These controls mostly affect particle count, simulation work, and how busy the preview feels over time.", + ); + let particle_count_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + particle_count_row.add_css_class("dashboard-preview-subsection"); + let particle_count_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + particle_count_controls.add_css_class("dashboard-inline-row"); + particle_count_controls.set_valign(gtk4::Align::Center); + let particle_count_label = gtk4::Label::new(Some("Particle count")); + particle_count_label.add_css_class("dashboard-field-label"); + particle_count_label.set_xalign(0.0); + particle_count_label.set_hexpand(true); + let particle_count_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().particle_count as f64, + 100.0, + 500_000.0, + 100.0, + 5_000.0, + 0.0, + ); + let particle_count_spin = gtk4::SpinButton::new(Some(&particle_count_adjustment), 1.0, 0); + configure_preview_spin( + &particle_count_spin, + &OptionType::Int { + min: 100, + max: 500_000, + }, + "How many particles the preview simulates. More particles usually raise CPU work first, then GPU cost as the scene gets denser.", + 0, + ); + let particle_count_hint = + gtk4::Label::new(Some("Lower this first if the preview is too heavy.")); + particle_count_hint.add_css_class("dim-label"); + particle_count_hint.set_xalign(0.0); + particle_count_controls.append(&particle_count_label); + particle_count_controls.append(&particle_count_spin); + particle_count_row.append(&particle_count_controls); + particle_count_row.append(&particle_count_hint); + cpu_body.append(&particle_count_row); + + let interaction_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + interaction_row.add_css_class("dashboard-preview-subsection"); + let interaction_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + interaction_controls.add_css_class("dashboard-inline-row"); + interaction_controls.set_valign(gtk4::Align::Center); + let interaction_label = gtk4::Label::new(Some("CPU interaction")); + interaction_label.add_css_class("dashboard-field-label"); + interaction_label.set_xalign(0.0); + interaction_label.set_hexpand(true); + let interaction_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().interaction_steps as f64, + 0.0, + 256.0, + 1.0, + 8.0, + 0.0, + ); + let interaction_spin = gtk4::SpinButton::new(Some(&interaction_adjustment), 1.0, 0); + configure_preview_spin( + &interaction_spin, + &OptionType::Int { min: 0, max: 256 }, + "Adds extra simulation work on the CPU. Raise this when you want CPU-related MangoHud colors and graphs to move more.", + 0, + ); + interaction_controls.append(&interaction_label); + interaction_controls.append(&interaction_spin); + interaction_row.append(&interaction_controls); + cpu_body.append(&interaction_row); + + let (setup_group, setup_body) = preview_group( + "Preview setups", + "Apply a built-in workload shape quickly, or reset everything back to the Studio defaults.", + ); + let profile_rows = gtk4::Grid::new(); + profile_rows.add_css_class("preview-profile-grid"); + profile_rows.set_column_spacing(6); + profile_rows.set_row_spacing(6); + for (index, profile) in PreviewProfile::all().iter().enumerate() { + let button = gtk4::Button::with_label(profile.title()); + button.add_css_class("dashboard-preset-button"); + button.add_css_class("preview-profile-button"); + button.set_hexpand(true); + button.set_tooltip_text(Some(profile.tooltip())); + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let scene_dropdown = scene_dropdown.clone(); + let fps_spin = fps_spin.clone(); + let particle_count_spin = particle_count_spin.clone(); + let particle_size_spin = particle_size_spin.clone(); + let gpu_passes_spin = gpu_passes_spin.clone(); + let interaction_spin = interaction_spin.clone(); + let vram_spin = vram_spin.clone(); + let vsync_switch = vsync_switch.clone(); + let pause_switch = pause_switch.clone(); + button.connect_clicked(move |_| { + let studio = profile.studio(); + studio_defaults.replace(studio.clone()); + persist_studio_options(&studio); + control_sync.set(true); + if let Some(index) = StudioScene::all() + .iter() + .position(|scene| *scene == studio.scene) + { + scene_dropdown.set_selected(index as u32); + } + fps_spin.set_value(studio.fps_cap.unwrap_or(0) as f64); + particle_count_spin.set_value(studio.particle_count as f64); + particle_size_spin.set_value(studio.particle_size as f64); + gpu_passes_spin.set_value(studio.gpu_passes as f64); + interaction_spin.set_value(studio.interaction_steps as f64); + vram_spin.set_value(studio.vram_pressure_mb as f64); + vsync_switch.set_active(studio.vsync); + pause_switch.set_active(studio.paused); + control_sync.set(false); + maybe_apply_studio_preview_runtime(&ctx, &preview_controls, studio); + }); + profile_rows.attach(&button, (index % 2) as i32, (index / 2) as i32, 1, 1); + } + let reset_preview_button = gtk4::Button::with_label("Reset Preview Defaults"); + reset_preview_button.add_css_class("dashboard-preset-button"); + let ctx_reset = ctx.clone(); + let preview_controls_reset = preview_controls.clone(); + let studio_defaults_reset = studio_defaults.clone(); + let control_sync_reset = control_sync.clone(); + let scene_dropdown_reset = scene_dropdown.clone(); + let fps_spin_reset = fps_spin.clone(); + let particle_count_spin_reset = particle_count_spin.clone(); + let particle_size_spin_reset = particle_size_spin.clone(); + let gpu_passes_spin_reset = gpu_passes_spin.clone(); + let interaction_spin_reset = interaction_spin.clone(); + let vram_spin_reset = vram_spin.clone(); + let vsync_switch_reset = vsync_switch.clone(); + let pause_switch_reset = pause_switch.clone(); + let width_spin_reset = width_spin.clone(); + let height_spin_reset = height_spin.clone(); + reset_preview_button.connect_clicked(move |_| { + let studio = PreviewStudioOptions::default(); + let (default_width, default_height) = default_preview_window_size(); + studio_defaults_reset.replace(studio.clone()); + persist_studio_options(&studio); + persist_preview_window_width(default_width); + persist_preview_window_height(default_height); + control_sync_reset.set(true); + if let Some(index) = StudioScene::all() + .iter() + .position(|scene| *scene == studio.scene) + { + scene_dropdown_reset.set_selected(index as u32); + } + fps_spin_reset.set_value(studio.fps_cap.unwrap_or(0) as f64); + particle_count_spin_reset.set_value(studio.particle_count as f64); + particle_size_spin_reset.set_value(studio.particle_size as f64); + gpu_passes_spin_reset.set_value(studio.gpu_passes as f64); + interaction_spin_reset.set_value(studio.interaction_steps as f64); + vram_spin_reset.set_value(studio.vram_pressure_mb as f64); + vsync_switch_reset.set_active(studio.vsync); + pause_switch_reset.set_active(studio.paused); + width_spin_reset.set_value(default_width as f64); + height_spin_reset.set_value(default_height as f64); + control_sync_reset.set(false); + maybe_restart_active_preview(&ctx_reset, &preview_controls_reset, studio); + }); + setup_body.append(&profile_rows); + setup_body.append(&reset_preview_button); + + panel.append(&session_group); + panel.append(&window_group); + panel.append(&setup_group); + panel.append(&backdrop_group); + panel.append(&gpu_group); + panel.append(&cpu_group); + + preview_controls.refresh(ctx); + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + scene_dropdown.connect_selected_notify(move |dropdown| { + if control_sync.get() { + return; + } + let Some(scene) = StudioScene::all() + .get(dropdown.selected() as usize) + .copied() + else { + return; + }; + let mut studio = studio_defaults.borrow_mut(); + studio.scene = scene; + persist_preview_studio_scene(scene); + maybe_apply_studio_preview_runtime(&ctx, &preview_controls, studio.clone()); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + fps_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let value = spin.value().round().clamp(0.0, 1000.0) as i32; + let mut studio = studio_defaults.borrow_mut(); + studio.fps_cap = if value <= 0 { None } else { Some(value as u32) }; + persist_studio_fps_cap(value); + maybe_apply_studio_preview_runtime(&ctx, &preview_controls, studio.clone()); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + particle_count_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let count = spin.value().round().clamp(100.0, 500_000.0) as i32; + let mut studio = studio_defaults.borrow_mut(); + studio.particle_count = count as u32; + persist_studio_particle_count(count); + maybe_apply_studio_preview_runtime(&ctx, &preview_controls, studio.clone()); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + particle_size_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let size = spin.value().clamp(0.01, 5.0); + let mut studio = studio_defaults.borrow_mut(); + studio.particle_size = size as f32; + persist_studio_particle_size(size); + maybe_apply_studio_preview_runtime(&ctx, &preview_controls, studio.clone()); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + gpu_passes_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let passes = spin.value().round().clamp(1.0, 64.0) as i32; + let mut studio = studio_defaults.borrow_mut(); + studio.gpu_passes = passes as u32; + persist_studio_gpu_passes(passes); + maybe_apply_studio_preview_runtime(&ctx, &preview_controls, studio.clone()); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + interaction_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let steps = spin.value().round().clamp(0.0, 256.0) as i32; + let mut studio = studio_defaults.borrow_mut(); + studio.interaction_steps = steps as u32; + persist_studio_interaction_steps(steps); + maybe_apply_studio_preview_runtime(&ctx, &preview_controls, studio.clone()); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + vram_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let mb = spin.value().round().clamp(0.0, 4096.0) as i32; + let mut studio = studio_defaults.borrow_mut(); + studio.vram_pressure_mb = mb as u32; + persist_studio_vram_pressure(mb); + maybe_apply_studio_preview_runtime(&ctx, &preview_controls, studio.clone()); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + vsync_switch.connect_active_notify(move |switch| { + if control_sync.get() { + return; + } + let mut studio = studio_defaults.borrow_mut(); + studio.vsync = switch.is_active(); + persist_preview_vsync(switch.is_active()); + maybe_apply_studio_preview_runtime(&ctx, &preview_controls, studio.clone()); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + pause_switch.connect_active_notify(move |switch| { + if control_sync.get() { + return; + } + let mut studio = studio_defaults.borrow_mut(); + studio.paused = switch.is_active(); + maybe_apply_studio_preview_runtime(&ctx, &preview_controls, studio.clone()); + }); + } + + { + let ctx = ctx.clone(); + let control_sync = control_sync.clone(); + let preview_controls = preview_controls.clone(); + let studio_defaults = studio_defaults.clone(); + width_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let width = spin.value().round().clamp(640.0, 3840.0) as i32; + persist_preview_window_width(width); + maybe_restart_active_preview(&ctx, &preview_controls, studio_defaults.borrow().clone()); + }); + } + + { + let ctx = ctx.clone(); + let control_sync = control_sync.clone(); + let preview_controls = preview_controls.clone(); + let studio_defaults = studio_defaults.clone(); + height_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let height = spin.value().round().clamp(360.0, 2160.0) as i32; + persist_preview_window_height(height); + maybe_restart_active_preview(&ctx, &preview_controls, studio_defaults.borrow().clone()); + }); + } + + { + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + let studio_defaults = studio_defaults.clone(); + start_button.clone().connect_clicked(move |_| { + let config = current_config_snapshot(&ctx); + let (width, height) = preview_window_settings(PreviewScene::Studio, &config); + let studio = studio_defaults.borrow().clone(); + + finish_preview_pid_action( + &ctx, + &preview_controls, + "Started live preview", + "Preview failed", + ctx.preview + .start(PreviewScene::Studio, &config, width, height, false, studio), + ); + }); + } + + { + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + reload_button.clone().connect_clicked(move |_| { + let config = current_config_snapshot(&ctx); + finish_preview_pid_action( + &ctx, + &preview_controls, + "Applied current config to live preview", + "Apply failed", + ctx.preview.apply_live_config(&config), + ); + }); + } + + { + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + restart_button.clone().connect_clicked(move |_| { + let config = current_config_snapshot(&ctx); + finish_preview_pid_action( + &ctx, + &preview_controls, + "Restarted live preview", + "Restart failed", + ctx.preview.restart(&config), + ); + }); + } + + { + let ctx = ctx.clone(); + let preview_controls = preview_controls.clone(); + stop_button.clone().connect_clicked(move |_| { + finish_preview_stop_action(&ctx, &preview_controls, ctx.preview.stop()); + }); + } + + { + let ctx = ctx.clone(); + let start_button = start_button.downgrade(); + let status_label = status_label.downgrade(); + let reload_button = reload_button.downgrade(); + let restart_button = restart_button.downgrade(); + let stop_button = stop_button.downgrade(); + glib::timeout_add_seconds_local(1, move || { + let ( + Some(start_button), + Some(status_label), + Some(reload_button), + Some(restart_button), + Some(stop_button), + ) = ( + start_button.upgrade(), + status_label.upgrade(), + reload_button.upgrade(), + restart_button.upgrade(), + stop_button.upgrade(), + ) + else { + return glib::ControlFlow::Break; + }; + refresh_preview_widgets( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + ); + glib::ControlFlow::Continue + }); + } + + panel +} + +fn preferred_studio_options() -> PreviewStudioOptions { + let settings = app_settings(); + let scene = settings + .as_ref() + .and_then(|settings| { + mangotune::preview::StudioScene::from_label(&settings.string("preview-studio-scene")) + }) + .unwrap_or(mangotune::preview::StudioScene::DarkArena); + let fps_cap = settings + .as_ref() + .map(|settings| settings.int("preview-fps-cap")) + .unwrap_or(120) + .clamp(0, 1000); + let vsync = settings + .as_ref() + .map(|settings| settings.boolean("preview-vsync")) + .unwrap_or(false); + let vram_pressure_mb = settings + .as_ref() + .map(|settings| settings.int("preview-vram-pressure")) + .unwrap_or(0) + .clamp(0, 65_536); + let particle_count = settings + .as_ref() + .map(|settings| settings.int("preview-particle-count")) + .unwrap_or(1_000) + .clamp(100, 500_000); + let particle_size = settings + .as_ref() + .map(|settings| settings.double("preview-particle-size")) + .unwrap_or(0.03) + .clamp(0.01, 5.0); + let gpu_passes = settings + .as_ref() + .map(|settings| settings.int("preview-gpu-passes")) + .unwrap_or(1) + .clamp(1, 64); + let interaction_steps = settings + .as_ref() + .map(|settings| settings.int("preview-interaction-steps")) + .unwrap_or(0) + .clamp(0, 256); + PreviewStudioOptions { + scene, + fps_cap: if fps_cap <= 0 { + None + } else { + Some(fps_cap as u32) + }, + vsync, + vram_pressure_mb: vram_pressure_mb as u32, + particle_count: particle_count as u32, + particle_size: particle_size as f32, + gpu_passes: gpu_passes as u32, + interaction_steps: interaction_steps as u32, + paused: false, + } +} + +fn preferred_preview_window_size() -> (i32, i32) { + let settings = app_settings(); + let width = settings + .as_ref() + .map(|settings| settings.int("test-window-width")) + .unwrap_or(1280) + .clamp(640, 3840); + let height = settings + .as_ref() + .map(|settings| settings.int("test-window-height")) + .unwrap_or(720) + .clamp(360, 2160); + (width, height) +} + +fn persist_preview_window_width(width: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("test-window-width", width.clamp(640, 3840)); + } +} + +fn persist_preview_window_height(height: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("test-window-height", height.clamp(360, 2160)); + } +} + +fn persist_preview_studio_scene(scene: StudioScene) { + if let Some(settings) = app_settings() { + let _ = settings.set_string("preview-studio-scene", scene.label()); + } +} + +fn persist_studio_fps_cap(fps_cap: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("preview-fps-cap", fps_cap.clamp(0, 1000)); + } +} + +fn persist_studio_vram_pressure(mb: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("preview-vram-pressure", mb.clamp(0, 65_536)); + } +} + +fn persist_studio_particle_count(count: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("preview-particle-count", count.clamp(100, 500_000)); + } +} + +fn persist_studio_particle_size(size: f64) { + if let Some(settings) = app_settings() { + let _ = settings.set_double("preview-particle-size", size.clamp(0.01, 5.0)); + } +} + +fn persist_studio_gpu_passes(passes: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("preview-gpu-passes", passes.clamp(1, 64)); + } +} + +fn persist_studio_interaction_steps(steps: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("preview-interaction-steps", steps.clamp(0, 256)); + } +} + +fn persist_preview_vsync(enabled: bool) { + if let Some(settings) = app_settings() { + let _ = settings.set_boolean("preview-vsync", enabled); + } +} + +pub(super) fn preview_window_settings( + _scene: PreviewScene, + config: &AnnotatedConfig, +) -> (i32, i32) { + let (mut width, height) = preferred_preview_window_size(); + + if let Some(hud_width) = effective_preview_hud_width(config, width) { + width = width + .max(hud_width + extra_preview_margin(config)) + .clamp(720, 3840); + } + + (width, height) +} + +fn extra_preview_margin(config: &AnnotatedConfig) -> i32 { + if is_right_aligned_position(config) { + if is_horizontal_layout(config) { + 980 + } else { + 480 + } + } else { + 220 + } +} + +fn is_horizontal_layout(config: &AnnotatedConfig) -> bool { + match config.options.get("horizontal").map(|(_, value)| value) { + Some(ConfigValue::Flag) => true, + Some(ConfigValue::Value(value)) => { + let normalized = value.trim().to_ascii_lowercase(); + !matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off") + } + _ => false, + } +} + +fn is_right_aligned_position(config: &AnnotatedConfig) -> bool { + matches!( + current_config_position(config).as_deref(), + Some("top-right" | "middle-right" | "bottom-right") + ) +} + +fn maybe_apply_studio_preview_runtime( + ctx: &PageBuildContext, + preview_controls: &PreviewSessionWidgets, + studio: PreviewStudioOptions, +) { + if ctx.preview.running_scene() != Some(PreviewScene::Studio) { + return; + } + + match ctx.preview.update_studio_runtime(studio) { + Ok(()) => { + preview_controls.refresh(ctx); + } + Err(err) => { + show_toast(&ctx.toast_overlay, &format!("Studio update failed: {err}")); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn maybe_restart_active_preview( + ctx: &PageBuildContext, + preview_controls: &PreviewSessionWidgets, + studio: PreviewStudioOptions, +) { + let Some(scene) = ctx.preview.running_scene() else { + return; + }; + + let config = current_config_snapshot(ctx); + let (width, height) = preview_window_settings(scene, &config); + if let Ok(pid) = ctx + .preview + .start(scene, &config, width, height, false, studio) + { + show_toast( + &ctx.toast_overlay, + &format!("Updated {} preview (pid {pid})", scene.label()), + ); + preview_controls.refresh(ctx); + } +} + +fn finish_preview_pid_action( + ctx: &PageBuildContext, + preview_controls: &PreviewSessionWidgets, + success_prefix: &str, + error_prefix: &str, + result: anyhow::Result, +) { + match result { + Ok(pid) => { + show_toast(&ctx.toast_overlay, &format!("{success_prefix} (pid {pid})")); + preview_controls.refresh(ctx); + } + Err(err) => show_toast(&ctx.toast_overlay, &format!("{error_prefix}: {err}")), + } +} + +fn finish_preview_stop_action( + ctx: &PageBuildContext, + preview_controls: &PreviewSessionWidgets, + result: anyhow::Result, +) { + match result { + Ok(true) => show_toast(&ctx.toast_overlay, "Stopped live preview"), + Ok(false) => show_toast(&ctx.toast_overlay, "Preview was not running"), + Err(err) => show_toast(&ctx.toast_overlay, &format!("Stop failed: {err}")), + } + preview_controls.refresh(ctx); +} diff --git a/src/ui/pages/overview/profiles.rs b/src/ui/pages/overview/profiles.rs new file mode 100644 index 0000000..dfda98e --- /dev/null +++ b/src/ui/pages/overview/profiles.rs @@ -0,0 +1,474 @@ +use super::cards::{card_header, dashboard_card, install_scroll_passthrough}; +use super::*; + +pub(super) fn build_profiles_panel(ctx: &PageBuildContext) -> gtk4::Box { + let card = dashboard_card(); + card.append(&card_header( + "Profiles", + "Save the active config target as a real MangoHud profile, apply one back into the current target, or open the profile folder when you need it.", + )); + + let controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + controls.add_css_class("dashboard-profiles-strip"); + let name_entry = gtk4::Entry::new(); + name_entry.set_placeholder_text(Some("competitive, quality, streaming")); + name_entry.set_hexpand(true); + name_entry.set_width_chars(20); + name_entry.add_css_class("control-field"); + + let (names, paths) = profile_choices(); + let name_refs = names.iter().map(String::as_str).collect::>(); + let restore_dropdown = gtk4::DropDown::from_strings(&name_refs); + restore_dropdown.set_hexpand(true); + restore_dropdown.set_halign(gtk4::Align::Fill); + restore_dropdown.set_size_request(230, -1); + install_profile_dropdown_factory(&restore_dropdown); + install_scroll_passthrough(restore_dropdown.upcast_ref()); + restore_dropdown.add_css_class("control-field"); + + let save_button = icon_action_button( + "document-save-symbolic", + "Save the active config target as a profile", + ); + let restore_button = icon_action_button( + "document-revert-symbolic", + "Apply the selected profile into the active config target", + ); + let delete_button = icon_action_button("user-trash-symbolic", "Delete the selected profile"); + let open_button = icon_action_button("folder-open-symbolic", "Open the profile storage folder"); + + restore_button.set_sensitive(!paths.is_empty()); + delete_button.set_sensitive(!paths.is_empty()); + controls.append(&name_entry); + controls.append(&save_button); + controls.append(&restore_dropdown); + controls.append(&restore_button); + controls.append(&delete_button); + controls.append(&open_button); + + let profile_paths = Rc::new(RefCell::new(paths)); + let initial_selected = refresh_profile_picker( + &restore_dropdown, + &restore_button, + &delete_button, + &profile_paths, + last_selected_profile_name().as_deref(), + ); + if let Some(name) = initial_selected { + name_entry.set_text(&name); + } + + { + let name_entry = name_entry.clone(); + let profile_paths = profile_paths.clone(); + restore_dropdown.connect_selected_notify(move |dropdown| { + if profile_paths.borrow().is_empty() { + name_entry.set_text(""); + persist_last_selected_profile_name(None); + return; + } + let selected = dropdown_selected_profile_name(dropdown); + if let Some(name) = selected.as_deref() { + name_entry.set_text(name); + } + persist_last_selected_profile_name(selected.as_deref()); + }); + } + + { + let ctx = ctx.clone(); + let name_entry = name_entry.clone(); + let restore_dropdown = restore_dropdown.clone(); + let restore_button = restore_button.clone(); + let delete_button = delete_button.clone(); + let profile_paths = profile_paths.clone(); + save_button.connect_clicked(move |_| { + let profile_name = name_entry.text().to_string(); + if profile_name.trim().is_empty() { + show_toast(&ctx.toast_overlay, "Enter a profile name first"); + return; + } + let exists = match stored_profiles::profile_exists(&profile_name) { + Ok(exists) => exists, + Err(err) => { + show_toast( + &ctx.toast_overlay, + &format!("Profile save failed: {err}"), + ); + return; + } + }; + + if exists { + let ctx_clone = ctx.clone(); + let name_entry = name_entry.clone(); + let restore_dropdown = restore_dropdown.clone(); + let restore_button = restore_button.clone(); + let delete_button = delete_button.clone(); + let profile_paths = profile_paths.clone(); + let profile_name = profile_name.clone(); + glib::MainContext::default().spawn_local(async move { + let confirmed = confirm_profile_action( + &ctx_clone.parent_window, + "Overwrite profile?", + &format!( + "A profile named \"{profile_name}\" already exists. Replace it with the current active config target?" + ), + "Overwrite", + true, + ) + .await; + if confirmed { + save_profile_from_dashboard( + &ctx_clone, + &name_entry, + &restore_dropdown, + &restore_button, + &delete_button, + &profile_paths, + &profile_name, + ); + } + }); + } else { + save_profile_from_dashboard( + &ctx, + &name_entry, + &restore_dropdown, + &restore_button, + &delete_button, + &profile_paths, + &profile_name, + ); + } + }); + } + + { + let ctx = ctx.clone(); + let profile_paths = profile_paths.clone(); + let restore_dropdown = restore_dropdown.clone(); + let restore_button = restore_button.clone(); + let delete_button = delete_button.clone(); + restore_button.clone().connect_clicked(move |_| { + let idx = restore_dropdown.selected() as usize; + let Some(path) = profile_paths.borrow().get(idx).cloned() else { + show_toast(&ctx.toast_overlay, "No profile selected"); + return; + }; + + let target_path = current_config_snapshot(&ctx).path; + match stored_profiles::load_profile_from_path(&path, target_path) { + Ok(loaded) => { + if let Ok(mut state) = ctx.state.lock() { + state.config = loaded; + state.dirty = true; + } + sync_config_ui(&ctx); + apply_live_preview_now(&ctx); + refresh_current_page_later(&ctx.parent_window); + show_toast( + &ctx.toast_overlay, + "Applied profile into the active config target", + ); + let selected_name = dropdown_selected_profile_name(&restore_dropdown); + let selected = refresh_profile_picker( + &restore_dropdown, + &restore_button, + &delete_button, + &profile_paths, + selected_name.as_deref(), + ); + if let Some(selected) = selected { + persist_last_selected_profile_name(Some(&selected)); + } + } + Err(err) => show_toast(&ctx.toast_overlay, &format!("Profile apply failed: {err}")), + } + }); + } + + { + let ctx = ctx.clone(); + let profile_paths = profile_paths.clone(); + let restore_dropdown = restore_dropdown.clone(); + let restore_button = restore_button.clone(); + let delete_button = delete_button.clone(); + let name_entry = name_entry.clone(); + delete_button.clone().connect_clicked(move |_| { + let idx = restore_dropdown.selected() as usize; + let Some(path) = profile_paths.borrow().get(idx).cloned() else { + show_toast(&ctx.toast_overlay, "No profile selected"); + return; + }; + let profile_name = path + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("profile") + .to_string(); + let ctx_clone = ctx.clone(); + let restore_dropdown = restore_dropdown.clone(); + let restore_button = restore_button.clone(); + let delete_button = delete_button.clone(); + let profile_paths = profile_paths.clone(); + let name_entry = name_entry.clone(); + glib::MainContext::default().spawn_local(async move { + let confirmed = confirm_profile_action( + &ctx_clone.parent_window, + "Delete profile?", + &format!("Delete the profile \"{profile_name}\"? This cannot be undone."), + "Delete", + true, + ) + .await; + if !confirmed { + return; + } + + match stored_profiles::delete_profile_path(&path) { + Ok(()) => { + let selected = refresh_profile_picker( + &restore_dropdown, + &restore_button, + &delete_button, + &profile_paths, + None, + ); + if let Some(selected) = selected { + name_entry.set_text(&selected); + } else { + name_entry.set_text(""); + } + show_toast( + &ctx_clone.toast_overlay, + &format!("Deleted profile {profile_name}"), + ); + } + Err(err) => show_toast( + &ctx_clone.toast_overlay, + &format!("Profile delete failed: {err}"), + ), + } + }); + }); + } + + { + let overlay = ctx.toast_overlay.clone(); + open_button.connect_clicked(move |_| { + let has_graphical_session = std::env::var("DISPLAY") + .ok() + .is_some_and(|value| !value.is_empty()) + || std::env::var("WAYLAND_DISPLAY") + .ok() + .is_some_and(|value| !value.is_empty()); + if !has_graphical_session { + show_toast( + &overlay, + "No graphical session is available to open the profiles folder", + ); + return; + } + + match std::process::Command::new("xdg-open") + .arg(stored_profiles::profiles_dir()) + .spawn() + { + Ok(_) => {} + Err(error) => show_toast( + &overlay, + &format!("Failed to open profiles folder: {error}"), + ), + } + }); + } + + card.append(&controls); + card +} + +fn save_profile_from_dashboard( + ctx: &PageBuildContext, + name_entry: >k4::Entry, + restore_dropdown: >k4::DropDown, + restore_button: >k4::Button, + delete_button: >k4::Button, + profile_paths: &Rc>>, + profile_name: &str, +) { + let config = current_config_snapshot(ctx); + match stored_profiles::save_profile(profile_name, &config) { + Ok(path) => { + let selected = refresh_profile_picker( + restore_dropdown, + restore_button, + delete_button, + profile_paths, + Some(profile_name), + ); + if let Some(selected) = selected { + name_entry.set_text(&selected); + } + show_toast( + &ctx.toast_overlay, + &format!("Saved active config target as profile {}", path.display()), + ); + } + Err(err) => show_toast(&ctx.toast_overlay, &format!("Profile save failed: {err}")), + } +} + +async fn confirm_profile_action( + parent: &libadwaita::ApplicationWindow, + heading: &str, + body: &str, + confirm_label: &str, + destructive: bool, +) -> bool { + let dialog = libadwaita::AlertDialog::builder() + .heading(heading) + .body(body) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("confirm", confirm_label); + dialog.set_default_response(Some("confirm")); + dialog.set_close_response("cancel"); + dialog.set_response_appearance( + "confirm", + if destructive { + libadwaita::ResponseAppearance::Destructive + } else { + libadwaita::ResponseAppearance::Suggested + }, + ); + dialog.choose_future(parent).await.as_str() == "confirm" +} + +fn profile_choices() -> (Vec, Vec) { + let Ok(profiles) = stored_profiles::list_profiles() else { + return (vec!["No profiles found".to_string()], Vec::new()); + }; + + if profiles.is_empty() { + return (vec!["No profiles found".to_string()], Vec::new()); + } + + let names = profiles + .iter() + .map(|profile| profile.name.clone()) + .collect(); + let paths = profiles + .iter() + .map(|profile| profile.path.clone()) + .collect(); + (names, paths) +} + +fn refresh_profile_picker( + dropdown: >k4::DropDown, + restore_button: >k4::Button, + delete_button: >k4::Button, + profile_paths: &Rc>>, + preferred_name: Option<&str>, +) -> Option { + let (names, paths) = profile_choices(); + *profile_paths.borrow_mut() = paths; + let refs = names.iter().map(String::as_str).collect::>(); + let model = gtk4::StringList::new(&refs); + dropdown.set_model(Some(&model)); + let has_profiles = !profile_paths.borrow().is_empty(); + restore_button.set_sensitive(has_profiles); + delete_button.set_sensitive(has_profiles); + if !has_profiles { + dropdown.set_selected(0); + persist_last_selected_profile_name(None); + return None; + } + + let selected_index = preferred_name + .and_then(|preferred| names.iter().position(|name| name == preferred)) + .unwrap_or(0); + dropdown.set_selected(selected_index as u32); + let selected = names.get(selected_index).cloned(); + persist_last_selected_profile_name(selected.as_deref()); + selected +} + +fn last_selected_profile_name() -> Option { + app_settings() + .map(|settings| settings.string("last-profile-name").to_string()) + .filter(|value| !value.trim().is_empty()) +} + +fn persist_last_selected_profile_name(name: Option<&str>) { + if let Some(settings) = app_settings() { + let _ = settings.set_string("last-profile-name", name.unwrap_or("").trim()); + } +} + +fn dropdown_selected_profile_name(dropdown: >k4::DropDown) -> Option { + dropdown + .selected_item() + .and_then(|item| item.downcast::().ok()) + .map(|item| item.string().to_string()) + .filter(|value| value != "No profiles found") +} + +fn install_profile_dropdown_factory(dropdown: >k4::DropDown) { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let Some(list_item) = item.downcast_ref::() else { + return; + }; + let label = gtk4::Label::new(None); + label.set_xalign(0.0); + label.set_hexpand(true); + label.set_ellipsize(EllipsizeMode::End); + label.set_max_width_chars(28); + list_item.set_child(Some(&label)); + }); + factory.connect_bind(|_, item| { + let Some(list_item) = item.downcast_ref::() else { + return; + }; + let Some(label) = list_item + .child() + .and_then(|child| child.downcast::().ok()) + else { + return; + }; + let text = list_item + .item() + .and_then(|obj| obj.downcast::().ok()) + .map(|obj| obj.string().to_string()) + .unwrap_or_default(); + label.set_label(&text); + label.set_tooltip_text(Some(&text)); + }); + + dropdown.set_factory(Some(&factory)); + dropdown.set_list_factory(Some(&factory)); +} + +pub(super) fn active_config_name(ctx: &PageBuildContext) -> String { + let Ok(state) = ctx.state.lock() else { + return "current config".to_string(); + }; + + state + .config + .path + .as_ref() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .map(ToString::to_string) + .unwrap_or_else(|| "current config".to_string()) +} + +fn icon_action_button(icon_name: &str, tooltip: &str) -> gtk4::Button { + let button = gtk4::Button::from_icon_name(icon_name); + button.add_css_class("flat"); + button.add_css_class("shell-menu-button"); + button.set_tooltip_text(Some(tooltip)); + button +} diff --git a/src/ui/pages/raw_editor.rs b/src/ui/pages/raw_editor.rs index ced36b6..9a9e529 100644 --- a/src/ui/pages/raw_editor.rs +++ b/src/ui/pages/raw_editor.rs @@ -1,7 +1,8 @@ -use crate::ui::pages::PageBuildContext; +use crate::ui::pages::{ + apply_live_preview_now, refresh_current_page_later, sync_config_ui, PageBuildContext, +}; use crate::ui::toast::show_toast; use crate::ui::widgets::tool_page; -use crate::window::{recompute_validation, refresh_save_button}; use gtk4::prelude::*; use libadwaita::prelude::*; use mangotune::config::parser::Parser; @@ -45,24 +46,12 @@ pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { let launch_row = libadwaita::ActionRow::builder() .title("Open raw text editor") - .subtitle("Launch a dedicated editor window with apply and reload actions") + .subtitle("Launch a dedicated editor window with apply and buffer-revert actions") .build(); launch_row.add_css_class("control-row"); - let button_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let open_button = gtk4::Button::with_label("Open Editor"); open_button.add_css_class("suggested-action"); - let reload_button = gtk4::Button::with_label("Refresh Snapshot"); - button_box.append(&reload_button); - button_box.append(&open_button); - - let ctx_reload = ctx.clone(); - let source_row_reload = source_row.clone(); - let stats_row_reload = stats_row.clone(); - reload_button.connect_clicked(move |_| { - source_row_reload.set_subtitle(¤t_config_path(&ctx_reload)); - stats_row_reload.set_subtitle(¤t_stats_label(&ctx_reload)); - }); let ctx_open = ctx.clone(); let source_row_open = source_row.clone(); @@ -73,7 +62,7 @@ pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { open_editor_window(&ctx_open); }); - launch_row.add_suffix(&button_box); + launch_row.add_suffix(&open_button); session_group.add(&launch_row); let workflow_group = tool_page::append_custom_section( @@ -107,7 +96,7 @@ fn open_editor_window(ctx: &PageBuildContext) { let (outer, body, footer_row) = tool_page::build_utility_window_shell( "Manual editing", "Raw Config Editor", - "Apply updates back into MangoTune when you want the dashboard and page controls to reflect them. Reload discards unsaved raw edits and restores the current in-memory config.", + "Apply updates back into MangoTune when you want the dashboard and page controls to reflect them. Revert Buffer discards unsaved raw edits and restores the current in-memory config.", ); let buffer = gtk4::TextBuffer::new(None::<>k4::TextTagTable>); @@ -139,7 +128,7 @@ fn open_editor_window(ctx: &PageBuildContext) { let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); spacer.set_hexpand(true); - let reload = gtk4::Button::with_label("Reload"); + let reload = gtk4::Button::with_label("Revert Buffer"); let apply = gtk4::Button::with_label("Apply to Workspace"); apply.add_css_class("suggested-action"); footer_row.append(&spacer); @@ -162,8 +151,9 @@ fn open_editor_window(ctx: &PageBuildContext) { } update_footer(&footer_apply, &text); - recompute_validation(&ctx_apply.state); - refresh_save_button(&ctx_apply.state, &ctx_apply.save_button); + sync_config_ui(&ctx_apply); + apply_live_preview_now(&ctx_apply); + refresh_current_page_later(&ctx_apply.parent_window); show_toast( &ctx_apply.toast_overlay, "Applied raw text changes to the workspace", diff --git a/src/ui/pages/typography.rs b/src/ui/pages/typography.rs index 14f0e46..c22e7f5 100644 --- a/src/ui/pages/typography.rs +++ b/src/ui/pages/typography.rs @@ -1,6 +1,7 @@ -use crate::ui::pages::{refresh_live_preview_for_key, register_option_row, PageBuildContext}; +use crate::ui::pages::{ + refresh_live_preview_for_key, register_option_row, sync_config_ui, PageBuildContext, +}; use crate::ui::widgets::{toggle_row, tool_page}; -use crate::window::{recompute_validation, refresh_save_button}; use gtk4::prelude::*; use libadwaita::prelude::*; use mangotune::config::parser::Parser; @@ -195,8 +196,7 @@ fn apply_font_value(ctx: &PageBuildContext, key: &str, selected_path: Option { - recompute_validation(&state); - refresh_save_button(&state, &save_button); - apply_preview_current_config(&page_ctx); - let _ = gtk4::prelude::WidgetExt::activate_action( - &page_ctx.parent_window, - "win.refresh-current-page", - None, - ); + refresh_workspace_after_config_change(&state, &save_button, &page_ctx); crate::ui::toast::show_toast( &toast_overlay, &format!("Loaded safety backup from {}", path.display()), @@ -1565,14 +1537,7 @@ fn install_window_actions( let settings = settings.cloned(); reset_defaults_action.connect_activate(move |_, _| { if reset_config_to_defaults(&state, settings.as_ref()) { - recompute_validation(&state); - refresh_save_button(&state, &save_button); - apply_preview_current_config(&page_ctx); - let _ = gtk4::prelude::WidgetExt::activate_action( - &page_ctx.parent_window, - "win.refresh-current-page", - None, - ); + refresh_workspace_after_config_change(&state, &save_button, &page_ctx); crate::ui::toast::show_toast( &toast_overlay, "Reset the current config and preview defaults", @@ -2559,19 +2524,7 @@ fn load_config_into_state( if let Some(settings) = settings { let _ = settings.set_string("last-config-path", &path.display().to_string()); } - recompute_validation(state); - refresh_save_button(state, save_button); - apply_preview_current_config(page_ctx); - let _ = gtk4::prelude::WidgetExt::activate_action( - &page_ctx.parent_window, - "win.refresh-current-page", - None, - ); - let _ = gtk4::prelude::WidgetExt::activate_action( - &page_ctx.parent_window, - "win.refresh-layer-stack", - None, - ); + refresh_workspace_after_config_load(state, save_button, page_ctx); crate::ui::toast::show_toast( toast_overlay, &format!("Switched active config to {label}"), @@ -2802,6 +2755,34 @@ fn apply_preview_current_config(page_ctx: &PageBuildContext) { pages::apply_live_preview_now(page_ctx); } +fn refresh_workspace_after_config_change( + state: &Arc>, + save_button: &libadwaita::SplitButton, + page_ctx: &PageBuildContext, +) { + recompute_validation(state); + refresh_save_button(state, save_button); + apply_preview_current_config(page_ctx); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-current-page", + None, + ); +} + +fn refresh_workspace_after_config_load( + state: &Arc>, + save_button: &libadwaita::SplitButton, + page_ctx: &PageBuildContext, +) { + refresh_workspace_after_config_change(state, save_button, page_ctx); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-layer-stack", + None, + ); +} + fn reload_config_from_disk( state: &Arc>, save_button: &libadwaita::SplitButton, @@ -2836,19 +2817,7 @@ fn reload_config_from_disk( if let Some(settings) = settings { let _ = settings.set_string("last-config-path", &path.display().to_string()); } - recompute_validation(state); - refresh_save_button(state, save_button); - apply_preview_current_config(page_ctx); - let _ = gtk4::prelude::WidgetExt::activate_action( - &page_ctx.parent_window, - "win.refresh-current-page", - None, - ); - let _ = gtk4::prelude::WidgetExt::activate_action( - &page_ctx.parent_window, - "win.refresh-layer-stack", - None, - ); + refresh_workspace_after_config_load(state, save_button, page_ctx); crate::ui::toast::show_toast(toast_overlay, "Reloaded config from disk"); true }