Initial import

This commit is contained in:
2026-03-30 22:51:56 -04:00
commit 08e2910b9d
103 changed files with 35475 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
# MangoTune — Agent Master Plan
## Project Summary
MangoTune is a GTK4 + libadwaita desktop application written in Rust for Linux.
It is a superior replacement for GOverlay — a GUI configurator for MangoHud,
with first-class support for config conflict detection, strict validation, visual
config-layer stacking (like CSS cascade), live preview via test launchers, and
integrations with GameMode, Steam, Lutris, and Heroic Games Launcher.
## Agent Instructions — READ FIRST
1. Read this file completely before doing anything.
2. Read `docs/architecture.md` for the full module map and dependency graph.
3. Read `docs/mangohud_schema.md` for the complete MangoHud option reference.
4. Read `docs/design_system.md` for all UI/UX rules and GTK4 widget patterns.
5. Read `phases/phase_XX.md` for the specific phase you are implementing.
6. Each module has its own spec file in `modules/`. Read the relevant spec before writing code.
7. Never modify files outside your assigned phase without noting it in a comment block.
8. After completing a phase, verify against the acceptance criteria in that phase file.
## Directory Layout (this plan repo)
```
mangotune-plan/
├── README.md ← YOU ARE HERE — read first
├── docs/
│ ├── architecture.md ← module map, crate deps, file tree
│ ├── mangohud_schema.md ← every MangoHud config option, types, constraints
│ ├── config_resolution.md ← how MangoHud config files are discovered & prioritized
│ ├── design_system.md ← GTK4/libadwaita UI rules, widget patterns, UX decisions
│ └── integrations.md ← GameMode, Steam, Lutris, Heroic specs
├── modules/
│ ├── config_parser.md ← parser/writer module spec
│ ├── config_schema.md ← schema/type system module spec
│ ├── config_validator.md ← validation engine module spec
│ ├── config_resolver.md ← multi-file conflict resolver module spec
│ ├── system_detect.md ← system detection module spec
│ ├── launcher.md ← test launcher module spec
│ └── ui_pages.md ← all UI page/widget specs
├── phases/
│ ├── phase_01.md ← Project scaffold, Cargo.toml, build system
│ ├── phase_02.md ← Config parser + schema + validator (no UI)
│ ├── phase_03.md ← Config resolver + system detection (no UI)
│ ├── phase_04.md ← GTK4 app skeleton, main window, navigation
│ ├── phase_05.md ← Performance & GPU/CPU pages (core UI)
│ ├── phase_06.md ← Appearance, layout, colors, typography pages
│ ├── phase_07.md ← Conflict resolver UI (cascade stack view)
│ ├── phase_08.md ← Keybindings, logging, FPS limits pages
│ ├── phase_09.md ← Test launcher (vkcube, glxgears, custom)
│ ├── phase_10.md ← Integrations (GameMode, Steam, Lutris, Heroic)
│ └── phase_11.md ← Polish, packaging, .desktop file, final QA
└── ui/
├── widget_toggle.md ← toggle switch widget spec
├── widget_color.md ← color picker widget spec
├── widget_hotkey.md ← hotkey capture widget spec
├── widget_cascade.md ← config cascade/layer stack widget spec
└── widget_validation.md ← inline validation error display spec
```
## Target Source Tree (the actual Rust project)
```
mangotune/
├── Cargo.toml
├── Cargo.lock
├── build.rs
├── data/
│ ├── com.mangotune.MangoTune.gschema.xml
│ ├── com.mangotune.MangoTune.desktop
│ └── icons/
│ └── com.mangotune.MangoTune.svg
├── src/
│ ├── main.rs ← entry point only — app init + run
│ ├── app.rs ← Application struct, GtkApplication setup
│ ├── window.rs ← MainWindow: AdwApplicationWindow
│ ├── config/
│ │ ├── mod.rs
│ │ ├── parser.rs ← read/write .conf files, preserve comments
│ │ ├── schema.rs ← typed schema: all ~120 MangoHud options
│ │ ├── validator.rs ← validation logic, dependency checks
│ │ ├── resolver.rs ← discover all config files, build priority stack
│ │ └── types.rs ← shared enums/structs (ConfigValue, OptionType, etc.)
│ ├── system/
│ │ ├── mod.rs
│ │ ├── detect.rs ← detect MangoHud version, tools, GPU, display server
│ │ └── paths.rs ← XDG path resolution, known config locations
│ ├── launcher/
│ │ ├── mod.rs
│ │ └── runner.rs ← spawn vkcube/glxgears/custom with MANGOHUD=1
│ ├── integrations/
│ │ ├── mod.rs
│ │ ├── gamemode.rs
│ │ ├── steam.rs
│ │ ├── lutris.rs
│ │ └── heroic.rs
│ └── ui/
│ ├── mod.rs
│ ├── pages/
│ │ ├── mod.rs
│ │ ├── overview.rs
│ │ ├── performance.rs
│ │ ├── gpu.rs
│ │ ├── cpu.rs
│ │ ├── memory.rs
│ │ ├── io_network.rs
│ │ ├── media_player.rs
│ │ ├── battery.rs
│ │ ├── appearance.rs
│ │ ├── colors.rs
│ │ ├── typography.rs
│ │ ├── keybindings.rs
│ │ ├── fps_limits.rs
│ │ ├── logging.rs
│ │ ├── blacklist.rs
│ │ ├── opengl_quirks.rs
│ │ ├── raw_editor.rs
│ │ └── conflicts.rs
│ └── widgets/
│ ├── mod.rs
│ ├── toggle_row.rs
│ ├── color_row.rs
│ ├── hotkey_row.rs
│ ├── cascade_view.rs
│ ├── validation_label.rs
│ └── launch_bar.rs
```
## Core Design Principles
- **Strict validation** — the Save button is disabled if any field contains an invalid value.
Inline error labels appear below each offending field. No config is written to disk in an
invalid state under any circumstances.
- **Comment preservation** — the parser round-trips existing files preserving all comments,
whitespace, and ordering. Only changed lines are modified.
- **Dependency awareness** — the schema encodes option dependencies (e.g. `gpu_mem_clock`
requires `vram=1`). Enabling a dependent option auto-enables its parent and shows a notice.
- **Config cascade visibility** — the conflict page shows all detected config files as a
vertical stack ordered by MangoHud's actual priority (env > app-local > per-app XDG > global).
Each option that is overridden shows which file wins and which files are shadowed.
- **No hardcoded paths** — all paths resolve via XDG spec, `$HOME`, and runtime detection.
- **Graceful degradation** — if MangoHud is not installed, the app opens but shows a clear
install prompt. Missing optional tools (vkcube, glxgears, GameMode) are indicated per-feature,
not as global errors.
## Key Differentiators vs GOverlay
| Feature | GOverlay | MangoTune |
|-------------------------------|----------------|---------------|
| Config conflict detection | None | Visual cascade|
| Option dependency validation | None | Full schema |
| Comment preservation | Destroys them | Preserved |
| Multi-file editing | Global only | All layers |
| Strict type validation | None | Blocks save |
| Wayland support | Broken | Native GTK4 |
| HiDPI | Buggy | GTK4 native |
| XDG compliance | Fixed in v1.7 | From day one |
| Toolkit | Qt (AppImage) | GTK4/Adwaita |
| Live test launcher | vkcube only | vkcube+glx+custom |
+230
View File
@@ -0,0 +1,230 @@
# Module Specs
---
# Module: config/parser.rs
**Purpose:** Read and write MangoHud .conf files preserving all comments and whitespace.
**Core constraint:** A config written by MangoTune must be byte-for-byte identical to
the original file except for the lines that were explicitly changed. All comments, blank
lines, section headers, and ordering must survive a read-write-read cycle unchanged.
## Parsing State Machine
```
for each line in file:
if line.trim().is_empty() → ConfigLine::Blank
if line.starts_with('#'):
strip leading '# ' or '#'
try parse as "key=value" or "key"
if valid MangoHud key format: → ConfigLine::CommentedOption { key, value }
else: → ConfigLine::Comment(original_line)
else:
split on first '=' if present
→ ConfigLine::Option { key, value, raw: original_line }
```
## Write Strategy
When serializing back to disk:
- For each ConfigLine in order: write it back
- For changed options: find the line by key and update ONLY that line's value portion
- For new options (key didn't exist): append at end of file
- For disabled options: prepend "# " to the line
## Key sanitization
- Keys are trimmed of whitespace
- Keys are case-sensitive (MangoHud is case-sensitive)
- Keys must match `^[a-zA-Z_][a-zA-Z0-9_]*$` to be recognized as options
---
# Module: config/validator.rs
**Purpose:** Stateless validation engine. Every function is pure (no side effects).
## Validation Priority
When multiple validation rules apply, return the most severe result (Error > Warning > Ok).
## Regex patterns (compile once with once_cell::Lazy)
```rust
static COLOR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[0-9A-Fa-f]{6}$").unwrap());
static PCI_DEV_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$").unwrap()
});
static KEYBIND_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^((Shift|Control|Alt|Super)_[LR]\+)*(F[1-9]|F1[0-2]|[A-Z])$").unwrap()
});
static FTRACE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(histogram|linegraph|label)/[a-zA-Z0-9_]+(/[a-zA-Z0-9_]+)?(\+(histogram|linegraph|label)/[a-zA-Z0-9_]+(/[a-zA-Z0-9_]+)?)*$").unwrap()
});
```
## Special cases
`fps_metrics` validation:
- Split by comma
- Each element must be: "AVG" (case-insensitive) OR a decimal between 0.0 and 100.0
`font_glyph_ranges` validation:
- Valid values: `["korean", "chinese", "chinese_simplified", "japanese",
"cyrillic", "thai", "vietnamese", "latin_ext_a", "latin_ext_b"]`
`graphs` validation:
- Valid values: `["gpu_load", "cpu_load", "gpu_core_clock", "gpu_mem_clock",
"vram", "ram", "cpu_temp", "gpu_temp"]`
`time_format` validation:
- Must be a valid strftime format string
- Validate by attempting to format a known date with the string using the `time` crate
(add `time = "0.3"` to dev-dependencies if not already present, or use chrono)
- If format produces empty string or contains '?': ValidationResult::Warning
---
# Module: system/detect.rs
**Purpose:** One-shot async system probe run at startup. Results are immutable after detection.
## MangoHud version parsing
`mangohud --version` outputs something like:
- `MangoHud 0.7.2`
- `v0.7.1-3-gabcdef`
Parse with: `^(?:MangoHud\s+)?v?(\d+\.\d+[\.\d]*)` to extract version string.
## GPU vendor detection (primary method: /sys/class/drm)
```
/sys/class/drm/card0/device/vendor → e.g. "0x1002\n"
0x1002 → AMD
0x10de → NVIDIA
0x8086 → Intel
```
If multiple GPUs found, use the first discrete GPU (non-Intel if Intel also present).
Store all detected GPUs in a `Vec<GpuInfo>` so the UI can show a GPU selector.
## Fallback GPU detection (if /sys fails)
Parse `lspci -nn 2>/dev/null` output — look for lines containing:
- "VGA compatible controller"
- "3D controller"
- "Display controller"
Extract vendor from PCI ID in brackets: `[10de:xxxx]` → NVIDIA, `[1002:xxxx]` → AMD.
## SystemInfo::unknown() constructor
Returns a SystemInfo with all fields set to "not detected" / false.
Used when detect_system() fails (should not happen in normal operation).
---
# Module: launcher/runner.rs
**Purpose:** Manage child processes for test applications.
## Environment setup
Always set these environment variables for launched processes:
```
MANGOHUD=1
MANGOHUD_CONFIGFILE={absolute_path_to_config}
```
Also preserve the user's existing environment (don't replace it — add to it).
Use `std::process::Command::env()` not `env_clear()`.
## Terminal detection
For `show_terminal=true`, detect the user's terminal in this order:
1. `$MANGOTUNE_TERMINAL` env var (user override)
2. `$TERM_PROGRAM`
3. Try `which` for: `gnome-terminal`, `kgx` (GNOME Console), `konsole`,
`xfce4-terminal`, `mate-terminal`, `lxterminal`, `xterm`
4. If none found: launch without terminal, show toast warning
Terminal command construction:
- gnome-terminal: `gnome-terminal -- {command}`
- kgx: `kgx -e {command}`
- konsole: `konsole -e {command}`
- xterm: `xterm -e {command}`
## Process monitoring
After launch, spawn a tokio task that:
1. Waits for process exit via `child.wait()`
2. On exit: sends a message via channel back to UI thread
3. UI removes the "running process" row
## SIGUSR1 for config reload
MangoHud reloads config on SIGUSR1.
```rust
use nix::sys::signal::{kill, Signal};
use nix::unistd::Pid;
pub async fn reload_config(pid: u32) -> anyhow::Result<()> {
kill(Pid::from_raw(pid as i32), Signal::SIGUSR1)?;
Ok(())
}
```
Add `nix = { version = "0.29", features = ["signal"] }` to Cargo.toml.
---
# Module: ui/widgets/cascade_view.rs
**Purpose:** Visual CSS-cascade-style display of config layers and conflicts.
## Data model
```rust
pub struct CascadeViewModel {
pub layers: Vec<LayerViewModel>,
pub filter: CascadeFilter,
}
pub struct LayerViewModel {
pub source: LayerSource,
pub priority: u8,
pub label: String,
pub is_editable: bool,
pub options: Vec<OptionViewModel>,
}
pub struct OptionViewModel {
pub key: String,
pub value: String,
pub state: OptionState,
pub overridden_by: Option<String>, // layer label that wins
}
pub enum OptionState {
Effective, // this layer's value is used at runtime
Shadowed, // overridden by a higher-priority layer
Winning, // this layer provides the winning value (overrides lower)
}
pub enum CascadeFilter {
All,
ConflictsOnly,
ShadowedOnly,
}
```
## Widget construction
```rust
pub fn build_cascade_view(model: CascadeViewModel) -> gtk4::Widget
```
Returns a scrollable `GtkScrolledWindow` containing a `GtkBox` (vertical) of
`AdwPreferencesGroup` widgets, one per layer in `model.layers`.
The widget must be efficiently rebuildable when the filter changes.
Connect filter button signals to rebuild/filter the view in-place.
+184
View File
@@ -0,0 +1,184 @@
# Architecture — MangoTune
## Rust Edition & MSRV
- Rust edition: **2021**
- Minimum Supported Rust Version: **1.75.0**
- Build target: `x86_64-unknown-linux-gnu` (primary), `aarch64-unknown-linux-gnu` (secondary)
## Cargo.toml Dependencies
```toml
[package]
name = "mangotune"
version = "0.1.0"
edition = "2021"
authors = ["MangoTune Contributors"]
description = "A modern MangoHud configurator for Linux"
license = "GPL-3.0"
repository = "https://github.com/your-org/mangotune"
[[bin]]
name = "mangotune"
path = "src/main.rs"
[dependencies]
# GUI
gtk4 = { version = "0.9", features = ["v4_12"] }
libadwaita = { version = "0.7", features = ["v1_5"] }
glib = "0.20"
gio = "0.20"
# Async runtime (for subprocess management, file watching)
tokio = { version = "1", features = ["rt-multi-thread", "process", "fs", "sync", "time"] }
# Serialization (for GSettings schema, internal state)
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Config file parsing
indexmap = "2" # preserve insertion order in parsed configs
# Error handling
anyhow = "1"
thiserror = "1"
# Filesystem watching (live reload when config changes externally)
notify = "6"
# XDG base directory resolution
xdg = "2"
# Regex (for config line parsing)
regex = "1"
once_cell = "1"
# Process detection (checking if gamemode is running, etc.)
sysinfo = "0.31"
# Logging/tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[build-dependencies]
# For compiling GSettings schema and other build-time assets
glib-build-tools = "0.20"
[dev-dependencies]
tempfile = "3"
assert_fs = "1"
```
## System Dependencies (must be present at build time)
| Package | Ubuntu/Debian | Fedora/RHEL | Arch |
|--------------------------|---------------------------|---------------------------------|----------------|
| GTK4 dev headers | `libgtk-4-dev` | `gtk4-devel` | `gtk4` |
| libadwaita dev headers | `libadwaita-1-dev` | `libadwaita-devel` | `libadwaita` |
| GLib dev headers | `libglib2.0-dev` | `glib2-devel` | `glib2` |
| pkg-config | `pkg-config` | `pkgconf` | `pkgconf` |
Runtime (optional, detected at launch):
- `mangohud` — the actual overlay
- `vkcube` — from `vulkan-tools` package
- `glxgears` — from `mesa-utils` package
- `gamemoded` — from `gamemode` package
- `gamemodectl` — from `gamemode` package
## Module Dependency Graph
```
main.rs
└── app.rs (GtkApplication)
└── window.rs (AdwApplicationWindow)
├── ui/pages/*.rs ← all pages
│ ├── config/resolver.rs ← discovers config stack
│ ├── config/validator.rs ← validates on every change
│ ├── config/parser.rs ← reads/writes files
│ └── config/schema.rs ← option definitions
├── ui/widgets/*.rs ← reusable widgets
├── system/detect.rs ← run at startup
├── system/paths.rs ← XDG resolution
├── launcher/runner.rs ← test process management
└── integrations/*.rs ← GameMode/Steam/Lutris/Heroic
```
## Data Flow
```
Startup:
system::detect::run()
→ SystemInfo { mangohud_version, gpu_vendor, display_server, available_tools }
config::resolver::discover()
→ Vec<ConfigLayer> { path, source_type, priority, exists }
For each ConfigLayer:
config::parser::read(path)
→ RawConfig { lines: Vec<ConfigLine> }
config::schema::annotate(raw)
→ AnnotatedConfig { options: IndexMap<key, ConfigEntry> }
User edits a field:
ui::widgets::* emits change signal
→ config::validator::check(key, value, &schema)
→ ValidationResult::Ok | ValidationResult::Error(msg) | ValidationResult::Warning(msg)
If Ok: update in-memory AnnotatedConfig
check for dependency side-effects
update cascade_view to show which layer owns the value
enable Save button if no errors anywhere
Save:
config::validator::check_all(&config) ← full pass before any write
→ if any Error: abort, show error summary toast
→ if all Ok: config::parser::write(path, config)
preserving all comment lines unchanged
```
## Threading Model
- **Main thread**: GTK4 event loop only. No blocking calls.
- **Tokio thread pool**: file I/O, subprocess spawning, filesystem watcher.
- Communication: `glib::MainContext::channel()` for sending results back to GTK main thread.
- Never call GTK functions from tokio threads.
## GSettings Schema
Used for persisting app preferences (window size, last-opened config path, theme preference).
NOT used for MangoHud config itself — that is always written directly to .conf files.
Schema ID: `com.mangotune.MangoTune`
Keys:
- `last-config-path` (string)
- `window-width` (int, default 1200)
- `window-height` (int, default 780)
- `active-page` (string, default "performance")
- `show-raw-editor` (bool, default false)
## Error Handling Strategy
- All I/O operations return `anyhow::Result`.
- UI layer converts errors to `AdwToast` notifications (non-blocking).
- Critical startup errors (GTK init failure) use `eprintln!` + `process::exit(1)`.
- Validation errors are `thiserror` enums, displayed inline, never panicked on.
- Never use `.unwrap()` or `.expect()` in production paths. Use `?` or match.
## Config File Format Notes
MangoHud .conf files follow these rules (the parser must handle all of them):
1. Lines starting with `#` are comments — preserve verbatim.
2. Empty lines — preserve verbatim.
3. `key=value` — option with value.
4. `key` alone (no `=`) — boolean flag, presence = enabled.
5. `# key` — commented-out option (disabled).
6. `# key=value` — commented-out option with default value shown.
7. Inline comments after values are NOT standard and should be treated as part of value.
8. Duplicate keys: last occurrence wins (MangoHud behavior).
9. Encoding: UTF-8.
The parser must distinguish between:
- An option that is absent from the file (use MangoHud's compiled default)
- An option explicitly set to 0 or empty (user explicitly disabled)
- An option present as a bare key (user enabled a flag)
+149
View File
@@ -0,0 +1,149 @@
# Config Resolution & Priority System
## MangoHud's Config Priority Order (highest to lowest)
When MangoHud loads, it resolves configuration from multiple sources. Later sources
override earlier ones. **Highest priority wins for any given option.**
```
Priority 5 (HIGHEST) — Environment variable override
$MANGOHUD_CONFIG="key=value,key2=value2"
Also: $MANGOHUD_CONFIGFILE="/path/to/custom.conf"
Priority 4 — App-local config (same directory as the game executable)
{game_directory}/MangoHud.conf
Priority 3 — Per-app XDG config (named after the process)
$XDG_CONFIG_HOME/MangoHud/{appname}.conf
(default: ~/.config/MangoHud/{appname}.conf)
Priority 2 — Global XDG user config
$XDG_CONFIG_HOME/MangoHud/MangoHud.conf
(default: ~/.config/MangoHud/MangoHud.conf)
Priority 1 (LOWEST) — MangoHud compiled defaults
(no file, built into the library)
```
## Discovery Algorithm for `config::resolver`
```
fn discover() -> Vec<ConfigLayer>:
1. Check environment:
a. Read $MANGOHUD_CONFIGFILE — if set and path exists, record as Priority 5b
b. Read $MANGOHUD_CONFIG — if set, parse inline key=value pairs as Priority 5a
Note: 5a overrides 5b which overrides all file-based configs
2. Determine XDG config home:
a. Use $XDG_CONFIG_HOME if set and non-empty
b. Otherwise use $HOME/.config
c. If neither available: warn and skip file-based discovery
3. Enumerate known config files in priority order:
a. {XDG_CONFIG_HOME}/MangoHud/MangoHud.conf (global)
b. {XDG_CONFIG_HOME}/MangoHud/*.conf (all per-app configs found)
c. Scan common game directories for MangoHud.conf:
- $HOME/.steam/steam/steamapps/common/*/
- $HOME/.local/share/Steam/steamapps/common/*/
- $HOME/Games/*/
- $HOME/.var/app/com.valvesoftware.Steam/data/Steam/steamapps/common/*/
(Flatpak Steam)
4. For each discovered config file:
- Record: path, source_type, priority_rank, file_exists, last_modified
- Parse if exists
5. Build conflict map:
For each option key found in more than one layer:
- Record which layer provides the winning value
- Record which layers are shadowed
- Mark as "conflict" in the UI
```
## ConfigLayer Struct
```rust
pub struct ConfigLayer {
pub path: Option<PathBuf>, // None for env-var inline configs
pub source_type: LayerSource,
pub priority: u8, // 1=lowest (compiled default) to 5=highest (env)
pub exists: bool,
pub is_editable: bool, // false for env-var layers
pub last_modified: Option<SystemTime>,
pub config: Option<AnnotatedConfig>,
}
pub enum LayerSource {
CompiledDefault,
GlobalXdg, // ~/.config/MangoHud/MangoHud.conf
PerAppXdg(String), // ~/.config/MangoHud/{appname}.conf — stores appname
AppLocal(PathBuf), // {game_dir}/MangoHud.conf
EnvFile(PathBuf), // $MANGOHUD_CONFIGFILE
EnvInline(String), // $MANGOHUD_CONFIG inline value
}
```
## Conflict Detection Rules
A **conflict** exists when:
- An option is explicitly set in two or more layers with different values.
- OR an env var (`$MANGOHUD_CONFIG` or `$MANGOHUD_CONFIGFILE`) is set AND any file-based config also sets the same option — the env always wins but the user may not realize it.
A **shadow** occurs when:
- A lower-priority layer sets an option that a higher-priority layer also sets.
The lower-priority setting is "shadowed" (has no effect at runtime).
## UI: Visual Cascade (CSS Specificity Style)
The Conflicts page (`ui/pages/conflicts.rs`) renders a vertical stack of layers,
highest priority at top. For each layer:
```
┌─────────────────────────────────────────────────────────┐
│ 🔴 ENV: $MANGOHUD_CONFIG [not editable]│
│ gpu_stats=0 fps_limit=120 text_color=FF0000 │
├─────────────────────────────────────────────────────────┤
│ 🟡 Per-App: ~/.config/MangoHud/cs2.conf [Edit] │
│ fps_limit=60 ← SHADOWED by ENV above │
│ gpu_temp=1 cpu_temp=1 ram=1 │
├─────────────────────────────────────────────────────────┤
│ 🟢 Global: ~/.config/MangoHud/MangoHud.conf [Edit] │
│ fps_limit=0 ← SHADOWED by cs2.conf and ENV │
│ font_size=24 position=top-left background_alpha=0.5 │
└─────────────────────────────────────────────────────────┘
```
Color coding:
- 🔴 Red badge = env var override (cannot edit in app, show value only)
- 🟡 Yellow badge = per-app or app-local config
- 🟢 Green badge = global config
- Grey strikethrough text = shadowed (ineffective) option
Clicking an option in any layer:
- If editable layer: jumps to that option in the config editor with that layer selected
- If env-var layer: shows tooltip explaining how to unset the env var
## Creating New Config Files
When user clicks "+ New Config":
1. Ask: Global, Per-App (enter app name), or App-Local (browse for directory)?
2. If Per-App: validate app name (alphanumeric + hyphens/underscores only)
3. Create file with header comment block:
```
### MangoHud configuration - managed by MangoTune
### Created: {date}
### App: {appname or "global"}
```
4. Add to resolver's layer stack immediately.
5. Set as the active editing target.
## Config File Write Safety
Before writing any config to disk:
1. Run full validation pass — abort if any errors.
2. Create a backup: `{original_path}.mangotune.bak` (overwrite if exists).
3. Write to `{original_path}.mangotune.tmp`.
4. On success: atomically rename tmp → original.
5. On failure: restore from backup, show error toast.
6. Never write a partial/corrupt file.
+341
View File
@@ -0,0 +1,341 @@
# Design System — MangoTune GTK4 / libadwaita
## Guiding Principles
1. **Libadwaita first** — use `Adw::*` widgets wherever they exist before falling back to GTK4.
This ensures correct dark/light mode, accent color, and GNOME HIG compliance automatically.
2. **Every field validates on change** — instant inline feedback, never wait for save.
3. **Save button state is truth** — it is only sensitive when all fields are valid AND there
are unsaved changes. It is insensitive otherwise. Never disable fields; always show why.
4. **Contextual help is inline** — use `subtitle` on `AdwActionRow` for brief descriptions.
Longer explanations go in an `AdwTooltip`. No separate help dialogs.
5. **Destructive actions require confirmation** — deleting a config file uses `AdwAlertDialog`.
---
## Main Window Structure
```
AdwApplicationWindow "MangoTune"
AdwToolbarView
┌── [top] AdwHeaderBar
│ Title: "MangoTune"
│ Start: AdwSplitButton "Save" (primary action)
│ End: menu button (gear icon → preferences, about)
├── [top] ConfigBarWidget (custom, below header)
│ Shows: current file being edited
│ Dropdown: select from all discovered config layers
│ Conflict indicator: if any conflicts detected
└── [content] AdwOverlaySplitView
Sidebar: NavigationSidebar (AdwNavigationSidebar or custom ListBox)
Content: AdwNavigationView (manages page stack)
```
## Header Bar
- Title: "MangoTune"
- Subtitle: name of current config file being edited (short path)
- Primary button: `AdwSplitButton` labeled "Save"
- Main click: save current file
- Dropdown arrow: "Save As…", "Revert to Saved", "Create Backup"
- End: `Gtk::MenuButton` with gear icon
- Menu items: Preferences, Keyboard Shortcuts, About MangoTune
## Config File Selector Bar
Custom widget rendered between HeaderBar and the sidebar/content split.
Appearance: an `AdwBanner` variant or custom `GtkBox` with background `@card_bg_color`.
Contents (left to right):
- Icon indicating layer type (globe for global, app icon for per-app, warning for env)
- Dropdown (`GtkDropDown`) showing all discovered layers with their priority
- Conflict badge: `GtkLabel` with `.error` or `.warning` CSS class if conflicts exist
- Right side: "View All Layers" button → navigates to Conflicts page
Layer display format in dropdown:
```
[●] ~/.config/MangoHud/MangoHud.conf (global)
[◉] ~/.config/MangoHud/cs2.conf (per-app: cs2) ← currently editing
[⚠] $MANGOHUD_CONFIG (env override — read only)
```
---
## Sidebar Navigation
Use `AdwNavigationSidebar` if available in libadwaita 1.4+, otherwise `GtkListBox` with
`.navigation-sidebar` CSS class.
Sections (use `GtkSeparator` between groups):
**Config**
- Overview (house icon)
- Layer Conflicts (warning icon, badge with conflict count if > 0)
**Display**
- Performance (speedometer icon)
- GPU (chip icon)
- CPU (cpu icon)
- Memory (memory icon)
- I/O & Network (network icon)
- Media Player (music note icon)
- Battery (battery icon)
**Appearance**
- Layout & Position (layout icon)
- Colors & Theme (palette icon)
- Typography (text icon)
**Behavior**
- Keybindings (keyboard icon)
- FPS Limits (gauge icon)
- Logging (file icon)
- Blacklist (block icon)
**Advanced**
- OpenGL Quirks (warning icon)
- Raw Editor (code icon)
**Tools**
- Test Launcher (play icon)
- Integrations (plugin icon)
---
## Page Layout Pattern
Every config page follows this structure:
```
AdwPreferencesPage
title: "GPU Metrics"
icon-name: "processor-symbolic" (or custom)
AdwPreferencesGroup
title: "GPU Statistics"
description: "Core GPU monitoring options"
AdwSwitchRow ← for Flag/Bool options
AdwSpinRow ← for Int options
AdwEntryRow ← for String/Path options
AdwComboRow ← for Enum options
AdwExpanderRow ← for groups with sub-options (e.g. load color thresholds)
└── nested rows inside
AdwPreferencesGroup
title: "Advanced"
...
```
---
## Widget Patterns Per Option Type
### Flag / Bool → `AdwSwitchRow`
```
AdwSwitchRow {
title: "GPU Temperature",
subtitle: "Show GPU core temperature (gpu_temp)",
active: <bool>,
}
```
On toggle: validate, update model, check dependencies.
### Int with range → `AdwSpinRow`
```
AdwSpinRow {
title: "Font Size",
subtitle: "font_size — valid range: 872",
value: 24.0,
adjustment: Gtk::Adjustment { lower: 8, upper: 72, step-increment: 1 },
}
```
### Float with range → `AdwSpinRow` (digits: 2 or 3)
### Enum → `AdwComboRow`
```
AdwComboRow {
title: "HUD Position",
subtitle: "position",
model: StringList ["top-left", "top-right", "bottom-left", ...],
}
```
### String (free text) → `AdwEntryRow`
```
AdwEntryRow {
title: "Custom GPU Label",
text: "",
// validation on ::changed signal
}
```
Validation error: add `.error` CSS class to the row, set subtitle to error message.
### Path → `AdwEntryRow` + browse button
```
AdwActionRow {
title: "Font File",
AdwEntryRow + GtkButton "Browse…"
}
```
Browse opens `GtkFileDialog` filtered to `.ttf,.otf`.
Validate path exists after selection.
### Color → `AdwActionRow` with color swatch button
```
AdwActionRow {
title: "GPU Color",
subtitle: "gpu_color — hex RRGGBB",
[suffix] GtkButton (color swatch, shows current color)
→ opens AdwDialog with color picker
→ also shows a GtkEntry for manual hex input
}
```
### Hotkey / Keybind → Custom `KeybindRow` widget
```
AdwActionRow {
title: "Toggle HUD",
subtitle: "toggle_hud",
[suffix] GtkShortcutLabel (shows current binding)
[suffix] GtkButton "Edit" → opens capture dialog
}
```
Capture dialog: fullscreen-ish `AdwDialog`, listens for keypress, shows "Press a key combination…",
captures and validates the combination, shows preview, OK/Cancel.
### CommaSeparatedStrings (controlled set) → `AdwExpanderRow` with checkboxes
Example: `graphs`, `font_glyph_ranges`, `device_battery`
```
AdwExpanderRow {
title: "Graphs",
subtitle: "Select which graphs to display",
[child per valid value] AdwSwitchRow or CheckButton row
}
```
### CommaSeparatedStrings (free) → `AdwEntryRow` with validation
Example: `blacklist`, `network`
### FpsLimitList → Custom widget
A `GtkFlowBox` of chips showing current FPS values (0, 30, 60, etc.)
with + button to add and × to remove each. Each value validated as non-negative int.
---
## Inline Validation Display
When a field has an error:
1. The `AdwActionRow` or `AdwEntryRow` gets `.error` CSS class applied.
2. The row's subtitle changes to the error message (red text via `.error` on a child label).
3. A validation summary appears at top of page: `AdwBanner` with "N fields have errors — fix to enable saving".
4. The Save button in the header becomes insensitive.
When a dependency warning fires (e.g. user enables `gpu_mem_clock` without `vram`):
1. Show `AdwAlertDialog`: "Enabling 'GPU Memory Clock' also requires 'VRAM display' to be enabled. Enable it now?"
2. Buttons: "Enable Both" (suggested-action), "Cancel".
---
## Conflict/Layer Cascade Page
This is the most distinctive page in the app.
Layout: vertical stack of `AdwPreferencesGroup` cards, one per discovered layer,
ordered top-to-bottom = highest-to-lowest priority.
Each layer card header shows:
- Priority badge (e.g. "ENV", "PER-APP", "GLOBAL") with color coding
- File path or env var name
- "Edit" button (disabled for env layers)
- "Open in Files" button (for file layers)
Inside each layer card: a `GtkListBox` showing every option set in that layer.
Options that are shadowed by a higher-priority layer:
- Shown with strikethrough text
- A label "overridden by {LAYER}" in muted color
Options that are unique to this layer (no conflict): normal display.
Options that this layer wins on (it overrides lower layers): bold text.
Filter bar at top of page:
- "Show all options" / "Show conflicts only" / "Show shadowed only" toggle buttons
---
## Test Launcher Panel
Shown as a persistent bottom bar (collapsed by default) OR as a dedicated page.
Decision: dedicated page (cleaner, avoids layout complications).
Layout:
```
AdwPreferencesPage "Test Launcher"
AdwPreferencesGroup "Quick Test"
description: "Launch a test application with MangoHud active to preview your config"
AdwActionRow "vkcube (Vulkan)"
subtitle: "vulkan-tools — tests Vulkan overlay"
[suffix] status: "installed" / "not found"
[suffix] GtkButton "Launch"
AdwActionRow "glxgears (OpenGL)"
subtitle: "mesa-utils — tests OpenGL overlay"
[suffix] status indicator
[suffix] GtkButton "Launch"
AdwActionRow "Custom Application"
subtitle: "Launch any app with MangoHud injected"
[suffix] GtkEntry (command)
[suffix] GtkButton "Launch"
AdwPreferencesGroup "Launch Options"
AdwSwitchRow "Auto-reload config on save"
subtitle: "Sends SIGUSR1 to running MangoHud processes on save"
AdwSwitchRow "Show terminal output"
subtitle: "Opens a terminal window showing app stdout/stderr"
AdwPreferencesGroup "Running Process"
(only visible when a test process is active)
AdwActionRow showing process name + PID
[suffix] GtkButton "Stop"
```
When Launch is clicked:
1. Check tool is installed (which vkcube, which glxgears).
2. If not found: `AdwToast` "vkcube not found. Install vulkan-tools package."
3. If found: spawn process with `MANGOHUD=1 MANGOHUD_CONFIGFILE={current_path} {command}`.
4. Show running process row.
5. Monitor process — remove row when it exits.
---
## Theming
- Follow system theme (light/dark) automatically via libadwaita.
- Do NOT hardcode colors. Use only named GTK/Adwaita CSS variables:
`@accent_color`, `@destructive_color`, `@warning_color`, `@success_color`,
`@card_bg_color`, `@window_bg_color`, `@headerbar_bg_color`, etc.
- MangoTune-specific CSS: only for the cascade view layer badges and color swatch button.
Place in `data/style.css`, loaded at runtime via `GtkCssProvider`.
---
## Accessibility
- All interactive widgets must have accessible labels.
- Color information must never be the sole indicator of state (always pair with icon or text).
- Keyboard navigation must work for all pages (GTK4 handles most of this by default).
- Use `gtk_accessible_update_property` where needed for dynamic content.
---
## Window Size & Responsiveness
- Default: 1200 × 780
- Minimum: 900 × 600
- The `AdwOverlaySplitView` collapses the sidebar at narrow widths (< 980px) automatically.
- Persist window size via GSettings `window-width` / `window-height`.
+274
View File
@@ -0,0 +1,274 @@
# Integrations Spec
## Overview
MangoTune implements four integrations accessible from the Integrations page.
Each integration is independent — a missing tool shows a "not available" state
for that section only, without affecting the rest of the app.
---
## 1. GameMode Integration
**What is GameMode?** A daemon by Feral Interactive that applies CPU governor,
scheduler, and I/O priority optimizations when games run. MangoHud can display
whether GameMode is currently active via the `gamemode=1` config option.
### Detection
```rust
// src/integrations/gamemode.rs
pub struct GameModeStatus {
pub daemon_installed: bool, // gamemoded binary found
pub ctl_installed: bool, // gamemodectl binary found
pub daemon_running: bool, // gamemoded process in process list
pub current_clients: u32, // number of active gamemoded clients
}
fn detect() -> GameModeStatus
```
Detection steps:
1. `which gamemoded` — sets `daemon_installed`
2. `which gamemodectl` — sets `ctl_installed`
3. Check process list via `sysinfo` for `gamemoded` — sets `daemon_running`
4. If ctl installed: run `gamemodectl status` and parse client count
### UI (on Integrations page)
```
AdwPreferencesGroup "GameMode"
description: "Feral Interactive GameMode performance optimization daemon"
AdwActionRow "Status"
subtitle: "gamemoded process"
[suffix] label: "Running (3 clients)" / "Stopped" / "Not installed"
AdwSwitchRow "Show GameMode status in overlay"
subtitle: "Sets gamemode=1 in current config"
(bound to the gamemode config option)
AdwActionRow "Enable GameMode for all Steam games"
subtitle: "Adds %command% to default Steam launch options helper"
[suffix] GtkButton "Configure"
```
### No direct daemon control
MangoTune does NOT start/stop gamemoded. It only shows status and helps the user
configure the launch options. Provide a tooltip: "Start/stop GameMode via your
system service manager (systemctl --user start gamemoded)."
---
## 2. Steam Launch Option Helper
**Purpose:** Generate the correct launch option string for Steam games so that
MangoHud (and optionally GameMode) is injected automatically.
### Detection
```rust
pub struct SteamStatus {
pub installed: bool,
pub flatpak: bool, // true if running as Flatpak
pub running: bool,
pub steam_root: Option<PathBuf>,
pub localconfig_path: Option<PathBuf>,
}
```
Detection steps:
1. Check `which steam` and `flatpak list | grep com.valvesoftware.Steam`
2. Find Steam root:
- Native: `~/.steam/steam/` or `~/.local/share/Steam/`
- Flatpak: `~/.var/app/com.valvesoftware.Steam/.steam/steam/`
3. Find `userdata/{userId}/config/localconfig.vdf`
### Launch Option Generator UI
```
AdwPreferencesGroup "Steam Launch Options"
AdwComboRow "Inject method"
options: [
"mangohud {command}", ← standard
"MANGOHUD=1 %command%", ← env var method
"MANGOHUD_CONFIGFILE=~/.config/... %command%", ← explicit config
"gamemoderun mangohud %command%", ← with GameMode
"gamemoderun mangemoderun mangohud %command%", ← with GameMode (flatpak)
]
AdwEntryRow (read-only)
title: "Generated launch option"
[Shows generated string based on above selection]
[suffix] GtkButton "Copy to clipboard"
AdwActionRow "Instructions"
subtitle: "In Steam: right-click game → Properties → Launch Options → paste above"
```
### Important note for Flatpak Steam
If Flatpak Steam is detected, the generated command uses the correct Flatpak-aware
prefix and warns the user that MangoHud must also be installed inside the Flatpak
sandbox or as a Flatpak extension.
### DO NOT write to localconfig.vdf
MangoTune does NOT modify Steam's localconfig.vdf directly — too fragile and risky.
The user copies the generated string manually. This is deliberate and safe.
---
## 3. Lutris Integration
**Purpose:** Help users configure MangoHud for games managed by Lutris.
### Detection
```rust
pub struct LutrisStatus {
pub installed: bool,
pub flatpak: bool,
pub config_dir: Option<PathBuf>, // ~/.config/lutris/
pub games: Vec<LutrisGame>,
}
pub struct LutrisGame {
pub name: String,
pub slug: String,
pub config_path: PathBuf, // ~/.config/lutris/games/{slug}.yml
pub runner: String,
}
```
Detection steps:
1. `which lutris` or `flatpak list | grep net.lutris.Lutris`
2. Enumerate `~/.config/lutris/games/*.yml` — parse YAML for name, slug, runner fields.
Use a simple line-by-line parser (avoid heavy YAML dep — these files are simple).
### UI
```
AdwPreferencesGroup "Lutris"
AdwActionRow "Status"
[suffix] "Installed" / "Not found"
(if installed):
AdwComboRow "Game"
[lists all detected Lutris games]
AdwSwitchRow "Enable MangoHud for selected game"
subtitle: "Adds mangohud to the game's Lutris runner configuration"
AdwActionRow "Open game config in Lutris"
[suffix] GtkButton "Open Lutris"
AdwPreferencesGroup (informational)
AdwActionRow
subtitle: "MangoTune can enable MangoHud in Lutris game configs.
Per-game MangoHud config files will be placed at:
~/.config/MangoHud/{game-slug}.conf"
[suffix] GtkButton "Create per-game config"
```
### Config modification approach for Lutris
When "Enable MangoHud for selected game" is toggled ON:
1. Read `~/.config/lutris/games/{slug}.yml`
2. Find or create the `system:` section
3. Set `mangohud: true` under the `system:` key
4. Write back (preserve all other content, modify only the mangohud line)
5. Show toast: "MangoHud enabled for {game name}. Restart Lutris if it's open."
When toggled OFF: set `mangohud: false`.
---
## 4. Heroic Games Launcher Integration
**Purpose:** Help users configure MangoHud for games managed by Heroic (Epic Games,
GOG, and Amazon Prime on Linux).
### Detection
```rust
pub struct HeroicStatus {
pub installed: bool,
pub flatpak: bool,
pub config_dir: Option<PathBuf>,
pub games: Vec<HeroicGame>,
}
pub struct HeroicGame {
pub title: String,
pub app_name: String, // Heroic's internal ID
pub store: HeroicStore, // Epic, GOG, Amazon
pub config_path: PathBuf, // ~/.config/heroic/GamesConfig/{app_name}.json
}
pub enum HeroicStore { Epic, Gog, Amazon }
```
Detection steps:
1. `which heroic` or `flatpak list | grep com.heroicgameslauncher.hgl`
2. Config dirs to check:
- Native: `~/.config/heroic/`
- Flatpak: `~/.var/app/com.heroicgameslauncher.hgl/config/heroic/`
3. Enumerate `GamesConfig/*.json` — each file = one game config.
4. Parse JSON: extract `title`, `appName`, store type from file content.
### Heroic Game Config JSON structure (relevant fields)
```json
{
"appName": "AppId",
"title": "Game Title",
"enviromentOptions": [
{ "key": "MANGOHUD", "value": "1" }
],
"wrapperOptions": [
{ "exe": "mangohud", "args": "" }
]
}
```
### UI
```
AdwPreferencesGroup "Heroic Games Launcher"
AdwActionRow "Status"
[suffix] "Installed (Flatpak)" / "Not found"
AdwComboRow "Game"
[lists games grouped by store if > 5 games]
AdwSwitchRow "Enable MangoHud via wrapper"
subtitle: "Adds mangohud as a wrapper in Heroic game settings"
AdwSwitchRow "Enable MangoHud via environment"
subtitle: "Sets MANGOHUD=1 in game environment variables"
AdwActionRow "Per-game config"
subtitle: "~/.config/MangoHud/{app_name}.conf"
[suffix] GtkButton "Create / Edit"
```
### Config modification approach for Heroic
When "Enable MangoHud via wrapper" is toggled ON:
1. Read `~/.config/heroic/GamesConfig/{app_name}.json` (handle Flatpak path too)
2. Parse JSON using `serde_json`
3. Add `{ "exe": "mangohud", "args": "" }` to `wrapperOptions` if not present
4. Write back with pretty-printing
5. Show toast: "MangoHud wrapper enabled. Restart Heroic if it's open."
When toggled OFF: remove the mangohud entry from `wrapperOptions`.
Environment method similarly adds/removes `{ "key": "MANGOHUD", "value": "1" }`.
---
## Integration Page Layout
```
AdwPreferencesPage "Integrations"
icon-name: "insert-object-symbolic"
[One AdwPreferencesGroup per integration, as described above]
AdwPreferencesGroup "Global MangoHud Enable"
description: "Enable MangoHud system-wide for all applications"
AdwExpanderRow "Auto-enable method"
AdwSwitchRow "Via ~/.config/environment.d/mangohud.conf (recommended, user-scoped)"
AdwSwitchRow "Via ~/.bashrc (shell sessions only)"
AdwSwitchRow "Via /etc/environment (system-wide, requires sudo)"
[Shows currently active method with green checkmark]
[Warning: "System-wide enable may break some applications. Per-game is preferred."]
```
+439
View File
@@ -0,0 +1,439 @@
# MangoHud Schema Reference
Superseded as the user-facing/help-source reference by
[`docs/MANGOHUD_OPTION_BEHAVIOR.md`](/home/aaron/Programming/mangotune/docs/MANGOHUD_OPTION_BEHAVIOR.md).
Keep this file only as schema-planning context for `src/config/schema.rs`.
Source: Official MangoHud repository `data/MangoHud.conf` cross-referenced with
the MangoHud README and source code. Last verified against MangoHud 0.7.x.
This file is no longer the authoritative help text source and does not currently
cover every upstream MangoHud option.
## Schema Entry Structure
Each option in `schema.rs` must encode:
```rust
pub struct SchemaEntry {
pub key: &'static str,
pub option_type: OptionType,
pub default: DefaultValue,
pub description: &'static str,
pub category: Category,
pub dependencies: &'static [&'static str], // options that must be enabled for this to work
pub conflicts_with: &'static [&'static str], // mutually exclusive options
pub gpu_vendor_only: Option<GpuVendor>, // None = all, Some(Amd) = AMD only
pub gamescope_only: bool,
pub mangoapp_only: bool,
}
pub enum OptionType {
Flag, // bare key, no value — presence means enabled
Bool, // key=0 or key=1
Int { min: i64, max: i64 },
Float { min: f64, max: f64 },
String { max_len: usize },
Color, // 6-char hex RRGGBB, no #
Enum { variants: &'static [&'static str] },
FpsLimitList, // comma-separated ints e.g. "0,30,60"
KeyBind, // e.g. "Shift_R+F12"
CommaSeparatedInts,
CommaSeparatedFloats,
CommaSeparatedStrings,
Path, // filesystem path, validated to exist or be writable
}
```
---
## Category: PERFORMANCE
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `fps_limit` | FpsLimitList | `0` | 0 = unlimited; comma-separated list e.g. `0,30,60` |
| `fps_limit_method` | Enum[`early`,`late`] | `""` | early = wait before present |
| `vsync` | Int[-1..3] | `-1` | -1=unset; 0=adaptive; 1=off; 2=mailbox; 3=on |
| `gl_vsync` | Int[-2..N] | `-2` | OpenGL only; -2=unset; 0=off; >=1=wait N vblanks |
| `picmip` | Int[-17..16] | `-17` | Mip-map LoD bias; negative=sharper |
| `af` | Int[-1..16] | `-1` | Anisotropic filtering; -1=unset |
| `bicubic` | Flag | absent | Force bicubic filtering |
| `trilinear` | Flag | absent | Force trilinear filtering |
| `retro` | Flag | absent | Disable linear filtering (blocky textures) |
---
## Category: DISPLAY — FPS & FRAMETIME
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `fps` | Flag | present | Show FPS counter — enabled by default |
| `fps_only` | Flag | absent | CONFLICTS WITH: all other display params |
| `fps_sampling_period` | Int[100..60000] | `500` | ms |
| `fps_color_change` | Flag | absent | Enable FPS color thresholds |
| `fps_value` | CommaSeparatedInts | `30,60` | Two thresholds: warn,ok |
| `fps_color` | CommaSeparatedStrings | `B22222,FDFD09,39F900` | Three hex colors: low,mid,high |
| `fps_text` | String | `""` | Custom label for FPS row |
| `fps_metrics` | CommaSeparatedStrings | `""` | e.g. `avg,0.01,1,97``AVG` or decimal percentiles |
| `frametime` | Flag | present | Show frametime — enabled by default |
| `frame_count` | Flag | absent | Show frame counter |
| `frame_timing` | Flag | present | Frametime graph — enabled by default |
| `frame_timing_detailed` | Flag | absent | More detailed frametime graph |
| `dynamic_frame_timing` | Flag | absent | Dynamic scale frametime graph |
| `histogram` | Flag | absent | CONFLICTS WITH frame_timing |
| `throttling_status` | Flag | present | GPU throttling indicator |
| `throttling_status_graph` | Flag | absent | Show throttling on frametime graph |
| `show_fps_limit` | Flag | absent | Display current FPS limit value |
---
## Category: DISPLAY — GPU
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `gpu_stats` | Flag | present | Master GPU section toggle |
| `gpu_temp` | Flag | absent | |
| `gpu_junction_temp` | Flag | absent | |
| `gpu_core_clock` | Flag | absent | |
| `gpu_mem_temp` | Flag | absent | DEPENDS ON: `vram` |
| `gpu_mem_clock` | Flag | absent | DEPENDS ON: `vram` |
| `gpu_power` | Flag | absent | |
| `gpu_power_limit` | Flag | absent | |
| `gpu_text` | String[32] | `""` | Custom GPU label |
| `gpu_load_change` | Flag | absent | Color GPU load |
| `gpu_load_value` | CommaSeparatedInts | `60,90` | Two load thresholds |
| `gpu_load_color` | CommaSeparatedStrings | `39F900,FDFD09,B22222` | Three hex colors |
| `gpu_fan` | Flag | absent | RPM on AMD, percent on NVIDIA |
| `gpu_voltage` | Flag | absent | AMD ONLY |
| `gpu_list` | CommaSeparatedInts | `""` | Select GPUs by index e.g. `0,1` |
| `gpu_efficiency` | Flag | absent | |
| `gpu_name` | Flag | absent | Show GPU model name |
| `vulkan_driver` | Flag | absent | Show Vulkan driver string |
| `engine_version` | Flag | absent | |
| `engine_short_names` | Flag | absent | |
| `present_mode` | Flag | absent | |
| `pci_dev` | String | `""` | Format: `domain:bus:slot.function` e.g. `0000:03:00.0` |
---
## Category: DISPLAY — CPU
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `cpu_stats` | Flag | present | Master CPU section toggle |
| `cpu_temp` | Flag | absent | |
| `cpu_power` | Flag | absent | |
| `cpu_text` | String[32] | `""` | Custom CPU label |
| `cpu_mhz` | Flag | absent | Average MHz across cores |
| `cpu_load_change` | Flag | absent | Color CPU load |
| `cpu_load_value` | CommaSeparatedInts | `60,90` | Two thresholds |
| `cpu_load_color` | CommaSeparatedStrings | `39F900,FDFD09,B22222` | Three hex colors |
| `cpu_efficiency` | Flag | absent | |
| `core_load` | Flag | absent | Per-core load bars |
| `core_load_change` | Flag | absent | Color per-core load |
| `core_bars` | Flag | absent | Graphical core bars |
| `core_type` | Flag | absent | Show P/E core type labels |
---
## Category: DISPLAY — MEMORY
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `vram` | Flag | absent | Required by gpu_mem_clock, gpu_mem_temp |
| `ram` | Flag | absent | |
| `swap` | Flag | absent | |
| `procmem` | Flag | absent | Per-process resident memory |
| `procmem_shared` | Flag | absent | DEPENDS ON: `procmem` |
| `procmem_virt` | Flag | absent | DEPENDS ON: `procmem` |
| `proc_vram` | Flag | absent | Per-process VRAM |
---
## Category: DISPLAY — I/O & NETWORK
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `io_read` | Flag | absent | Per-app I/O read |
| `io_write` | Flag | absent | Per-app I/O write |
| `network` | CommaSeparatedStrings | `""` | Network interfaces e.g. `eth0,wlo1`; empty = all |
---
## Category: DISPLAY — MISC INDICATORS
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `wine` | Flag | absent | Wine/Proton version |
| `winesync` | Flag | absent | Wine sync method |
| `exec_name` | Flag | absent | Show executable name |
| `arch` | Flag | absent | MangoHud architecture |
| `gamemode` | Flag | absent | GameMode running status |
| `vkbasalt` | Flag | absent | vkBasalt running status |
| `engine_version` | Flag | absent | |
| `version` | Flag | absent | Show MangoHud version in overlay |
| `resolution` | Flag | absent | Current display resolution |
| `display_server` | Flag | absent | Wayland/X11 indicator |
| `temp_fahrenheit` | Flag | absent | Use °F instead of °C |
| `flip_efficiency` | Flag | absent | Joules per frame |
| `fex_stats` | Flag | absent | FEX-Emu stats (ARM64 only) |
---
## Category: DISPLAY — GRAPHS
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `graphs` | CommaSeparatedStrings | `""` | Valid values: `gpu_load,cpu_load,gpu_core_clock,gpu_mem_clock,vram,ram,cpu_temp,gpu_temp` |
---
## Category: DISPLAY — BATTERY
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `battery` | Flag | absent | |
| `battery_icon` | Flag | absent | DEPENDS ON: `battery` |
| `device_battery` | CommaSeparatedStrings | `""` | e.g. `gamepad,mouse` |
| `device_battery_icon` | Flag | absent | |
| `battery_watt` | Flag | absent | DEPENDS ON: `battery` |
| `battery_time` | Flag | absent | DEPENDS ON: `battery` |
---
## Category: DISPLAY — MEDIA PLAYER
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `media_player` | Flag | absent | Enable media player metadata |
| `media_player_name` | String | `""` | e.g. `spotify` — DEPENDS ON: `media_player` |
| `media_player_format` | String | `""` | e.g. `{title};{artist};{album}` — DEPENDS ON: `media_player` |
---
## Category: DISPLAY — GAMESCOPE
All options in this category: `gamescope_only = true`
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `fsr` | Flag | absent | FSR status |
| `hide_fsr_sharpness` | Flag | absent | DEPENDS ON: `fsr` |
| `hdr` | Flag | absent | HDR status |
| `refresh_rate` | Flag | absent | Current refresh rate |
| `debug` | Flag | absent | Gamescope app frametimes graph |
---
## Category: DISPLAY — STEAM DECK
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `fan` | Flag | absent | Steam Deck fan RPM |
| `mangoapp_steam` | Flag | absent | mangoapp only |
---
## Category: DISPLAY — TIME & MISC TEXT
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `time` | Flag | absent | Current time |
| `time_no_label` | Flag | absent | DEPENDS ON: `time` |
| `time_format` | String | `"%T"` | strftime format |
| `custom_text_center` | String | `""` | Centered header text |
| `custom_text` | String | `""` | Custom text line |
| `exec` | String | `""` | Shell command — output shown in next column |
---
## Category: APPEARANCE — LAYOUT
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `legacy_layout` | Bool | `0` | |
| `preset` | Int[-1..4] | `-1` | -1=default; 0=off; 1=fps only; 2=horizontal; 3=extended; 4=high detail |
| `full` | Flag | absent | Enable most toggleable params |
| `no_display` | Flag | absent | Start hidden |
| `horizontal` | Flag | absent | Horizontal layout |
| `horizontal_stretch` | Flag | absent | DEPENDS ON: `horizontal` |
| `hud_compact` | Flag | absent | Compact mode |
| `hud_no_margin` | Flag | absent | Remove margins |
| `position` | Enum[`top-left`,`top-right`,`bottom-left`,`bottom-right`,`top-center`,`middle-left`,`middle-right`,`bottom-center`] | `top-left` | |
| `offset_x` | Int[0..9999] | `0` | pixels |
| `offset_y` | Int[0..9999] | `0` | pixels |
| `width` | Int[0..9999] | `0` | 0 = auto |
| `height` | Int[0..9999] | `140` | |
| `table_columns` | Int[1..10] | `3` | |
| `cellpadding_y` | Float[-2.0..2.0] | `-0.085` | |
| `round_corners` | Int[0..50] | `0` | |
| `background_alpha` | Float[0.0..1.0] | `0.5` | |
| `alpha` | Float[0.0..1.0] | `1.0` | Overall HUD transparency |
---
## Category: APPEARANCE — COLORS
All color values: 6-char hex string RRGGBB (no `#`). Validate: must match `^[0-9A-Fa-f]{6}$`.
| Key | Default |
|-----|---------|
| `text_color` | `FFFFFF` |
| `gpu_color` | `2E9762` |
| `cpu_color` | `2E97CB` |
| `vram_color` | `AD64C1` |
| `ram_color` | `C26693` |
| `engine_color` | `EB5B5B` |
| `io_color` | `A491D3` |
| `frametime_color` | `00FF00` |
| `background_color` | `020202` |
| `media_player_color` | `FFFFFF` |
| `wine_color` | `EB5B5B` |
| `battery_color` | `FF9078` |
| `network_color` | `E07B85` |
| `horizontal_separator_color` | `AD64C1` |
Also:
| Key | Type | Default |
|-----|------|---------|
| `text_outline` | Flag | present (default on) |
| `text_outline_color` | Color | `000000` |
| `text_outline_thickness` | Float[0.5..5.0] | `1.5` |
---
## Category: APPEARANCE — TYPOGRAPHY
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `font_size` | Int[8..72] | `24` | |
| `font_scale` | Float[0.1..5.0] | `1.0` | |
| `font_size_text` | Int[8..72] | `24` | For text elements |
| `font_scale_media_player` | Float[0.1..5.0] | `0.55` | |
| `no_small_font` | Flag | absent | Disable small font for secondary info |
| `font_file` | Path | `""` | TTF/OTF path — validated to exist if set |
| `font_file_text` | Path | `""` | |
| `font_glyph_ranges` | CommaSeparatedStrings | `""` | Valid: `korean,chinese,chinese_simplified,japanese,cyrillic,thai,vietnamese,latin_ext_a,latin_ext_b` |
---
## Category: BEHAVIOR — KEYBINDINGS
All keybind values: format is `Key` or `Modifier+Key` or `Mod1+Mod2+Key`.
Valid modifiers: `Shift_L`, `Shift_R`, `Control_L`, `Control_R`, `Alt_L`, `Alt_R`, `Super_L`, `Super_R`.
| Key | Default |
|-----|---------|
| `toggle_hud` | `Shift_R+F12` |
| `toggle_hud_position` | `Shift_R+F11` |
| `toggle_preset` | `Shift_R+F10` |
| `toggle_fps_limit` | `Shift_L+F1` |
| `toggle_logging` | `Shift_L+F2` |
| `reload_cfg` | `Shift_L+F4` |
| `upload_log` | `Shift_L+F3` |
| `reset_fps_metrics` | `Shift_R+F9` |
---
## Category: BEHAVIOR — LOGGING
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `autostart_log` | Int[0..3600] | absent | Seconds before auto-start |
| `log_duration` | Int[1..86400] | absent | Seconds |
| `log_interval` | Int[0..10000] | `0` | ms; 0 = default |
| `output_folder` | Path | `""` | Must be writable directory |
| `output_file` | String | `""` | |
| `permit_upload` | Bool | `0` | Upload to flightlessmango.com |
| `benchmark_percentiles` | CommaSeparatedStrings | `97,AVG` | |
| `log_versioning` | Flag | absent | |
| `upload_logs` | Flag | absent | DEPENDS ON: `permit_upload=1` |
---
## Category: BEHAVIOR — MISC
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `blacklist` | CommaSeparatedStrings | `""` | App names to suppress overlay |
| `control` | String | `-1` | Socket name; -1=disabled; `%p` replaced with PID |
---
## Category: WORKAROUNDS — OPENGL
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `gl_size_query` | Enum[`viewport`,`scissorbox`,`disabled`] | `""` | Default: glXQueryDrawable |
| `gl_bind_framebuffer` | Int[0..999] | absent | Rebind framebuffer before draw |
| `gl_dont_flip` | Bool | absent | Don't swap origin for GL_UPPER_LEFT |
---
## Category: ADVANCED — FCAT
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `fcat` | Flag | absent | Enable FCAT overlay |
| `fcat_overlay_width` | Int[20..200] | `24` | DEPENDS ON: `fcat` |
| `fcat_screen_edge` | Int[0..3] | `0` | DEPENDS ON: `fcat` |
---
## Category: ADVANCED — FTRACE
| Key | Type | Notes |
|-----|------|-------|
| `ftrace` | String | Complex format: `type/event[+type/event2]`; validated by regex |
ftrace format regex: `^(histogram|linegraph|label)/[a-zA-Z0-9_]+(\/[a-zA-Z0-9_]+)?(\+(histogram|linegraph|label)/[a-zA-Z0-9_]+(\/[a-zA-Z0-9_]+)?)*$`
---
## Validation Rules Summary
### Cross-option dependencies (enabling B requires A to also be enabled):
```
gpu_mem_clock → requires vram
gpu_mem_temp → requires vram
hide_fsr_sharpness → requires fsr
battery_icon → requires battery
battery_watt → requires battery
battery_time → requires battery
media_player_name → requires media_player
media_player_format → requires media_player
procmem_shared → requires procmem
procmem_virt → requires procmem
upload_logs → requires permit_upload = 1
horizontal_stretch → requires horizontal
time_no_label → requires time
```
### Mutual exclusions (A and B cannot both be active):
```
fps_only ↔ (any other display param)
histogram ↔ frame_timing
```
### Vendor restrictions:
```
gpu_voltage → AMD only (warn if non-AMD GPU detected)
```
### Gamescope-only (warn if not running gamescope):
```
fsr, hide_fsr_sharpness, hdr, refresh_rate, debug
```
### Value format validations:
- All Color fields: must match `^[0-9A-Fa-f]{6}$`
- All Path fields: if non-empty, path must exist (for input files) or parent dir must be writable (for output)
- `fps_limit`: each comma-separated value must be non-negative integer
- `font_glyph_ranges`: each value must be in the valid set
- `graphs`: each value must be in the valid set
- `device_battery`: each value must be in `[gamepad, mouse, controller, headset]`
- `fps_metrics`: each value must be `AVG` or a valid decimal between 0.0 and 100.0
- `benchmark_percentiles`: same as fps_metrics
- `time_format`: must be a valid strftime format string
- `pci_dev`: must match `^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$`
- `control`: must be `-1` or a valid socket name string
- Keybind fields: must match `^(Shift_[LR]\+|Control_[LR]\+|Alt_[LR]\+|Super_[LR]\+)*(F[1-9]|F1[0-2]|[A-Z])$`
+154
View File
@@ -0,0 +1,154 @@
# Phase 01 — Project Scaffold
## Goal
Create the complete Rust project skeleton with correct Cargo.toml, build system,
directory structure, and verify that the project compiles (empty/stub implementations).
## Prerequisites
- Rust toolchain installed (rustup, cargo)
- System dependencies installed (see docs/architecture.md — System Dependencies section)
- Verify with: `pkg-config --exists gtk4 libadwaita-1 && echo "OK"`
## Steps
### 1. Create project
```bash
cargo new --bin mangotune
cd mangotune
```
### 2. Create full directory structure
```bash
mkdir -p src/{config,system,launcher,integrations,ui/{pages,widgets}}
mkdir -p data/icons
```
### 3. Write Cargo.toml
Copy the exact dependency block from `docs/architecture.md` → Cargo.toml section.
Do not add or remove any dependencies without updating docs/architecture.md.
### 4. Write build.rs
```rust
// build.rs
fn main() {
glib_build_tools::compile_schemas("data");
}
```
### 5. Create GSettings schema
File: `data/com.mangotune.MangoTune.gschema.xml`
```xml
<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
<schema id="com.mangotune.MangoTune" path="/com/mangotune/MangoTune/">
<key name="last-config-path" type="s">
<default>''</default>
<summary>Last edited config file path</summary>
</key>
<key name="window-width" type="i">
<default>1200</default>
<summary>Window width</summary>
</key>
<key name="window-height" type="i">
<default>780</default>
<summary>Window height</summary>
</key>
<key name="active-page" type="s">
<default>'performance'</default>
<summary>Currently active sidebar page</summary>
</key>
<key name="show-raw-editor" type="b">
<default>false</default>
<summary>Whether raw editor tab is visible</summary>
</key>
</schema>
</schemalist>
```
### 6. Create .desktop file
File: `data/com.mangotune.MangoTune.desktop`
```ini
[Desktop Entry]
Name=MangoTune
Comment=MangoHud Overlay Configurator
Exec=mangotune
Icon=com.mangotune.MangoTune
Terminal=false
Type=Application
Categories=Settings;System;
Keywords=MangoHud;overlay;gaming;performance;
StartupNotify=true
StartupWMClass=mangotune
```
### 7. Create all source stub files
Each file listed in docs/architecture.md → Target Source Tree must be created
as a stub with correct module declarations and a `todo!()` placeholder where needed.
Required stub content for each module file:
```rust
// src/config/parser.rs
//! MangoHud config file parser and writer.
//! See: mangotune-plan/modules/config_parser.md for full spec.
pub struct Parser;
impl Parser {
pub fn new() -> Self { todo!() }
}
```
### 8. Wire up main.rs
```rust
// src/main.rs
mod app;
mod config;
mod system;
mod launcher;
mod integrations;
mod ui;
fn main() {
tracing_subscriber::fmt::init();
let app = app::MangoTuneApp::new();
std::process::exit(app.run());
}
```
### 9. Wire up app.rs stub
```rust
// src/app.rs
use gtk4::prelude::*;
use libadwaita::prelude::*;
pub struct MangoTuneApp {
app: libadwaita::Application,
}
impl MangoTuneApp {
pub fn new() -> Self {
let app = libadwaita::Application::builder()
.application_id("com.mangotune.MangoTune")
.build();
MangoTuneApp { app }
}
pub fn run(&self) -> i32 {
self.app.run().into()
}
}
```
### 10. Verify compilation
```bash
cargo check 2>&1
```
Must produce zero errors (warnings acceptable at this stage).
## Acceptance Criteria
- [ ] `cargo check` exits with code 0
- [ ] All directories from the target source tree exist
- [ ] All source stub files exist with correct module declarations
- [ ] `data/` contains gschema.xml and .desktop file
- [ ] `build.rs` compiles the schema without error
- [ ] No `.unwrap()` calls in non-stub code (stubs with `todo!()` are exempt)
+290
View File
@@ -0,0 +1,290 @@
# Phase 02 — Config Parser, Schema & Validator
## Goal
Implement the complete non-UI config stack: parser, typed schema, and validation engine.
This phase has NO GTK4 code. All modules are pure Rust with full unit tests.
## Files to implement (fully, not stubs)
- `src/config/types.rs`
- `src/config/parser.rs`
- `src/config/schema.rs`
- `src/config/validator.rs`
- `src/config/mod.rs`
---
## src/config/types.rs
Define all shared types used across the config subsystem.
```rust
use std::path::PathBuf;
/// A single line from a MangoHud config file, preserving its original text.
pub enum ConfigLine {
Comment(String), // lines starting with #
Blank, // empty lines
Option { key: String, value: Option<String>, raw: String },
CommentedOption { key: String, value: Option<String>, raw: String },
}
/// The current state of an option in the in-memory config.
#[derive(Debug, Clone, PartialEq)]
pub enum ConfigValue {
Absent, // not present in file; use MangoHud compiled default
Flag, // bare key with no value (presence = enabled)
Value(String), // key=value
Disabled, // was commented out explicitly
}
/// Type system for schema entries.
#[derive(Debug, Clone)]
pub enum OptionType {
Flag,
Bool,
Int { min: i64, max: i64 },
Float { min: f64, max: f64 },
Str { max_len: usize },
Color,
Enum { variants: Vec<String> },
FpsLimitList,
KeyBind,
CommaSepInts,
CommaSepFloats,
CommaSepStrings { valid_values: Option<Vec<String>> },
Path { must_exist: bool, must_be_writable: bool },
}
#[derive(Debug, Clone, PartialEq)]
pub enum GpuVendor { Any, AmdOnly, NvidiaOnly, IntelOnly }
#[derive(Debug, Clone, PartialEq)]
pub enum Category {
Performance,
DisplayFps,
DisplayGpu,
DisplayCpu,
DisplayMemory,
DisplayIoNetwork,
DisplayMisc,
DisplayGraphs,
DisplayBattery,
DisplayMediaPlayer,
DisplayGamescope,
DisplaySteamDeck,
DisplayTimeText,
AppearanceLayout,
AppearanceColors,
AppearanceTypography,
BehaviorKeybindings,
BehaviorFpsLimits,
BehaviorLogging,
BehaviorMisc,
WorkaroundsOpengl,
AdvancedFcat,
AdvancedFtrace,
}
/// A single schema entry — defines everything about one MangoHud option.
#[derive(Debug, Clone)]
pub struct SchemaEntry {
pub key: &'static str,
pub option_type: OptionType,
pub description: &'static str,
pub category: Category,
pub dependencies: &'static [&'static str],
pub conflicts_with: &'static [&'static str],
pub gpu_vendor_only: GpuVendor,
pub gamescope_only: bool,
}
/// Validation result for a single option.
#[derive(Debug, Clone, PartialEq)]
pub enum ValidationResult {
Ok,
Warning(String), // save is allowed but issue shown
Error(String), // save is BLOCKED
}
/// The full in-memory representation of a parsed config file.
pub struct AnnotatedConfig {
/// Ordered list of lines as they appear in the file.
/// Used for writing back to disk (preserves comments/order).
pub lines: Vec<ConfigLine>,
/// Fast lookup map: key → (line_index, current_value).
pub options: indexmap::IndexMap<String, (usize, ConfigValue)>,
/// Source path, if backed by a file.
pub path: Option<PathBuf>,
/// Whether this config has unsaved in-memory changes.
pub dirty: bool,
}
```
---
## src/config/parser.rs
Rules to implement (from docs/architecture.md → Config File Format Notes):
1. Lines starting with `#``ConfigLine::Comment` (preserve verbatim)
2. Empty/whitespace-only lines → `ConfigLine::Blank`
3. `key=value``ConfigLine::Option { key, value: Some(value) }`
4. `key` alone → `ConfigLine::Option { key, value: None }` (flag)
5. `# key``ConfigLine::CommentedOption { key, value: None }`
6. `# key=value``ConfigLine::CommentedOption { key, value: Some(value) }`
7. On duplicate keys: last occurrence wins (update options map accordingly)
8. All parsing: UTF-8, trim trailing whitespace from values
### Public API
```rust
impl Parser {
/// Parse a config file from disk.
pub fn read(path: &Path) -> anyhow::Result<AnnotatedConfig>
/// Parse config from a string (for env var inline and tests).
pub fn parse_str(content: &str, path: Option<PathBuf>) -> AnnotatedConfig
/// Write an AnnotatedConfig back to disk.
/// - Creates backup at {path}.mangotune.bak
/// - Writes to {path}.mangotune.tmp
/// - Atomically renames to {path}
/// - Returns Err if any step fails (restores from backup on failure)
pub fn write(config: &AnnotatedConfig) -> anyhow::Result<()>
/// Update a specific key's value in the config lines.
/// If key exists: update that line in-place.
/// If key doesn't exist: append to end of file.
/// If setting to Absent/Disabled: comment out the line.
pub fn set_value(config: &mut AnnotatedConfig, key: &str, value: ConfigValue)
/// Serialize config to a string (for preview or clipboard copy).
pub fn to_string(config: &AnnotatedConfig) -> String
}
```
### Tests required (in src/config/parser.rs at bottom, `#[cfg(test)]`)
- Parse a file with all line types (comment, blank, key=value, bare key, commented option)
- Round-trip: parse → write → parse → values must match
- set_value: update existing key preserves surrounding lines
- set_value: add new key appends at end
- set_value: disable key comments it out
- Duplicate key: last value wins
- UTF-8 edge cases: file with non-ASCII comments
---
## src/config/schema.rs
Implement `MANGOHUD_SCHEMA: &[SchemaEntry]` — a static slice containing one entry
per option documented in `docs/mangohud_schema.md`.
Every single option in that document must have a corresponding entry here.
Count: approximately 120 entries.
```rust
use once_cell::sync::Lazy;
pub static MANGOHUD_SCHEMA: Lazy<Vec<SchemaEntry>> = Lazy::new(|| vec![
SchemaEntry {
key: "fps",
option_type: OptionType::Flag,
description: "Show FPS counter (enabled by default)",
category: Category::DisplayFps,
dependencies: &[],
conflicts_with: &["fps_only"],
gpu_vendor_only: GpuVendor::Any,
gamescope_only: false,
},
// ... all ~120 entries
]);
/// Fast lookup by key.
pub fn get_schema_entry(key: &str) -> Option<&'static SchemaEntry>
```
Also implement:
```rust
/// Return all schema entries for a given category.
pub fn entries_for_category(category: &Category) -> Vec<&'static SchemaEntry>
/// Return all dependency keys for a given key (recursive).
pub fn all_dependencies(key: &str) -> Vec<&'static str>
```
---
## src/config/validator.rs
```rust
/// Validate a single value against its schema entry.
/// Returns Ok, Warning, or Error.
pub fn validate_value(
key: &str,
value: &ConfigValue,
schema: &SchemaEntry,
) -> ValidationResult
/// Validate an entire config against the full schema.
/// Returns a map of key → ValidationResult for every key that has an issue.
/// Keys with ValidationResult::Ok are NOT included (only problems).
pub fn validate_all(
config: &AnnotatedConfig,
) -> HashMap<String, ValidationResult>
/// Check dependency satisfaction.
/// Returns list of (dependent_key, missing_required_key) pairs.
pub fn check_dependencies(config: &AnnotatedConfig) -> Vec<(String, String)>
/// Check for mutual exclusions.
/// Returns list of (key_a, key_b) pairs where both are active but they conflict.
pub fn check_conflicts(config: &AnnotatedConfig) -> Vec<(String, String)>
/// True if config is fully valid (no Errors). Warnings are allowed.
pub fn is_saveable(config: &AnnotatedConfig) -> bool
```
### Validation logic per type (implement all):
- `Flag`: always valid if present; no value to validate
- `Bool`: value must be "0" or "1"
- `Int { min, max }`: must parse as i64, must be in [min, max]
- `Float { min, max }`: must parse as f64, must be in [min, max]
- `Str { max_len }`: must be ≤ max_len bytes
- `Color`: must match regex `^[0-9A-Fa-f]{6}$`
- `Enum { variants }`: value must be in variants list (case-sensitive)
- `FpsLimitList`: comma-separated, each part must be non-negative integer
- `KeyBind`: must match the keybind regex from mangohud_schema.md
- `CommaSepInts`: each comma-part must parse as integer
- `CommaSepFloats`: each comma-part must parse as f64
- `CommaSepStrings { valid_values: Some(_) }`: each part must be in valid set
- `CommaSepStrings { valid_values: None }`: any non-empty string OK
- `Path { must_exist, must_be_writable }`: validate with std::fs
### Tests required
- Each validation type: valid input, invalid input, boundary values
- Dependency check: config with missing dependency detected
- Conflict check: fps_only + fps = conflict detected
- is_saveable: returns false if any Error present, true if only Warnings
---
## src/config/mod.rs
```rust
pub mod types;
pub mod parser;
pub mod schema;
pub mod validator;
pub mod resolver; // stub only in this phase
pub use types::*;
```
---
## Acceptance Criteria
- [ ] `cargo test --lib` passes all tests with 0 failures
- [ ] Schema contains entries for ALL ~120 options from docs/mangohud_schema.md
- [ ] Parser round-trips without losing comments or blank lines
- [ ] Validator blocks saves on invalid Color, out-of-range Int, unknown Enum value
- [ ] Dependency checker catches gpu_mem_clock without vram
- [ ] Conflict checker catches fps_only with any other display param
- [ ] No `.unwrap()` in production code paths
- [ ] No `todo!()` remaining in any of the 4 implemented files
+234
View File
@@ -0,0 +1,234 @@
# Phase 03 — Config Resolver & System Detection
## Goal
Implement the config file discovery/priority system and system detection module.
No GTK4 code. All modules are pure Rust with unit tests.
## Files to implement
- `src/config/resolver.rs`
- `src/system/detect.rs`
- `src/system/paths.rs`
- `src/system/mod.rs`
---
## src/system/paths.rs
```rust
use std::path::PathBuf;
use xdg::BaseDirectories;
pub struct XdgPaths {
pub config_home: PathBuf, // $XDG_CONFIG_HOME or ~/.config
pub mangohud_dir: PathBuf, // {config_home}/MangoHud/
pub global_config: PathBuf, // {mangohud_dir}/MangoHud.conf
pub data_home: PathBuf,
}
impl XdgPaths {
pub fn resolve() -> anyhow::Result<Self>
}
/// Return all possible Steam root directories (native + Flatpak variants).
pub fn steam_roots() -> Vec<PathBuf>
/// Return all possible Heroic config directories.
pub fn heroic_config_dirs() -> Vec<PathBuf>
/// Return all possible Lutris config directories.
pub fn lutris_config_dirs() -> Vec<PathBuf>
/// Expand ~ in paths (since std::fs doesn't do this).
pub fn expand_tilde(path: &str) -> PathBuf
```
---
## src/system/detect.rs
Run at application startup. Returns a `SystemInfo` struct that is passed to the
rest of the app and refreshed on demand.
```rust
#[derive(Debug, Clone)]
pub struct SystemInfo {
pub mangohud: MangoHudInfo,
pub gpu: GpuInfo,
pub display_server: DisplayServer,
pub tools: AvailableTools,
pub integrations: IntegrationAvailability,
}
#[derive(Debug, Clone)]
pub struct MangoHudInfo {
pub installed: bool,
pub version: Option<String>, // parsed from `mangohud --version`
pub lib_path: Option<PathBuf>, // path to libMangoHud.so
pub flatpak: bool,
}
#[derive(Debug, Clone)]
pub struct GpuInfo {
pub vendor: GpuVendor,
pub name: Option<String>,
pub pci_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DisplayServer {
Wayland,
X11,
XwaylandUnderWayland,
Unknown,
}
#[derive(Debug, Clone)]
pub struct AvailableTools {
pub vkcube: Option<PathBuf>,
pub glxgears: Option<PathBuf>,
pub gamemodectl: Option<PathBuf>,
pub gamemoded: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct IntegrationAvailability {
pub steam: bool,
pub steam_flatpak: bool,
pub lutris: bool,
pub lutris_flatpak: bool,
pub heroic: bool,
pub heroic_flatpak: bool,
pub gamemode: bool,
}
/// Run all detection. Runs in a tokio task, sends result back via channel.
pub async fn detect_system() -> anyhow::Result<SystemInfo>
```
### Detection implementation details:
**MangoHud detection:**
1. Try `which mangohud` → path
2. Run `mangohud --version 2>&1` → parse version string
3. Check library existence at common paths:
- `/usr/lib/x86_64-linux-gnu/mangohud/libMangoHud.so`
- `/usr/lib/mangohud/libMangoHud.so`
- `/usr/local/lib/mangohud/libMangoHud.so`
- `~/.local/lib/mangohud/libMangoHud.so`
4. Check Flatpak: `flatpak list 2>/dev/null | grep -i mangohud`
**GPU detection:**
1. Read `/sys/class/drm/card*/device/vendor``0x1002`=AMD, `0x10de`=NVIDIA, `0x8086`=Intel
2. Read `/sys/class/drm/card*/device/device` for device ID
3. Cross-reference with `lspci -nn 2>/dev/null` for name
**Display server detection:**
1. Check `$WAYLAND_DISPLAY` — if set → Wayland
2. Check `$DISPLAY` — if set → X11 (or XwaylandUnderWayland if also Wayland)
3. Check `$XDG_SESSION_TYPE`
**Tool detection:** `which` for each tool via `std::process::Command`
---
## src/config/resolver.rs
Full implementation of the config layer discovery system.
See `docs/config_resolution.md` for the complete algorithm.
```rust
use crate::config::types::*;
use crate::system::paths::XdgPaths;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct ConfigLayer {
pub path: Option<PathBuf>,
pub source_type: LayerSource,
pub priority: u8,
pub exists: bool,
pub is_editable: bool,
pub config: Option<AnnotatedConfig>,
}
#[derive(Debug, Clone)]
pub enum LayerSource {
CompiledDefault,
GlobalXdg,
PerAppXdg(String),
AppLocal(PathBuf),
EnvFile(PathBuf),
EnvInline(String),
}
/// A conflict: one option set in multiple layers.
#[derive(Debug, Clone)]
pub struct ConfigConflict {
pub key: String,
pub winning_layer_priority: u8,
pub winning_value: ConfigValue,
pub shadowed: Vec<(u8, ConfigValue)>, // (priority, value) of shadowed layers
}
pub struct Resolver;
impl Resolver {
/// Discover all config layers. Reads env vars and filesystem.
pub async fn discover(xdg: &XdgPaths) -> anyhow::Result<Vec<ConfigLayer>>
/// Build conflict map from a resolved layer stack.
/// Returns only options that appear in more than one layer with different values.
pub fn find_conflicts(layers: &[ConfigLayer]) -> Vec<ConfigConflict>
/// Return the effective value for a key (highest priority layer that sets it).
pub fn effective_value(key: &str, layers: &[ConfigLayer]) -> ConfigValue
/// Return a display label for a layer source.
pub fn layer_label(source: &LayerSource) -> String
/// Create a new per-app config at the XDG path.
pub fn create_per_app_config(app_name: &str, xdg: &XdgPaths) -> anyhow::Result<PathBuf>
/// Scan common game directories for app-local MangoHud.conf files.
async fn scan_game_dirs() -> Vec<PathBuf>
}
```
### Discovery order (implement exactly as in docs/config_resolution.md):
1. `$MANGOHUD_CONFIGFILE` → EnvFile (priority 5)
2. `$MANGOHUD_CONFIG` → EnvInline (priority 5, wins over EnvFile)
3. `{xdg}/MangoHud/MangoHud.conf` → GlobalXdg (priority 2)
4. `{xdg}/MangoHud/*.conf` (excluding MangoHud.conf) → PerAppXdg (priority 3)
5. Steam/game dirs scan → AppLocal (priority 4)
### Tests required
- discover: correctly reads $XDG_CONFIG_HOME override
- discover: env var $MANGOHUD_CONFIG parsed as inline layer at priority 5
- find_conflicts: detects option set in both global and per-app configs
- effective_value: returns env value when set, file value otherwise
- create_per_app_config: creates file with correct header comment
---
## src/system/mod.rs
```rust
pub mod detect;
pub mod paths;
pub use detect::{SystemInfo, MangoHudInfo, GpuInfo, DisplayServer, AvailableTools};
pub use paths::XdgPaths;
```
---
## Acceptance Criteria
- [ ] `cargo test --lib` passes all new tests
- [ ] Resolver correctly discovers layers in priority order
- [ ] Env var layers detected with correct priority (5 = highest)
- [ ] Conflict detection identifies options shadowed by higher-priority layers
- [ ] System detection identifies GPU vendor from `/sys/class/drm`
- [ ] MangoHud version extracted from `--version` output
- [ ] Display server (Wayland/X11) correctly detected from environment
- [ ] All paths resolve via XDG (no hardcoded `/home/username`)
- [ ] No `.unwrap()` in production code
+268
View File
@@ -0,0 +1,268 @@
# Phase 04 — GTK4 App Skeleton & Main Window
## Goal
Build the complete application shell: window, header bar, config selector bar,
sidebar navigation, and empty page placeholders. All navigation must work.
No option editing yet — pages show "Coming soon" content.
## Files to implement
- `src/app.rs` (replace stub)
- `src/window.rs` (replace stub)
- `src/ui/mod.rs`
- `src/ui/pages/mod.rs`
- `src/ui/widgets/mod.rs`
- `data/style.css`
---
## src/app.rs
```rust
use gtk4::prelude::*;
use libadwaita::prelude::*;
use crate::window::MainWindow;
use crate::system::detect;
pub struct MangoTuneApp {
app: libadwaita::Application,
}
impl MangoTuneApp {
pub fn new() -> Self {
let app = libadwaita::Application::builder()
.application_id("com.mangotune.MangoTune")
.flags(gio::ApplicationFlags::FLAGS_NONE)
.build();
let app_clone = app.clone();
app.connect_activate(move |_| {
// Load CSS
let provider = gtk4::CssProvider::new();
provider.load_from_data(include_str!("../data/style.css"));
gtk4::style_context_add_provider_for_display(
&gdk::Display::default().expect("No display"),
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
// Run system detection async, then build window
let ctx = glib::MainContext::default();
ctx.spawn_local(async move {
let system_info = detect::detect_system().await
.unwrap_or_else(|_| detect::SystemInfo::unknown());
let window = MainWindow::new(&app_clone, system_info);
window.present();
});
});
MangoTuneApp { app }
}
pub fn run(&self) -> i32 {
self.app.run().into()
}
}
```
---
## src/window.rs
```rust
use gtk4::prelude::*;
use libadwaita::prelude::*;
use crate::system::detect::SystemInfo;
use crate::ui::pages;
pub struct MainWindow {
pub window: libadwaita::ApplicationWindow,
}
impl MainWindow {
pub fn new(app: &libadwaita::Application, system_info: SystemInfo) -> Self {
let window = libadwaita::ApplicationWindow::builder()
.application(app)
.title("MangoTune")
.default_width(1200)
.default_height(780)
.build();
// Restore window size from GSettings
// Build layout:
// AdwToolbarView
// top: AdwHeaderBar
// top: ConfigBarWidget (custom)
// content: AdwOverlaySplitView
// sidebar: navigation list
// content: AdwNavigationView (page stack)
let toolbar_view = libadwaita::ToolbarView::new();
let header = build_header_bar();
let config_bar = build_config_bar(&system_info);
let split_view = build_split_view(&system_info);
toolbar_view.add_top_bar(&header);
toolbar_view.add_top_bar(&config_bar);
toolbar_view.set_content(Some(&split_view));
window.set_content(Some(&toolbar_view));
MainWindow { window }
}
pub fn present(&self) { self.window.present(); }
}
```
### Header Bar implementation
```
AdwHeaderBar
title-widget: AdwWindowTitle { title: "MangoTune", subtitle: "No config loaded" }
start: AdwSplitButton "Save" (insensitive by default)
dropdown items: "Save As…", "Revert to Saved", "Create Backup"
end: GtkMenuButton (gear icon)
popover menu: "Preferences", "Keyboard Shortcuts", "About MangoTune"
```
### Config Bar implementation
Custom `GtkBox` with `@card_bg_color` background:
```
GtkBox (horizontal, spacing=8, margin=6)
GtkImage (config type icon — globe/per-app/warning)
GtkLabel "Editing:"
GtkDropDown ← lists all discovered config layers
model: StringList populated from resolver
GtkLabel "⚠ 2 conflicts" (hidden if no conflicts)
GtkButton "View All Layers" → navigate to conflicts page
```
### Split View implementation
```
AdwOverlaySplitView
sidebar-width-fraction: 0.22
min-sidebar-width: 180
max-sidebar-width: 260
show-sidebar: true
collapsed at width < 980
sidebar: NavigationSidebar (GtkListBox with .navigation-sidebar CSS class)
content: AdwNavigationView ← all pages pushed here
```
### Navigation Sidebar
The sidebar is a `GtkListBox` with rows grouped by sections.
See docs/design_system.md → Sidebar Navigation for the full list of sections and items.
Each row stores the page ID as data. On row activation:
- Call `navigation_view.push_by_tag(page_id)`
- Highlight the active row
Section headers: `GtkLabel` with `.heading` CSS class, not selectable.
### Page Stubs (all pages in this phase return placeholder content)
Create all page files listed in `src/ui/pages/` with a stub that returns:
```rust
pub fn build_page() -> libadwaita::PreferencesPage {
let page = libadwaita::PreferencesPage::new();
page.set_title("Page Name");
let group = libadwaita::PreferencesGroup::new();
group.set_title("Coming Soon");
group.set_description(Some("This page will be implemented in a future phase."));
page.add(&group);
page
}
```
### About Dialog
`AdwAboutDialog` with:
```
application-name: "MangoTune"
application-icon: "com.mangotune.MangoTune"
version: env!("CARGO_PKG_VERSION")
comments: "A modern, accurate MangoHud configurator for Linux"
license-type: Gtk::License::Gpl30
website: "https://github.com/your-org/mangotune"
issue-url: "https://github.com/your-org/mangotune/issues"
developers: ["MangoTune Contributors"]
```
---
## data/style.css
Only custom styles that libadwaita/GTK4 don't provide natively.
```css
/* Config layer priority badges */
.layer-badge-env {
background-color: @destructive_color;
color: @destructive_fg_color;
border-radius: 4px;
padding: 2px 6px;
font-size: 0.75em;
font-weight: bold;
}
.layer-badge-perapp {
background-color: @warning_color;
color: @warning_fg_color;
border-radius: 4px;
padding: 2px 6px;
font-size: 0.75em;
font-weight: bold;
}
.layer-badge-global {
background-color: @success_color;
color: @success_fg_color;
border-radius: 4px;
padding: 2px 6px;
font-size: 0.75em;
font-weight: bold;
}
/* Shadowed option text in cascade view */
.option-shadowed {
text-decoration: line-through;
opacity: 0.5;
}
/* Color swatch button */
.color-swatch-button {
min-width: 28px;
min-height: 28px;
border-radius: 4px;
border: 1px solid @borders;
padding: 0;
}
/* Config bar */
.config-bar {
background-color: @card_bg_color;
border-bottom: 1px solid @borders;
padding: 6px 12px;
}
/* Conflict count badge */
.conflict-badge {
background-color: @destructive_color;
color: @destructive_fg_color;
border-radius: 8px;
padding: 1px 6px;
font-size: 0.75em;
font-weight: bold;
}
```
---
## Acceptance Criteria
- [ ] App launches without crashing
- [ ] Window appears at 1200×780
- [ ] Header bar shows "MangoTune" title and (insensitive) Save button
- [ ] Config bar renders below header
- [ ] Sidebar shows all navigation sections with correct icons
- [ ] Clicking each sidebar item navigates to its placeholder page
- [ ] About dialog opens from gear menu
- [ ] System detection runs on startup (check tracing log output)
- [ ] App responds to window resize (sidebar collapses at narrow width)
- [ ] No GTK warnings or critical messages in stderr on launch
+628
View File
@@ -0,0 +1,628 @@
# Phases 0511 — Implementation Phases
---
# Phase 05 — Core Config Pages: Performance, GPU, CPU, Memory
## Goal
Implement the four most-used config pages with full working controls,
inline validation, and dependency handling. The Save button becomes functional.
## Files to implement (replace stubs)
- `src/ui/pages/performance.rs`
- `src/ui/pages/gpu.rs`
- `src/ui/pages/cpu.rs`
- `src/ui/pages/memory.rs`
- `src/ui/widgets/toggle_row.rs` ← shared AdwSwitchRow wrapper
- `src/ui/widgets/validation_label.rs` ← inline error display
## Application State Architecture
Before implementing pages, establish the central app state that all pages share.
Add to `src/window.rs`:
```rust
use std::sync::{Arc, Mutex};
use crate::config::types::AnnotatedConfig;
use crate::config::validator;
/// Shared mutable application state, passed via Arc<Mutex<>> to all pages.
pub struct AppState {
pub config: AnnotatedConfig,
pub validation: HashMap<String, ValidationResult>,
pub dirty: bool,
}
```
Use `glib::MainContext::channel()` or `Arc<Mutex<AppState>>` + GObject signals
to communicate changes from widget callbacks to the save button sensitivity.
## Page Implementation Pattern
Every page follows this exact pattern:
```rust
// src/ui/pages/gpu.rs
pub fn build_page(state: Arc<Mutex<AppState>>) -> libadwaita::PreferencesPage {
let page = libadwaita::PreferencesPage::new();
page.set_title("GPU");
page.set_icon_name(Some("computer-symbolic"));
// ── GROUP: GPU Statistics ──────────────────────────────────────
let group = libadwaita::PreferencesGroup::new();
group.set_title("GPU Statistics");
group.set_description(Some("Core GPU monitoring display options"));
// gpu_stats (master toggle)
let gpu_stats_row = build_switch_row(
"GPU Statistics",
"gpu_stats — master GPU section toggle",
"gpu_stats",
&state,
);
group.add(&gpu_stats_row);
// gpu_temp
let gpu_temp_row = build_switch_row("Temperature", "gpu_temp", "gpu_temp", &state);
group.add(&gpu_temp_row);
// ... etc for every option in Category::DisplayGpu
page.add(&group);
page
}
```
## Widget Helpers to implement in this phase
### `build_switch_row(title, subtitle, key, state)` → AdwSwitchRow
1. Read current value from `state.config`
2. Set initial active state
3. Connect `notify::active`:
a. Update `state.config` via `parser::set_value`
b. Run `validator::validate_value(key, value, schema_entry)`
c. If dependency triggered: show `AdwAlertDialog` "Enable {dep} too?"
d. If conflict triggered: show warning toast
e. Update save button sensitivity
### `build_spin_row(title, subtitle, key, min, max, state)` → AdwSpinRow
1. Read current value, set initial
2. Connect `notify::value`:
a. Update state
b. Validate
c. Show/hide error on the row (add/remove `.error` CSS class)
d. Update save button
### `build_combo_row(title, subtitle, key, variants, state)` → AdwComboRow
### `build_entry_row(title, subtitle, key, state)` → AdwEntryRow
### Validation error display on rows
```rust
fn set_row_error(row: &impl IsA<gtk4::Widget>, error: Option<&str>) {
if let Some(msg) = error {
row.add_css_class("error");
// Update row subtitle to show error message
} else {
row.remove_css_class("error");
// Restore original subtitle
}
}
```
## Save Button Wiring
In `window.rs`, connect save button:
1. Run `validator::validate_all(&state.config)` — full pass
2. If any Error: show `AdwToast` "Fix N errors before saving", abort
3. If all Ok: call `parser::write(&state.config)`
4. On success: show `AdwToast` "Config saved", set `state.dirty = false`,
make save button insensitive
## GPU-specific notes
- `gpu_mem_clock` and `gpu_mem_temp`: when enabled, check if `vram` is also active.
If not, show `AdwAlertDialog`: "GPU Memory Clock requires VRAM display to be enabled.
Enable VRAM now?" → buttons: "Enable Both" / "Cancel".
- `gpu_voltage`: if GPU vendor != AMD, add an `AdwBanner` warning at top of group:
"gpu_voltage is only available on AMD GPUs. This option will have no effect."
- Color load thresholds (gpu_load_change, gpu_load_value, gpu_load_color):
use an `AdwExpanderRow` that expands to show threshold + color sub-rows.
## Acceptance Criteria
- [ ] All four pages render with correct controls for every option in their category
- [ ] Toggle switches update in-memory state immediately
- [ ] Invalid values (e.g. font_size=999) show inline error and block save
- [ ] Save button is insensitive when no changes or when errors exist
- [ ] Save writes correct .conf format (key=value or bare key)
- [ ] Comments and blank lines preserved after save
- [ ] Dependency dialog appears when enabling gpu_mem_clock without vram
- [ ] Vendor warning shows for gpu_voltage on non-AMD systems
---
# Phase 06 — Appearance, Colors, Typography, Layout Pages
## Files to implement
- `src/ui/pages/appearance.rs`
- `src/ui/pages/colors.rs`
- `src/ui/pages/typography.rs`
- `src/ui/widgets/color_row.rs`
## Color Row Widget (color_row.rs)
The color row is a custom widget used for all color options.
```
AdwActionRow {
title: "GPU Color"
subtitle: "gpu_color — hex RRGGBB (no #)"
[suffix] GtkButton (color swatch) ← shows current color as background
[suffix] GtkEntry (6-char hex) ← manual entry
}
```
Color swatch button click → opens `AdwDialog`:
```
AdwDialog "Choose Color"
GtkColorDialogButton ← native GTK4 color chooser
GtkEntry showing hex ← synced with the color chooser
[footer] GtkButton "Reset to Default"
[footer] GtkButton "Cancel"
[footer] GtkButton "Apply" (suggested-action)
```
Validation: hex entry must match `^[0-9A-Fa-f]{6}$`. Show error inline.
On Apply: update color swatch background, update state, validate.
## Colors Page
One `AdwPreferencesGroup` per logical color section:
- "Text & Background" (text_color, background_color, text_outline*)
- "GPU" (gpu_color, gpu_load_color)
- "CPU" (cpu_color, cpu_load_color)
- "Memory" (vram_color, ram_color)
- "Other Components" (engine_color, io_color, frametime_color, etc.)
- "Media & Battery" (media_player_color, battery_color, wine_color, network_color)
At top of page: `AdwBanner` with "Tip: Colors are 6-digit hex without #. Example: FF0000 for red."
## Typography Page
- font_size: AdwSpinRow (872)
- font_scale: AdwSpinRow (0.15.0, 2 decimal digits)
- font_size_text: AdwSpinRow
- font_scale_media_player: AdwSpinRow
- no_small_font: AdwSwitchRow
- font_file: AdwEntryRow + "Browse…" button → GtkFileDialog
- font_file_text: same
- font_glyph_ranges: AdwExpanderRow with checkboxes for each valid range
## Layout & Position Page
- position: AdwComboRow with visual position preview (simple ASCII art grid in subtitle)
- offset_x, offset_y: AdwSpinRow
- horizontal: AdwSwitchRow (enabling it disables position since horizontal has its own placement)
- horizontal_stretch: AdwSwitchRow (depends on horizontal)
- hud_compact: AdwSwitchRow
- hud_no_margin: AdwSwitchRow
- background_alpha: AdwSpinRow (0.01.0) + live preview strip showing the alpha
- alpha: AdwSpinRow
- width, height: AdwSpinRow
- table_columns: AdwSpinRow (110)
- cellpadding_y: AdwSpinRow (-2.02.0)
- round_corners: AdwSpinRow (050)
- preset: AdwComboRow with descriptions for each preset value
## Acceptance Criteria
- [ ] All color rows show correct color swatches
- [ ] Invalid hex values blocked with inline error
- [ ] File browser for font_file filters to .ttf/.otf
- [ ] font_file path validated to exist on disk
- [ ] horizontal_stretch disables when horizontal is off
- [ ] All layout values saved and round-trip correctly
---
# Phase 07 — Config Conflict Cascade View Page
## Files to implement
- `src/ui/pages/conflicts.rs`
- `src/ui/widgets/cascade_view.rs`
## This is the most visually distinctive page in the app.
### Page Layout
```
AdwPreferencesPage "Layer Conflicts"
[top] GtkSearchBar + filter buttons: "All" / "Conflicts Only" / "Shadowed Only"
[for each layer, ordered highest priority first]:
AdwPreferencesGroup
title: "{layer_label}" e.g. "ENV: $MANGOHUD_CONFIG" or "~/.config/MangoHud/cs2.conf"
header-suffix: GtkBox containing:
- GtkLabel badge (ENV / PER-APP / GLOBAL / APP-LOCAL) with CSS class
- GtkButton "Edit" (hidden for env layers)
- GtkButton "Open Folder" (for file layers)
- GtkButton "Delete Config" (destructive, requires AdwAlertDialog confirm)
[for each option in this layer]:
AdwActionRow
title: option key (monospace font)
subtitle: option value
[if shadowed by higher layer]:
title gets .option-shadowed CSS class (strikethrough)
suffix: GtkLabel "overridden by {higher_layer_name}" with .dim-label
[if this layer wins over lower layers]:
title: bold
suffix: GtkImage "checkmark" icon
[bottom if no conflicts detected]:
AdwStatusPage
icon-name: "emblem-ok-symbolic"
title: "No Conflicts"
description: "All config layers are consistent."
```
### Filter Logic
- "All": show every layer with all their options
- "Conflicts Only": show only layers that contain conflicting options (hidden = no conflicts)
- "Shadowed Only": show only shadowed options across all layers
### Empty State (no layers found)
```
AdwStatusPage
icon-name: "document-open-symbolic"
title: "No Config Files Found"
description: "MangoHud will use compiled defaults.\nCreate a config file to get started."
child: GtkButton "Create Global Config" (suggested-action)
```
### Clicking a key in any editable layer
Navigate to the relevant config page with that option highlighted (scroll to it).
Implement by passing a "highlight_key" parameter to page build functions.
The targeted option's row briefly flashes with a CSS animation.
## Acceptance Criteria
- [ ] All layers shown in correct priority order (highest at top)
- [ ] ENV layers show as non-editable
- [ ] Shadowed options have strikethrough text and "overridden by X" label
- [ ] Winning options are visually distinct (bold)
- [ ] Filter buttons correctly show/hide options
- [ ] Delete config prompts for confirmation
- [ ] Clicking a key in an editable layer navigates to its editor page
---
# Phase 08 — Keybindings, I/O, Network, Media, Battery, Logging, Misc Pages
## Files to implement
- `src/ui/pages/keybindings.rs`
- `src/ui/pages/io_network.rs`
- `src/ui/pages/media_player.rs`
- `src/ui/pages/battery.rs`
- `src/ui/pages/fps_limits.rs`
- `src/ui/pages/logging.rs`
- `src/ui/pages/blacklist.rs`
- `src/ui/pages/opengl_quirks.rs`
- `src/ui/pages/raw_editor.rs`
- `src/ui/widgets/hotkey_row.rs`
## Hotkey Row Widget
Custom widget for capturing keybindings.
```
AdwActionRow
title: "Toggle HUD"
subtitle: "toggle_hud"
[suffix] GtkShortcutLabel ← displays current binding e.g. "⇧R + F12"
[suffix] GtkButton "Edit" → opens capture dialog
[suffix] GtkButton "✕" → clears binding (sets to empty = use default)
```
Capture dialog:
```
AdwDialog "Capture Keybind"
AdwStatusPage
icon-name: "input-keyboard-symbolic"
title: "Press a key combination"
description: "Hold modifier keys (Shift, Ctrl, Alt) then press a key"
[on keypress captured]:
Shows preview: "Shift_R + F12"
GtkButton "Accept" (suggested-action)
GtkButton "Try Again"
GtkButton "Cancel"
```
Validation: MangoHud only supports specific modifier+key combinations.
Valid keys: F1F12 only (MangoHud doesn't accept alphanumeric hotkeys).
If invalid combination captured, show error label and keep "Accept" insensitive.
## FPS Limits Page
Special widget for fps_limit (comma-separated list of FPS values):
```
AdwPreferencesGroup "FPS Limit Values"
description: "Comma-separated list. 0 = unlimited. Toggle between values with Shift_L+F1."
[custom widget: FpsChipList]
GtkFlowBox showing current values as removable chips:
[0] [30] [60] [+]
Each chip: GtkLabel + GtkButton "×"
"+" button: opens inline entry to add new value
Validation: each value must be non-negative integer
Values auto-sorted ascending when saved
AdwPreferencesGroup "FPS Limit Method"
AdwComboRow "Method" (fps_limit_method: early/late/"")
AdwPreferencesGroup "VSync"
AdwComboRow "Vulkan VSync" (vsync: -1/0/1/2/3 with labels)
AdwComboRow "OpenGL VSync" (gl_vsync with labels)
```
## Logging Page
All logging options with particular attention to:
- `output_folder`: path must be validated as an existing writable directory
Use AdwEntryRow + "Browse…" button → GtkFileDialog in FOLDER mode
- `permit_upload`: when toggled off, also disable `upload_logs`
- `output_file`: free string entry
## Raw Editor Page
A `GtkTextView` showing the current config file content as raw text.
- Monospace font
- Syntax highlighting: comments in muted color, keys in accent color, values in text color
(use GtkTextTag for basic highlighting — no external syntax highlighter dep)
- Changes in raw editor update the in-memory AnnotatedConfig via re-parsing on focus-out
- Warning banner at top: "Changes here bypass validation. Errors may prevent MangoHud from loading."
- Show line count and option count in footer
## Acceptance Criteria
- [ ] Hotkey capture works and validates MangoHud-compatible combinations
- [ ] FPS limit chips can be added and removed
- [ ] Logging output_folder validated as writable directory
- [ ] Raw editor shows current config content
- [ ] Raw editor changes re-parsed on focus-out
- [ ] All page options save correctly
---
# Phase 09 — Test Launcher
## Files to implement
- `src/launcher/runner.rs` (replace stub)
- `src/ui/pages/overview.rs` (implements the overview/dashboard)
- (Test launcher UI is part of a dedicated page — see docs/design_system.md)
## runner.rs
```rust
use tokio::process::{Command, Child};
use std::path::PathBuf;
pub struct LaunchConfig {
pub command: String,
pub args: Vec<String>,
pub config_path: PathBuf,
pub show_terminal: bool,
}
pub struct RunningProcess {
pub child: Child,
pub command: String,
pub pid: u32,
}
impl Runner {
/// Check if a tool is available on PATH.
pub fn is_available(tool: &str) -> bool
/// Launch a tool with MANGOHUD=1 and the specified config file.
pub async fn launch(config: LaunchConfig) -> anyhow::Result<RunningProcess>
/// Stop a running process (SIGTERM, then SIGKILL after 3s).
pub async fn stop(process: RunningProcess) -> anyhow::Result<()>
/// Send SIGUSR1 to reload config in a running MangoHud process.
pub async fn reload_config(pid: u32) -> anyhow::Result<()>
}
```
Launch environment:
```
MANGOHUD=1
MANGOHUD_CONFIGFILE={config_path}
```
If `show_terminal=true`: spawn via `xterm -e "{command}"` or detect default terminal
(`$TERM`, then try: `gnome-terminal`, `konsole`, `xfce4-terminal`, `xterm` in that order).
## Overview Page
Dashboard shown on first launch (app startup default page).
```
AdwPreferencesPage "Overview"
[if MangoHud not installed]:
AdwStatusPage
icon-name: "dialog-warning-symbolic"
title: "MangoHud Not Found"
description: "Install MangoHud to use this app.\n\n
Ubuntu/Debian: sudo apt install mangohud\n
Fedora: sudo dnf install mangohud\n
Arch: sudo pacman -S mangohud"
[if MangoHud installed]:
AdwPreferencesGroup "System Status"
AdwActionRow "MangoHud" [suffix: version label + green checkmark]
AdwActionRow "Display" [suffix: "Wayland" or "X11"]
AdwActionRow "GPU" [suffix: GPU name]
AdwActionRow "Active Config" [suffix: current config path]
AdwPreferencesGroup "Quick Actions"
AdwButtonRow "Launch vkcube test" → triggers launcher
AdwButtonRow "Open Config Folder" → xdg-open ~/.config/MangoHud/
AdwButtonRow "View Config Conflicts" → navigate to conflicts page
AdwButtonRow "Reset to Defaults" (destructive — AdwAlertDialog confirm)
```
## Acceptance Criteria
- [ ] vkcube launches with MANGOHUD=1 and current config path
- [ ] glxgears launches with MANGOHUD=1
- [ ] Custom app entry accepts any shell command
- [ ] Running process shown with PID and Stop button
- [ ] Stop sends SIGTERM, escalates to SIGKILL after 3s
- [ ] "Tool not found" toast shown if vkcube/glxgears missing
- [ ] Overview shows correct system info from detect::detect_system()
---
# Phase 10 — Integrations Page
## Files to implement
- `src/integrations/gamemode.rs` (replace stub)
- `src/integrations/steam.rs` (replace stub)
- `src/integrations/lutris.rs` (replace stub)
- `src/integrations/heroic.rs` (replace stub)
- `src/ui/pages/integrations.rs` (new file, not a stub page)
## Implementation follows docs/integrations.md exactly.
## Key implementation notes:
### Threading
All file I/O and process detection in integrations must run on tokio, not the GTK main thread.
Pattern:
```rust
let (sender, receiver) = glib::MainContext::channel(glib::Priority::DEFAULT);
tokio::spawn(async move {
let status = gamemode::detect().await;
sender.send(status).ok();
});
receiver.attach(None, move |status| {
update_ui_with_status(status);
glib::ControlFlow::Break
});
```
### Heroic JSON parsing
Parse `~/.config/heroic/GamesConfig/*.json` using serde_json.
Handle both native and Flatpak paths (try both, use whichever exists).
When writing back: preserve all fields not managed by MangoTune.
Use `serde_json::Value` for the full document to avoid losing unknown fields.
### Lutris YAML parsing
Lutris game configs are simple YAML. Do NOT add a full YAML parser dependency.
Instead, use targeted line-by-line parsing:
- Find the `system:` section
- Find or add `mangohud: true/false` under it
- For reading: look for `mangohud: true` line in file
## Acceptance Criteria
- [ ] GameMode section shows daemon running/stopped status
- [ ] Steam launch option generator produces correct command strings
- [ ] Flatpak Steam detected and generates different command
- [ ] Copy to clipboard button works for generated Steam command
- [ ] Lutris games enumerated from ~/.config/lutris/games/*.yml
- [ ] Enabling MangoHud for Lutris game writes correct YAML
- [ ] Heroic games enumerated from GamesConfig/*.json
- [ ] Enabling MangoHud for Heroic game writes correct JSON (no data loss)
- [ ] All integration sections show "Not installed" gracefully when tool absent
---
# Phase 11 — Polish, Packaging & Final QA
## Goals
Final quality pass, packaging, and ensuring the app is ready for distribution.
## Tasks
### 1. Window state persistence
- Save/restore window width+height via GSettings
- Save/restore last-edited config path
- Save/restore active sidebar page
### 2. Keyboard shortcuts
Register app-level shortcuts:
- `Ctrl+S` → Save
- `Ctrl+Z` → Undo last change (basic: revert to last saved state)
- `Ctrl+Shift+Z` → Redo
- `Ctrl+R` → Reload config from disk
- `Ctrl+W` → Close (prompts if unsaved changes)
- `Ctrl+,` → Preferences
- `F5` → Refresh system detection
Show shortcuts in AdwShortcutsWindow (accessible from gear menu).
### 3. Unsaved changes guard
On window close attempt with unsaved changes:
```
AdwAlertDialog "Unsaved Changes"
body: "You have unsaved changes to {config_name}. What would you like to do?"
buttons:
"Discard Changes" (destructive)
"Cancel"
"Save" (suggested-action)
```
### 4. External config change detection
Use the `notify` crate to watch config files for changes.
If a watched file changes on disk while app is open:
```
AdwBanner (persistent until dismissed):
"Config file changed externally. Reload to see changes."
[button] "Reload"
```
### 5. AppStream metadata
Create `data/com.mangotune.MangoTune.metainfo.xml` following AppStream spec.
### 6. Install targets (for Makefile / meson)
```makefile
PREFIX ?= /usr
BINDIR = $(PREFIX)/bin
DATADIR = $(PREFIX)/share
install:
install -Dm755 target/release/mangotune $(DESTDIR)$(BINDIR)/mangotune
install -Dm644 data/com.mangotune.MangoTune.desktop \
$(DESTDIR)$(DATADIR)/applications/com.mangotune.MangoTune.desktop
install -Dm644 data/com.mangotune.MangoTune.gschema.xml \
$(DESTDIR)$(DATADIR)/glib-2.0/schemas/com.mangotune.MangoTune.gschema.xml
install -Dm644 data/icons/com.mangotune.MangoTune.svg \
$(DESTDIR)$(DATADIR)/icons/hicolor/scalable/apps/com.mangotune.MangoTune.svg
glib-compile-schemas $(DESTDIR)$(DATADIR)/glib-2.0/schemas/
```
### 7. README.md for the actual project
Write a user-facing README covering:
- What MangoTune is and why it's better than GOverlay
- Installation (distro packages + build from source)
- How config priority works
- How to use the test launcher
- How to contribute
### 8. Final QA checklist
- [ ] `cargo clippy -- -D warnings` passes with zero warnings
- [ ] `cargo test` passes all tests
- [ ] App launches cleanly on X11
- [ ] App launches cleanly on Wayland
- [ ] All 120+ MangoHud options save and load correctly (write a test config, load it, verify)
- [ ] Config with comments round-trips without destroying comments
- [ ] Validation blocks all invalid values across all types
- [ ] Dependency auto-enable works for all dependency pairs in schema
- [ ] Conflict detection finds overlapping options across all layer combinations
- [ ] Heroic integration writes JSON without data loss
- [ ] Lutris integration writes YAML without data loss
- [ ] vkcube + glxgears launch with correct environment
- [ ] App handles MangoHud not installed gracefully (no crash)
- [ ] Window resize / sidebar collapse works correctly
- [ ] All keyboard shortcuts function
- [ ] Unsaved changes guard fires on close
- [ ] External file change detection triggers reload banner
- [ ] About dialog shows correct version
- [ ] .desktop file launches app correctly
- [ ] GSettings schema installs and compiles correctly