Initial import
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
||||
use crate::window::MainWindow;
|
||||
use gtk4::gdk;
|
||||
use gtk4::prelude::*;
|
||||
use mangotune::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();
|
||||
|
||||
app.set_accels_for_action("win.save", &["<Primary>s"]);
|
||||
app.set_accels_for_action("win.undo", &["<Primary>z"]);
|
||||
app.set_accels_for_action("win.redo", &["<Primary><Shift>z"]);
|
||||
app.set_accels_for_action("win.reload-config", &["<Primary>r"]);
|
||||
app.set_accels_for_action("win.close-window", &["<Primary>w"]);
|
||||
app.set_accels_for_action("win.refresh-detection", &["F5"]);
|
||||
|
||||
app.connect_startup(|_| {
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_string(include_str!("../data/style.css"));
|
||||
if let Some(display) = gdk::Display::default() {
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app.connect_activate(move |app| {
|
||||
if let Some(window) = app.active_window() {
|
||||
window.present();
|
||||
return;
|
||||
}
|
||||
|
||||
let system_info = glib::MainContext::default()
|
||||
.block_on(async { detect::detect_system().await })
|
||||
.unwrap_or_else(|_| detect::SystemInfo::unknown());
|
||||
|
||||
let window = MainWindow::new(app, system_info);
|
||||
window.present();
|
||||
});
|
||||
|
||||
Self { app }
|
||||
}
|
||||
|
||||
pub fn run(&self) -> i32 {
|
||||
self.app.run().into()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
mod renderer;
|
||||
mod scene;
|
||||
mod socket_api;
|
||||
mod workload;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PreviewScenePreset {
|
||||
DarkArena,
|
||||
BrightWash,
|
||||
MotionStress,
|
||||
StaticInspection,
|
||||
NoiseField,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PreviewPresentMode {
|
||||
Fifo,
|
||||
Immediate,
|
||||
}
|
||||
|
||||
impl PreviewPresentMode {
|
||||
fn from_env() -> Self {
|
||||
let enabled = std::env::var("MANGOTUNE_PREVIEW_VSYNC")
|
||||
.ok()
|
||||
.map(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if enabled {
|
||||
Self::Fifo
|
||||
} else {
|
||||
Self::Immediate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PreviewScenePreset {
|
||||
pub fn from_label(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"dark-arena" | "dark" => Some(Self::DarkArena),
|
||||
"bright-wash" | "bright" => Some(Self::BrightWash),
|
||||
"static-inspection" | "readability" => Some(Self::StaticInspection),
|
||||
"motion-stress" | "motion" => Some(Self::MotionStress),
|
||||
"noise-field" => Some(Self::NoiseField),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_env() -> Self {
|
||||
std::env::var("MANGOTUNE_PREVIEW_SCENE")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(Self::from_label)
|
||||
.unwrap_or(Self::DarkArena)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimState {
|
||||
pub gpu_load_target: f32,
|
||||
pub fps_cap: u32,
|
||||
pub vram_pressure_mb: u32,
|
||||
pub particle_count: u32,
|
||||
pub particle_size: f32,
|
||||
pub gpu_passes: u32,
|
||||
pub interaction_steps: u32,
|
||||
pub present_mode: PreviewPresentMode,
|
||||
pub paused: bool,
|
||||
pub should_quit: bool,
|
||||
pub scene_preset: PreviewScenePreset,
|
||||
}
|
||||
|
||||
impl Default for SimState {
|
||||
fn default() -> Self {
|
||||
let scene_preset = PreviewScenePreset::from_env();
|
||||
let gpu_load_target = std::env::var("MANGOTUNE_PREVIEW_LOAD")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<f32>().ok())
|
||||
.unwrap_or(0.0)
|
||||
.clamp(0.0, 10.0)
|
||||
/ 10.0;
|
||||
let particle_count = std::env::var("MANGOTUNE_PREVIEW_PARTICLE_COUNT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(1_000)
|
||||
.clamp(100, 500_000);
|
||||
let vram_pressure_mb = std::env::var("MANGOTUNE_PREVIEW_VRAM_MB")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(0)
|
||||
.clamp(0, 65_536);
|
||||
let particle_size = std::env::var("MANGOTUNE_PREVIEW_PARTICLE_SIZE")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<f32>().ok())
|
||||
.unwrap_or(0.03)
|
||||
.clamp(0.01, 5.0);
|
||||
let gpu_passes = std::env::var("MANGOTUNE_PREVIEW_GPU_PASSES")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(1)
|
||||
.clamp(1, 64);
|
||||
let interaction_steps = std::env::var("MANGOTUNE_PREVIEW_INTERACTION_STEPS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(0)
|
||||
.clamp(0, 256);
|
||||
let fps_cap = std::env::var("MANGOTUNE_PREVIEW_FPS_CAP")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(120);
|
||||
let present_mode = PreviewPresentMode::from_env();
|
||||
|
||||
Self {
|
||||
gpu_load_target,
|
||||
fps_cap,
|
||||
vram_pressure_mb,
|
||||
particle_count,
|
||||
particle_size,
|
||||
gpu_passes,
|
||||
interaction_steps,
|
||||
present_mode,
|
||||
paused: false,
|
||||
should_quit: false,
|
||||
scene_preset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut logger = env_logger::Builder::from_env(
|
||||
env_logger::Env::default().filter_or("MANGOTUNE_LOG", "warn"),
|
||||
);
|
||||
// Newer NVIDIA/Vulkan stacks may advertise extension present modes that this
|
||||
// wgpu release does not recognize by name. We still request plain FIFO, so
|
||||
// these probe-time warnings are just console noise.
|
||||
logger.filter_module("wgpu_hal::vulkan::conv", log::LevelFilter::Error);
|
||||
logger.init();
|
||||
|
||||
log::info!("mangotune-preview starting");
|
||||
log::info!("Control socket: {}", socket_api::socket_path());
|
||||
log::info!(
|
||||
"Config file: {}",
|
||||
std::env::var("MANGOHUD_CONFIGFILE")
|
||||
.unwrap_or_else(|_| "(not set — MangoHud will use its default)".into())
|
||||
);
|
||||
|
||||
let state = Arc::new(Mutex::new(SimState::default()));
|
||||
let state_for_socket = Arc::clone(&state);
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
rt.block_on(socket_api::run(state_for_socket))
|
||||
.expect("socket API crashed");
|
||||
});
|
||||
|
||||
renderer::run(state)
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
use crate::scene::Scene;
|
||||
use crate::workload::Workload;
|
||||
use crate::{PreviewPresentMode, PreviewScenePreset, SimState};
|
||||
use anyhow::Result;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
dpi::LogicalSize,
|
||||
event::{Event, WindowEvent},
|
||||
event_loop::{ControlFlow, EventLoop},
|
||||
window::Window,
|
||||
};
|
||||
|
||||
const SHADER_SRC: &str = r#"
|
||||
struct Camera {
|
||||
view_proj: mat4x4<f32>,
|
||||
time_pad: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> camera: Camera;
|
||||
|
||||
struct VertIn {
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
struct VertOut {
|
||||
@builtin(position) clip_pos: vec4<f32>,
|
||||
@location(0) color: vec4<f32>,
|
||||
@location(2) local_uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_main(in: VertIn) -> VertOut {
|
||||
var out: VertOut;
|
||||
out.clip_pos = camera.view_proj * vec4<f32>(in.position, 1.0);
|
||||
out.color = in.color;
|
||||
out.local_uv = in.uv;
|
||||
return out;
|
||||
}
|
||||
|
||||
fn hash3(p: vec3<f32>) -> f32 {
|
||||
var p3 = fract(p * 0.1031);
|
||||
p3 = p3 + vec3<f32>(dot(p3, p3.yzx + vec3<f32>(33.33)));
|
||||
return fract((p3.x + p3.y) * p3.z);
|
||||
}
|
||||
|
||||
fn value_noise(p: vec3<f32>) -> f32 {
|
||||
let i = floor(p);
|
||||
let f = fract(p);
|
||||
let u = f * f * (vec3<f32>(3.0) - 2.0 * f);
|
||||
|
||||
return mix(
|
||||
mix(mix(hash3(i + vec3<f32>(0.0, 0.0, 0.0)), hash3(i + vec3<f32>(1.0, 0.0, 0.0)), u.x),
|
||||
mix(hash3(i + vec3<f32>(0.0, 1.0, 0.0)), hash3(i + vec3<f32>(1.0, 1.0, 0.0)), u.x), u.y),
|
||||
mix(mix(hash3(i + vec3<f32>(0.0, 0.0, 1.0)), hash3(i + vec3<f32>(1.0, 0.0, 1.0)), u.x),
|
||||
mix(hash3(i + vec3<f32>(0.0, 1.0, 1.0)), hash3(i + vec3<f32>(1.0, 1.0, 1.0)), u.x), u.y),
|
||||
u.z
|
||||
);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertOut) -> @location(0) vec4<f32> {
|
||||
let uv = in.local_uv;
|
||||
let r2 = dot(uv, uv);
|
||||
if (r2 > 1.0) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let depth = sqrt(1.0 - r2);
|
||||
let ray_entry = -depth;
|
||||
let ray_exit = depth;
|
||||
let ray_length = 2.0 * depth;
|
||||
let step_size = ray_length / 64.0;
|
||||
var density = 0.0;
|
||||
|
||||
for (var step = 0u; step < 64u; step = step + 1u) {
|
||||
let t = ray_entry + (f32(step) + 0.5) * step_size;
|
||||
let p = vec3<f32>(uv, t);
|
||||
let time = camera.time_pad.x;
|
||||
let n1 = value_noise(p * 3.0 + vec3<f32>(time * 0.3, 0.0, 0.0));
|
||||
let n2 = value_noise(p * 6.0 - vec3<f32>(0.0, time * 0.5, 0.0)) * 0.5;
|
||||
let n3 = value_noise(p * 12.0 + vec3<f32>(time * 0.7, time * 0.2, 0.0)) * 0.25;
|
||||
let sample = (n1 + n2 + n3) / 1.75;
|
||||
density = density + sample * (1.0 / 64.0);
|
||||
}
|
||||
|
||||
return vec4<f32>(in.color.rgb, clamp(density, 0.0, 1.0));
|
||||
}
|
||||
"#;
|
||||
|
||||
type Mat4 = [f32; 16];
|
||||
|
||||
fn mat4_identity() -> Mat4 {
|
||||
[
|
||||
1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
|
||||
]
|
||||
}
|
||||
|
||||
fn mat4_mul(a: &Mat4, b: &Mat4) -> Mat4 {
|
||||
let mut out = [0f32; 16];
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
out[col * 4 + row] = (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn perspective(fov_y_rad: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
|
||||
let f = 1.0 / (fov_y_rad * 0.5).tan();
|
||||
[
|
||||
f / aspect,
|
||||
0.,
|
||||
0.,
|
||||
0.,
|
||||
0.,
|
||||
-f,
|
||||
0.,
|
||||
0.,
|
||||
0.,
|
||||
0.,
|
||||
far / (near - far),
|
||||
-1.,
|
||||
0.,
|
||||
0.,
|
||||
near * far / (near - far),
|
||||
0.,
|
||||
]
|
||||
}
|
||||
|
||||
fn look_at(eye: [f32; 3], center: [f32; 3], up: [f32; 3]) -> Mat4 {
|
||||
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
let norm = |v: [f32; 3]| {
|
||||
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
[v[0] / l, v[1] / l, v[2] / l]
|
||||
};
|
||||
let cross = |a: [f32; 3], b: [f32; 3]| {
|
||||
[
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
]
|
||||
};
|
||||
let dot = |a: [f32; 3], b: [f32; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
|
||||
let f = norm(sub(center, eye));
|
||||
let s = norm(cross(f, up));
|
||||
let u = cross(s, f);
|
||||
|
||||
let mut m = mat4_identity();
|
||||
m[0] = s[0];
|
||||
m[4] = s[1];
|
||||
m[8] = s[2];
|
||||
m[1] = u[0];
|
||||
m[5] = u[1];
|
||||
m[9] = u[2];
|
||||
m[2] = -f[0];
|
||||
m[6] = -f[1];
|
||||
m[10] = -f[2];
|
||||
m[12] = -dot(s, eye);
|
||||
m[13] = -dot(u, eye);
|
||||
m[14] = dot(f, eye);
|
||||
m
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct CameraUniform {
|
||||
view_proj: Mat4,
|
||||
time_pad: [f32; 4],
|
||||
}
|
||||
|
||||
struct GpuState {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
supported_present_modes: Vec<wgpu::PresentMode>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
camera_buffer: wgpu::Buffer,
|
||||
camera_bind_group: wgpu::BindGroup,
|
||||
scene: Scene,
|
||||
workload: Workload,
|
||||
}
|
||||
|
||||
impl GpuState {
|
||||
async fn new(window: Arc<Window>) -> Result<Self> {
|
||||
let size = window.inner_size();
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::VULKAN,
|
||||
flags: wgpu::InstanceFlags::empty(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let surface = instance.create_surface(Arc::clone(&window))?;
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: Some(&surface),
|
||||
force_fallback_adapter: false,
|
||||
})
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("No suitable Vulkan adapter found"))?;
|
||||
|
||||
let (device, queue) = adapter
|
||||
.request_device(
|
||||
&wgpu::DeviceDescriptor {
|
||||
label: Some("mangotune_preview"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: wgpu::Limits::default(),
|
||||
memory_hints: wgpu::MemoryHints::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let surface_caps = surface.get_capabilities(&adapter);
|
||||
let format = surface_caps
|
||||
.formats
|
||||
.iter()
|
||||
.find(|f| f.is_srgb())
|
||||
.copied()
|
||||
.unwrap_or(surface_caps.formats[0]);
|
||||
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
present_mode: choose_present_mode(
|
||||
&surface_caps.present_modes,
|
||||
PreviewPresentMode::from_env(),
|
||||
),
|
||||
alpha_mode: surface_caps.alpha_modes[0],
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
surface.configure(&device, &config);
|
||||
|
||||
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("camera_uniform"),
|
||||
size: std::mem::size_of::<CameraUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let camera_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("camera_bgl"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("camera_bg"),
|
||||
layout: &camera_bind_group_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: camera_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("particle_shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
|
||||
});
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("pipeline_layout"),
|
||||
bind_group_layouts: &[&camera_bind_group_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("particle_pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: "vs_main",
|
||||
buffers: &[crate::scene::ParticleVertex::desc()],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: "fs_main",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let scene = Scene::new(&device);
|
||||
let workload = Workload::new(&device);
|
||||
|
||||
Ok(Self {
|
||||
surface,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
supported_present_modes: surface_caps.present_modes.clone(),
|
||||
pipeline,
|
||||
camera_buffer,
|
||||
camera_bind_group,
|
||||
scene,
|
||||
workload,
|
||||
})
|
||||
}
|
||||
|
||||
fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
|
||||
if new_size.width > 0 && new_size.height > 0 {
|
||||
self.config.width = new_size.width;
|
||||
self.config.height = new_size.height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_runtime_mode(&mut self, state: &SimState) {
|
||||
let desired = choose_present_mode(&self.supported_present_modes, state.present_mode);
|
||||
if self.config.present_mode != desired {
|
||||
self.config.present_mode = desired;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, state: &SimState) -> Result<(), wgpu::SurfaceError> {
|
||||
self.apply_runtime_mode(state);
|
||||
let aspect = self.config.width as f32 / self.config.height as f32;
|
||||
let t = self.scene.time;
|
||||
let eye = match state.scene_preset {
|
||||
PreviewScenePreset::DarkArena => [t.cos() * 5.8, 1.8, t.sin() * 5.8],
|
||||
PreviewScenePreset::BrightWash => [t.cos() * 4.6, 1.6, t.sin() * 4.6],
|
||||
PreviewScenePreset::MotionStress => [
|
||||
(t * 0.95).cos() * 6.8,
|
||||
2.2 + (t * 0.7).sin() * 0.8,
|
||||
(t * 0.95).sin() * 6.8,
|
||||
],
|
||||
PreviewScenePreset::StaticInspection => [0.0, 2.4, 7.2],
|
||||
PreviewScenePreset::NoiseField => [
|
||||
(t * 0.45).cos() * 6.2,
|
||||
1.0 + (t * 0.25).sin() * 0.5,
|
||||
(t * 0.6).sin() * 6.2,
|
||||
],
|
||||
};
|
||||
let view = look_at(eye, [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
|
||||
let proj = perspective(std::f32::consts::FRAC_PI_4, aspect, 0.1, 100.0);
|
||||
let view_proj = mat4_mul(&proj, &view);
|
||||
self.queue.write_buffer(
|
||||
&self.camera_buffer,
|
||||
0,
|
||||
bytemuck::bytes_of(&CameraUniform {
|
||||
view_proj,
|
||||
time_pad: [self.scene.time, 0.0, 0.0, 0.0],
|
||||
}),
|
||||
);
|
||||
|
||||
let output = self.surface.get_current_texture()?;
|
||||
let view = output.texture.create_view(&Default::default());
|
||||
|
||||
let clear = match state.scene_preset {
|
||||
PreviewScenePreset::DarkArena => wgpu::Color {
|
||||
r: 0.03,
|
||||
g: 0.04,
|
||||
b: 0.09,
|
||||
a: 1.0,
|
||||
},
|
||||
PreviewScenePreset::BrightWash => wgpu::Color {
|
||||
r: 0.78,
|
||||
g: 0.82,
|
||||
b: 0.82,
|
||||
a: 1.0,
|
||||
},
|
||||
PreviewScenePreset::MotionStress => wgpu::Color {
|
||||
r: 0.08,
|
||||
g: 0.05,
|
||||
b: 0.11,
|
||||
a: 1.0,
|
||||
},
|
||||
PreviewScenePreset::StaticInspection => wgpu::Color {
|
||||
r: 0.10,
|
||||
g: 0.11,
|
||||
b: 0.14,
|
||||
a: 1.0,
|
||||
},
|
||||
PreviewScenePreset::NoiseField => wgpu::Color {
|
||||
r: 0.04,
|
||||
g: 0.08,
|
||||
b: 0.09,
|
||||
a: 1.0,
|
||||
},
|
||||
};
|
||||
|
||||
for pass_index in 0..state.gpu_passes {
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("frame_pass"),
|
||||
});
|
||||
|
||||
{
|
||||
let load_op = if pass_index == 0 {
|
||||
wgpu::LoadOp::Clear(clear)
|
||||
} else {
|
||||
wgpu::LoadOp::Load
|
||||
};
|
||||
|
||||
let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("particle_pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: load_op,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
rpass.set_pipeline(&self.pipeline);
|
||||
rpass.set_bind_group(0, &self.camera_bind_group, &[]);
|
||||
rpass.set_vertex_buffer(0, self.scene.vertex_buffer.slice(..));
|
||||
rpass.draw(0..self.scene.vertex_count(), 0..1);
|
||||
}
|
||||
|
||||
self.queue.submit(std::iter::once(encoder.finish()));
|
||||
}
|
||||
output.present();
|
||||
self.workload.update(&self.device, state);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_present_mode(
|
||||
supported: &[wgpu::PresentMode],
|
||||
requested: PreviewPresentMode,
|
||||
) -> wgpu::PresentMode {
|
||||
match requested {
|
||||
PreviewPresentMode::Fifo => supported
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|mode| *mode == wgpu::PresentMode::Fifo)
|
||||
.unwrap_or(wgpu::PresentMode::Fifo),
|
||||
PreviewPresentMode::Immediate => supported
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|mode| {
|
||||
matches!(
|
||||
mode,
|
||||
wgpu::PresentMode::Immediate
|
||||
| wgpu::PresentMode::Mailbox
|
||||
| wgpu::PresentMode::AutoNoVsync
|
||||
)
|
||||
})
|
||||
.unwrap_or(wgpu::PresentMode::Fifo),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
pub fn run(state: Arc<Mutex<SimState>>) -> Result<()> {
|
||||
let event_loop = EventLoop::new()?;
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
|
||||
let width = std::env::var("MANGOTUNE_PREVIEW_WIDTH")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(800);
|
||||
let height = std::env::var("MANGOTUNE_PREVIEW_HEIGHT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(600);
|
||||
|
||||
let window = Arc::new(
|
||||
event_loop.create_window(
|
||||
Window::default_attributes()
|
||||
.with_title("MangoTune Preview")
|
||||
.with_inner_size(LogicalSize::new(width, height)),
|
||||
)?,
|
||||
);
|
||||
|
||||
let mut gpu = pollster::block_on(GpuState::new(Arc::clone(&window)))?;
|
||||
let mut last_frame = Instant::now();
|
||||
|
||||
event_loop.run(move |event, elwt| {
|
||||
{
|
||||
let sim = state.lock().expect("preview state");
|
||||
if sim.should_quit {
|
||||
elwt.exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::WindowEvent {
|
||||
event: WindowEvent::CloseRequested,
|
||||
..
|
||||
} => {
|
||||
elwt.exit();
|
||||
}
|
||||
Event::WindowEvent {
|
||||
event: WindowEvent::Resized(size),
|
||||
..
|
||||
} => {
|
||||
gpu.resize(size);
|
||||
}
|
||||
Event::AboutToWait => {
|
||||
let now = Instant::now();
|
||||
let dt = now.duration_since(last_frame).as_secs_f32().min(0.05);
|
||||
last_frame = now;
|
||||
|
||||
let sim = state.lock().expect("preview state").clone();
|
||||
|
||||
gpu.scene.update(dt, sim.particle_size, &sim, &gpu.queue);
|
||||
match gpu.render(&sim) {
|
||||
Ok(_) => {}
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
gpu.resize(window.inner_size());
|
||||
}
|
||||
Err(wgpu::SurfaceError::OutOfMemory) => elwt.exit(),
|
||||
Err(err) => log::warn!("Surface error: {}", err),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})?;
|
||||
|
||||
let _ = std::fs::remove_file(crate::socket_api::socket_path());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
use crate::SimState;
|
||||
use rayon::prelude::*;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct ParticleVertex {
|
||||
pub position: [f32; 3],
|
||||
pub color: [f32; 4],
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
impl ParticleVertex {
|
||||
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<ParticleVertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &[
|
||||
wgpu::VertexAttribute {
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
|
||||
shader_location: 1,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: (std::mem::size_of::<[f32; 3]>() + std::mem::size_of::<[f32; 4]>())
|
||||
as wgpu::BufferAddress,
|
||||
shader_location: 2,
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Particle {
|
||||
pos: [f32; 3],
|
||||
vel: [f32; 3],
|
||||
phase: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct SceneProfile {
|
||||
target_radius_base: f32,
|
||||
target_pulse: f32,
|
||||
shell_strength: f32,
|
||||
swirl: f32,
|
||||
z_wave: f32,
|
||||
outer_limit: f32,
|
||||
}
|
||||
|
||||
impl Particle {
|
||||
fn random(r: f32, seed: u64) -> Self {
|
||||
let rng = Lcg::new(seed);
|
||||
let (x, y, z) = rng.point_in_sphere(r);
|
||||
Self {
|
||||
pos: [x, y, z],
|
||||
vel: [
|
||||
rng.range(-0.2, 0.2),
|
||||
rng.range(-0.2, 0.2),
|
||||
rng.range(-0.2, 0.2),
|
||||
],
|
||||
phase: rng.range(0.0, std::f32::consts::TAU),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_with_profile(
|
||||
&mut self,
|
||||
dt: f32,
|
||||
time: f32,
|
||||
interaction_steps: u32,
|
||||
profile: SceneProfile,
|
||||
) {
|
||||
let radius =
|
||||
(self.pos[0] * self.pos[0] + self.pos[1] * self.pos[1] + self.pos[2] * self.pos[2])
|
||||
.sqrt()
|
||||
.max(0.001);
|
||||
let target_radius =
|
||||
profile.target_radius_base + profile.target_pulse * (self.phase + time * 0.15).sin();
|
||||
let shell_force = (target_radius - radius) * profile.shell_strength;
|
||||
let ax = (self.pos[0] / radius) * shell_force;
|
||||
let ay = (self.pos[1] / radius) * shell_force;
|
||||
let az = (self.pos[2] / radius) * shell_force;
|
||||
|
||||
let sx = -self.pos[1] * profile.swirl;
|
||||
let sy = self.pos[0] * profile.swirl;
|
||||
let sz = (time * 0.35 + self.phase).sin() * profile.z_wave;
|
||||
|
||||
self.vel[0] += (ax + sx) * dt;
|
||||
self.vel[1] += (ay + sy) * dt;
|
||||
self.vel[2] += (az + sz) * dt;
|
||||
|
||||
let damp = 0.98f32.powf(dt * 60.0);
|
||||
self.vel[0] *= damp;
|
||||
self.vel[1] *= damp;
|
||||
self.vel[2] *= damp;
|
||||
|
||||
self.pos[0] += self.vel[0] * dt;
|
||||
self.pos[1] += self.vel[1] * dt;
|
||||
self.pos[2] += self.vel[2] * dt;
|
||||
|
||||
let radius_after =
|
||||
(self.pos[0] * self.pos[0] + self.pos[1] * self.pos[1] + self.pos[2] * self.pos[2])
|
||||
.sqrt();
|
||||
if !(0.6..=profile.outer_limit).contains(&radius_after) {
|
||||
let dir_x = self.pos[0] / radius_after.max(0.001);
|
||||
let dir_y = self.pos[1] / radius_after.max(0.001);
|
||||
let dir_z = self.pos[2] / radius_after.max(0.001);
|
||||
let reset_radius = profile.target_radius_base + 0.4 * self.phase.sin();
|
||||
self.pos[0] = dir_x * reset_radius;
|
||||
self.pos[1] = dir_y * reset_radius;
|
||||
self.pos[2] = dir_z * reset_radius;
|
||||
self.vel = [0.0, 0.0, 0.0];
|
||||
}
|
||||
|
||||
for k in 0..interaction_steps {
|
||||
let hash_input =
|
||||
(self.pos[0] * 73.1 + self.pos[1] * 157.3 + self.pos[2] * 43.7 + k as f32)
|
||||
.to_bits();
|
||||
let hashed = hash_input.wrapping_mul(2654435761).wrapping_add(k);
|
||||
let neighbour_x = (hashed as f32 / u32::MAX as f32) * 2.0 - 1.0;
|
||||
let neighbour_y = ((hashed.wrapping_mul(1664525)) as f32 / u32::MAX as f32) * 2.0 - 1.0;
|
||||
let neighbour_z =
|
||||
((hashed.wrapping_mul(22695477)) as f32 / u32::MAX as f32) * 2.0 - 1.0;
|
||||
let dx = neighbour_x - self.pos[0];
|
||||
let dy = neighbour_y - self.pos[1];
|
||||
let dz = neighbour_z - self.pos[2];
|
||||
let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(0.01);
|
||||
self.vel[0] += (dx / dist) * 0.0001;
|
||||
self.vel[1] += (dy / dist) * 0.0001;
|
||||
self.vel[2] += (dz / dist) * 0.0001;
|
||||
}
|
||||
}
|
||||
|
||||
fn to_vertices(&self, size: f32, time: f32) -> [ParticleVertex; 6] {
|
||||
let hue = (time * 0.2 + self.phase) % std::f32::consts::TAU;
|
||||
let color = hsv_to_rgb(hue / std::f32::consts::TAU, 0.8, 1.0);
|
||||
|
||||
let h = size * 0.5;
|
||||
let corners = [
|
||||
([-h, -h], [-1.0, -1.0]),
|
||||
([h, -h], [1.0, -1.0]),
|
||||
([h, h], [1.0, 1.0]),
|
||||
([-h, -h], [-1.0, -1.0]),
|
||||
([h, h], [1.0, 1.0]),
|
||||
([-h, h], [-1.0, 1.0]),
|
||||
];
|
||||
|
||||
let mut verts = [ParticleVertex {
|
||||
position: [0.0; 3],
|
||||
color,
|
||||
uv: [0.0; 2],
|
||||
}; 6];
|
||||
for (i, ([cx, cy], uv)) in corners.iter().enumerate() {
|
||||
verts[i].position = [self.pos[0] + cx, self.pos[1] + cy, self.pos[2]];
|
||||
verts[i].uv = *uv;
|
||||
}
|
||||
verts
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_PARTICLES: usize = 500_000;
|
||||
|
||||
pub struct Scene {
|
||||
particles: Vec<Particle>,
|
||||
vertex_data: Vec<ParticleVertex>,
|
||||
pub vertex_buffer: wgpu::Buffer,
|
||||
pub time: f32,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let initial_count = 50_000usize;
|
||||
let particles: Vec<Particle> = (0..initial_count)
|
||||
.map(|i| {
|
||||
Particle::random(
|
||||
3.0,
|
||||
(i as u64).wrapping_mul(6364136223846793005).wrapping_add(1),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let vertex_data = vec![
|
||||
ParticleVertex {
|
||||
position: [0.0; 3],
|
||||
color: [0.0; 4],
|
||||
uv: [0.0; 2],
|
||||
};
|
||||
MAX_PARTICLES * 6
|
||||
];
|
||||
|
||||
let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("particle_vertices"),
|
||||
size: (MAX_PARTICLES * 6 * std::mem::size_of::<ParticleVertex>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
Self {
|
||||
particles,
|
||||
vertex_data,
|
||||
vertex_buffer,
|
||||
time: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, dt: f32, particle_size: f32, state: &SimState, queue: &wgpu::Queue) {
|
||||
if state.paused {
|
||||
return;
|
||||
}
|
||||
|
||||
self.time += dt;
|
||||
let target = (state.particle_count as usize).min(MAX_PARTICLES);
|
||||
|
||||
while self.particles.len() < target {
|
||||
let seed = (self.particles.len() as u64)
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1);
|
||||
self.particles.push(Particle::random(3.0, seed));
|
||||
}
|
||||
self.particles.truncate(target);
|
||||
|
||||
let t = self.time;
|
||||
let scene_steps_bonus = match state.scene_preset {
|
||||
crate::PreviewScenePreset::DarkArena => 0,
|
||||
crate::PreviewScenePreset::BrightWash => 0,
|
||||
crate::PreviewScenePreset::MotionStress => 4,
|
||||
crate::PreviewScenePreset::StaticInspection => 0,
|
||||
crate::PreviewScenePreset::NoiseField => 2,
|
||||
};
|
||||
let steps = (state.interaction_steps + scene_steps_bonus).min(256);
|
||||
let profile = match state.scene_preset {
|
||||
crate::PreviewScenePreset::DarkArena => SceneProfile {
|
||||
target_radius_base: 2.2,
|
||||
target_pulse: 0.25,
|
||||
shell_strength: 0.40,
|
||||
swirl: 0.35,
|
||||
z_wave: 0.08,
|
||||
outer_limit: 4.0,
|
||||
},
|
||||
crate::PreviewScenePreset::BrightWash => SceneProfile {
|
||||
target_radius_base: 2.0,
|
||||
target_pulse: 0.18,
|
||||
shell_strength: 0.34,
|
||||
swirl: 0.22,
|
||||
z_wave: 0.06,
|
||||
outer_limit: 3.8,
|
||||
},
|
||||
crate::PreviewScenePreset::MotionStress => SceneProfile {
|
||||
target_radius_base: 2.6,
|
||||
target_pulse: 0.60,
|
||||
shell_strength: 0.65,
|
||||
swirl: 1.15,
|
||||
z_wave: 0.32,
|
||||
outer_limit: 4.8,
|
||||
},
|
||||
crate::PreviewScenePreset::StaticInspection => SceneProfile {
|
||||
target_radius_base: 1.9,
|
||||
target_pulse: 0.06,
|
||||
shell_strength: 0.55,
|
||||
swirl: 0.05,
|
||||
z_wave: 0.02,
|
||||
outer_limit: 3.4,
|
||||
},
|
||||
crate::PreviewScenePreset::NoiseField => SceneProfile {
|
||||
target_radius_base: 3.0,
|
||||
target_pulse: 0.45,
|
||||
shell_strength: 0.28,
|
||||
swirl: 0.65,
|
||||
z_wave: 0.16,
|
||||
outer_limit: 5.0,
|
||||
},
|
||||
};
|
||||
self.particles.par_iter_mut().for_each(|p| {
|
||||
p.update_with_profile(dt, t, steps, profile);
|
||||
});
|
||||
let mut vi = 0;
|
||||
for p in &self.particles {
|
||||
let verts = p.to_vertices(particle_size, t);
|
||||
for v in &verts {
|
||||
self.vertex_data[vi] = *v;
|
||||
vi += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let live_bytes = vi * std::mem::size_of::<ParticleVertex>();
|
||||
if live_bytes > 0 {
|
||||
queue.write_buffer(
|
||||
&self.vertex_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.vertex_data[..vi]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vertex_count(&self) -> u32 {
|
||||
(self.particles.len() * 6) as u32
|
||||
}
|
||||
}
|
||||
|
||||
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 4] {
|
||||
let i = (h * 6.0).floor() as i32;
|
||||
let f = h * 6.0 - i as f32;
|
||||
let p = v * (1.0 - s);
|
||||
let q = v * (1.0 - f * s);
|
||||
let t = v * (1.0 - (1.0 - f) * s);
|
||||
let (r, g, b) = match i % 6 {
|
||||
0 => (v, t, p),
|
||||
1 => (q, v, p),
|
||||
2 => (p, v, t),
|
||||
3 => (p, q, v),
|
||||
4 => (t, p, v),
|
||||
_ => (v, p, q),
|
||||
};
|
||||
[r, g, b, 1.0]
|
||||
}
|
||||
|
||||
struct Lcg {
|
||||
state: std::cell::Cell<u64>,
|
||||
}
|
||||
|
||||
impl Lcg {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self {
|
||||
state: std::cell::Cell::new(seed),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_u32(&self) -> u32 {
|
||||
let next = self
|
||||
.state
|
||||
.get()
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1);
|
||||
self.state.set(next);
|
||||
(next >> 32) as u32
|
||||
}
|
||||
|
||||
fn next_f32(&self) -> f32 {
|
||||
self.next_u32() as f32 / u32::MAX as f32
|
||||
}
|
||||
|
||||
fn range(&self, min: f32, max: f32) -> f32 {
|
||||
min + (max - min) * self.next_f32()
|
||||
}
|
||||
|
||||
fn point_in_sphere(&self, radius: f32) -> (f32, f32, f32) {
|
||||
loop {
|
||||
let x = self.range(-radius, radius);
|
||||
let y = self.range(-radius, radius);
|
||||
let z = self.range(-radius, radius);
|
||||
if x * x + y * y + z * z <= radius * radius {
|
||||
return (x, y, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use crate::SimState;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
pub fn socket_path() -> String {
|
||||
std::env::var("MANGOTUNE_SOCKET").unwrap_or_else(|_| "/tmp/mangotune_preview.sock".into())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "cmd", rename_all = "snake_case")]
|
||||
enum Command {
|
||||
SetScene { scene: String },
|
||||
SetLoad { gpu_percent: f32 },
|
||||
SetFpsCap { fps: u32 },
|
||||
SetVsync { enabled: bool },
|
||||
SetVramPressure { mb: u32 },
|
||||
SetParticleCount { count: u32 },
|
||||
SetParticleSize { size: f32 },
|
||||
SetGpuPasses { passes: u32 },
|
||||
SetInteractionSteps { steps: u32 },
|
||||
Pause,
|
||||
Resume,
|
||||
GetState,
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct StateSnapshot {
|
||||
gpu_load_target: f32,
|
||||
fps_cap: u32,
|
||||
vsync_enabled: bool,
|
||||
vram_pressure_mb: u32,
|
||||
particle_count: u32,
|
||||
particle_size: f32,
|
||||
gpu_passes: u32,
|
||||
interaction_steps: u32,
|
||||
paused: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum Response {
|
||||
Ok { ok: bool },
|
||||
OkWithState { ok: bool, state: StateSnapshot },
|
||||
Err { ok: bool, error: String },
|
||||
}
|
||||
|
||||
impl Response {
|
||||
fn ok() -> Self {
|
||||
Response::Ok { ok: true }
|
||||
}
|
||||
fn err(msg: impl Into<String>) -> Self {
|
||||
Response::Err {
|
||||
ok: false,
|
||||
error: msg.into(),
|
||||
}
|
||||
}
|
||||
fn with_state(state: StateSnapshot) -> Self {
|
||||
Response::OkWithState { ok: true, state }
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(state: Arc<Mutex<SimState>>) -> Result<()> {
|
||||
let path = socket_path();
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
log::info!("Socket API listening on {}", path);
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _addr)) => {
|
||||
let state = Arc::clone(&state);
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = handle_connection(stream, state).await {
|
||||
log::warn!("Socket connection error: {}", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Socket accept error: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::UnixStream,
|
||||
state: Arc<Mutex<SimState>>,
|
||||
) -> Result<()> {
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let reader = BufReader::new(read_half);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let line = line.trim().to_string();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = match serde_json::from_str::<Command>(&line) {
|
||||
Ok(cmd) => dispatch(cmd, &state),
|
||||
Err(err) => Response::err(format!("parse error: {}", err)),
|
||||
};
|
||||
|
||||
let mut bytes = serde_json::to_vec(&response)?;
|
||||
bytes.push(b'\n');
|
||||
write_half.write_all(&bytes).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dispatch(cmd: Command, state: &Arc<Mutex<SimState>>) -> Response {
|
||||
let mut sim = match state.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return Response::err("state lock poisoned"),
|
||||
};
|
||||
|
||||
match cmd {
|
||||
Command::SetScene { scene } => match crate::PreviewScenePreset::from_label(&scene) {
|
||||
Some(preset) => {
|
||||
sim.scene_preset = preset;
|
||||
Response::ok()
|
||||
}
|
||||
None => Response::err(format!("unknown scene: {scene}")),
|
||||
},
|
||||
Command::SetLoad { gpu_percent } => {
|
||||
sim.gpu_load_target = (gpu_percent / 100.0).clamp(0.0, 1.0);
|
||||
Response::ok()
|
||||
}
|
||||
Command::SetFpsCap { fps } => {
|
||||
sim.fps_cap = fps;
|
||||
Response::ok()
|
||||
}
|
||||
Command::SetVsync { enabled } => {
|
||||
sim.present_mode = if enabled {
|
||||
crate::PreviewPresentMode::Fifo
|
||||
} else {
|
||||
crate::PreviewPresentMode::Immediate
|
||||
};
|
||||
Response::ok()
|
||||
}
|
||||
Command::SetVramPressure { mb } => {
|
||||
sim.vram_pressure_mb = mb;
|
||||
Response::ok()
|
||||
}
|
||||
Command::SetParticleCount { count } => {
|
||||
sim.particle_count = count.clamp(100, 500_000);
|
||||
Response::ok()
|
||||
}
|
||||
Command::SetParticleSize { size } => {
|
||||
sim.particle_size = size.clamp(0.01, 5.0);
|
||||
log::info!("Particle size -> {:.2}", sim.particle_size);
|
||||
Response::ok()
|
||||
}
|
||||
Command::SetGpuPasses { passes } => {
|
||||
sim.gpu_passes = passes.clamp(1, 64);
|
||||
log::info!("GPU passes -> {}", sim.gpu_passes);
|
||||
Response::ok()
|
||||
}
|
||||
Command::SetInteractionSteps { steps } => {
|
||||
sim.interaction_steps = steps.clamp(0, 256);
|
||||
Response::ok()
|
||||
}
|
||||
Command::Pause => {
|
||||
sim.paused = true;
|
||||
Response::ok()
|
||||
}
|
||||
Command::Resume => {
|
||||
sim.paused = false;
|
||||
Response::ok()
|
||||
}
|
||||
Command::GetState => Response::with_state(StateSnapshot {
|
||||
gpu_load_target: sim.gpu_load_target,
|
||||
fps_cap: sim.fps_cap,
|
||||
vsync_enabled: sim.present_mode == crate::PreviewPresentMode::Fifo,
|
||||
vram_pressure_mb: sim.vram_pressure_mb,
|
||||
particle_count: sim.particle_count,
|
||||
particle_size: sim.particle_size,
|
||||
gpu_passes: sim.gpu_passes,
|
||||
interaction_steps: sim.interaction_steps,
|
||||
paused: sim.paused,
|
||||
}),
|
||||
Command::Quit => {
|
||||
sim.should_quit = true;
|
||||
Response::ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use crate::SimState;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct PressureBuffer {
|
||||
#[allow(dead_code)]
|
||||
buffer: wgpu::Buffer,
|
||||
size_bytes: u64,
|
||||
}
|
||||
|
||||
pub struct Workload {
|
||||
pressure_buffers: Vec<PressureBuffer>,
|
||||
current_vram_bytes: u64,
|
||||
last_frame: Instant,
|
||||
}
|
||||
|
||||
const CHUNK_SIZE_MB: u64 = 16;
|
||||
const CHUNK_SIZE_BYTES: u64 = CHUNK_SIZE_MB * 1024 * 1024;
|
||||
const MAX_ADD_CHUNKS_PER_FRAME: usize = 8;
|
||||
const MAX_REMOVE_CHUNKS_PER_FRAME: usize = 32;
|
||||
|
||||
impl Workload {
|
||||
pub fn new(_device: &wgpu::Device) -> Self {
|
||||
Self {
|
||||
pressure_buffers: Vec::new(),
|
||||
current_vram_bytes: 0,
|
||||
last_frame: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, device: &wgpu::Device, state: &SimState) {
|
||||
self.adjust_vram_pressure(device, state.vram_pressure_mb);
|
||||
self.enforce_fps_cap(state.fps_cap);
|
||||
}
|
||||
|
||||
fn adjust_vram_pressure(&mut self, device: &wgpu::Device, target_mb: u32) {
|
||||
let target_bytes = target_mb as u64 * 1024 * 1024;
|
||||
|
||||
let mut added = 0;
|
||||
while added < MAX_ADD_CHUNKS_PER_FRAME
|
||||
&& self.current_vram_bytes + CHUNK_SIZE_BYTES <= target_bytes
|
||||
{
|
||||
self.add_chunk(device);
|
||||
added += 1;
|
||||
}
|
||||
|
||||
let mut removed = 0;
|
||||
while removed < MAX_REMOVE_CHUNKS_PER_FRAME && self.current_vram_bytes > target_bytes {
|
||||
if let Some(chunk) = self.pressure_buffers.pop() {
|
||||
self.current_vram_bytes -= chunk.size_bytes;
|
||||
removed += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_chunk(&mut self, device: &wgpu::Device) {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("vram_pressure"),
|
||||
size: CHUNK_SIZE_BYTES,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: true,
|
||||
});
|
||||
|
||||
{
|
||||
let mut mapped = buffer.slice(..).get_mapped_range_mut();
|
||||
for byte in mapped.iter_mut() {
|
||||
*byte = 0xA5;
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
|
||||
self.pressure_buffers.push(PressureBuffer {
|
||||
buffer,
|
||||
size_bytes: CHUNK_SIZE_BYTES,
|
||||
});
|
||||
self.current_vram_bytes += CHUNK_SIZE_BYTES;
|
||||
}
|
||||
|
||||
fn enforce_fps_cap(&mut self, fps_cap: u32) {
|
||||
if fps_cap == 0 {
|
||||
self.last_frame = Instant::now();
|
||||
return;
|
||||
}
|
||||
|
||||
let frame_budget = Duration::from_secs_f64(1.0 / fps_cap as f64);
|
||||
let elapsed = self.last_frame.elapsed();
|
||||
|
||||
if elapsed < frame_budget {
|
||||
let remaining = frame_budget - elapsed;
|
||||
if fps_cap <= 10 && remaining > Duration::from_millis(2) {
|
||||
std::thread::sleep(remaining - Duration::from_millis(1));
|
||||
}
|
||||
while self.last_frame.elapsed() < frame_budget {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
|
||||
self.last_frame = Instant::now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use mangotune::config::parser::Parser;
|
||||
use mangotune::preview::{PreviewController, PreviewScene, PreviewStudioOptions, StudioScene};
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let profile = args
|
||||
.next()
|
||||
.map(PathBuf::from)
|
||||
.expect("usage: preview_probe <profile> [studio-scene] [width] [height] [seconds]");
|
||||
let studio_scene = args
|
||||
.next()
|
||||
.as_deref()
|
||||
.and_then(StudioScene::from_label)
|
||||
.unwrap_or(StudioScene::BrightWash);
|
||||
let width = args
|
||||
.next()
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(1400);
|
||||
let height = args
|
||||
.next()
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(760);
|
||||
let seconds = args.next().and_then(|v| v.parse::<u64>().ok()).unwrap_or(8);
|
||||
|
||||
let config = Parser::read(&profile)?;
|
||||
let controller = PreviewController::new();
|
||||
let studio = PreviewStudioOptions {
|
||||
scene: studio_scene,
|
||||
fps_cap: Some(500),
|
||||
vsync: false,
|
||||
vram_pressure_mb: 64,
|
||||
particle_count: 20_000,
|
||||
particle_size: 0.25,
|
||||
gpu_passes: 2,
|
||||
interaction_steps: 8,
|
||||
paused: false,
|
||||
};
|
||||
|
||||
let pid = controller.start(PreviewScene::Studio, &config, width, height, false, studio)?;
|
||||
eprintln!("preview pid={pid} profile={}", profile.display());
|
||||
thread::sleep(Duration::from_secs(seconds));
|
||||
let _ = controller.stop();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OptionHelp {
|
||||
pub summary: String,
|
||||
pub notes: String,
|
||||
pub option_type: String,
|
||||
pub default_value: String,
|
||||
}
|
||||
|
||||
static OPTION_HELP: Lazy<HashMap<String, OptionHelp>> = Lazy::new(|| {
|
||||
parse_option_help(include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/MANGOHUD_OPTION_BEHAVIOR.md"
|
||||
)))
|
||||
});
|
||||
|
||||
pub fn option_help_for_key(key: &str) -> Option<OptionHelp> {
|
||||
OPTION_HELP.get(key).cloned()
|
||||
}
|
||||
|
||||
pub fn display_title_for_key(key: &str) -> String {
|
||||
match key {
|
||||
"af" => "Anisotropic filtering".to_string(),
|
||||
"alpha" => "HUD alpha".to_string(),
|
||||
"arch" => "System architecture".to_string(),
|
||||
"autostart_log" => "Auto-start logging".to_string(),
|
||||
"background_alpha" => "Background alpha".to_string(),
|
||||
"background_color" => "Background color".to_string(),
|
||||
"battery_icon" => "Battery icon".to_string(),
|
||||
"battery_time" => "Battery time".to_string(),
|
||||
"battery_watt" => "Battery wattage".to_string(),
|
||||
"control" => "Control socket".to_string(),
|
||||
"cpu_custom_temp_sensor" => "Custom CPU temperature sensor".to_string(),
|
||||
"cpu_load_change" => "Enable CPU load threshold colors".to_string(),
|
||||
"cpu_load_color" => "CPU load threshold colors".to_string(),
|
||||
"cpu_load_value" => "CPU load threshold values".to_string(),
|
||||
"cpu_mhz" => "CPU clock".to_string(),
|
||||
"cpu_power" => "CPU power".to_string(),
|
||||
"cpu_stats" => "CPU stats".to_string(),
|
||||
"cpu_temp" => "CPU temperature".to_string(),
|
||||
"cpu_text" => "CPU label".to_string(),
|
||||
"custom_text" => "Custom text".to_string(),
|
||||
"custom_text_center" => "Centered custom text".to_string(),
|
||||
"device_battery" => "Device battery sources".to_string(),
|
||||
"device_battery_icon" => "Device battery icons".to_string(),
|
||||
"display_server" => "Display server".to_string(),
|
||||
"dx_api" => "DirectX API".to_string(),
|
||||
"engine_short_names" => "Short engine names".to_string(),
|
||||
"engine_version" => "Engine version".to_string(),
|
||||
"exec_name" => "Executable name".to_string(),
|
||||
"fcat_overlay_width" => "FCAT overlay width".to_string(),
|
||||
"flip_efficiency" => "Flip efficiency".to_string(),
|
||||
"font_file" => "Font file".to_string(),
|
||||
"font_file_text" => "Font file label".to_string(),
|
||||
"font_glyph_ranges" => "Font glyph ranges".to_string(),
|
||||
"font_scale" => "Font scale".to_string(),
|
||||
"font_scale_media_player" => "Media player font scale".to_string(),
|
||||
"font_size" => "Font size".to_string(),
|
||||
"font_size_secondary" => "Secondary font size".to_string(),
|
||||
"font_size_text" => "Custom text size".to_string(),
|
||||
"fps" => "FPS counter".to_string(),
|
||||
"fps_color" => "FPS threshold colors".to_string(),
|
||||
"fps_color_change" => "Enable FPS threshold colors".to_string(),
|
||||
"fps_limit" => "FPS limits".to_string(),
|
||||
"fps_limit_method" => "FPS limit method".to_string(),
|
||||
"fps_metrics" => "FPS metrics".to_string(),
|
||||
"fps_only" => "FPS-only mode".to_string(),
|
||||
"fps_sampling_period" => "FPS sampling period".to_string(),
|
||||
"fps_text" => "FPS label".to_string(),
|
||||
"fps_value" => "FPS threshold values".to_string(),
|
||||
"frame_count" => "Frame count".to_string(),
|
||||
"frame_timing" => "Frametime graph".to_string(),
|
||||
"frame_timing_detailed" => "Detailed frametime graph".to_string(),
|
||||
"frametime" => "Frametime readout".to_string(),
|
||||
"frametime_color" => "Frametime color".to_string(),
|
||||
"fsr_steam_sharpness" => "Steam FSR sharpness".to_string(),
|
||||
"ftrace" => "Ftrace probes".to_string(),
|
||||
"full" => "Full preset".to_string(),
|
||||
"gl_bind_framebuffer" => "OpenGL framebuffer binding".to_string(),
|
||||
"gl_dont_flip" => "OpenGL no-flip mode".to_string(),
|
||||
"gl_size_query" => "OpenGL size query".to_string(),
|
||||
"gl_vsync" => "OpenGL VSync".to_string(),
|
||||
"gpu_core_clock" => "GPU core clock".to_string(),
|
||||
"gpu_efficiency" => "GPU efficiency".to_string(),
|
||||
"gpu_fan" => "GPU fan".to_string(),
|
||||
"gpu_junction_temp" => "GPU hotspot temperature".to_string(),
|
||||
"gpu_list" => "GPU list".to_string(),
|
||||
"gpu_load_change" => "Enable GPU load threshold colors".to_string(),
|
||||
"gpu_load_color" => "GPU load threshold colors".to_string(),
|
||||
"gpu_load_value" => "GPU load threshold values".to_string(),
|
||||
"gpu_mem_clock" => "GPU memory clock".to_string(),
|
||||
"gpu_mem_temp" => "GPU memory temperature".to_string(),
|
||||
"gpu_name" => "GPU name".to_string(),
|
||||
"gpu_power" => "GPU power".to_string(),
|
||||
"gpu_power_limit" => "GPU power limit".to_string(),
|
||||
"gpu_stats" => "GPU stats".to_string(),
|
||||
"gpu_temp" => "GPU temperature".to_string(),
|
||||
"gpu_text" => "GPU labels".to_string(),
|
||||
"gpu_voltage" => "GPU voltage".to_string(),
|
||||
"hdr" => "HDR status".to_string(),
|
||||
"hide_engine_names" => "Hide engine names".to_string(),
|
||||
"hide_fps_superscript" => "Hide FPS superscript".to_string(),
|
||||
"hide_fsr_sharpness" => "Hide FSR sharpness".to_string(),
|
||||
"hud_compact" => "Compact HUD".to_string(),
|
||||
"hud_no_margin" => "No HUD margin".to_string(),
|
||||
"horizontal" => "Horizontal layout".to_string(),
|
||||
"horizontal_separator_color" => "Separator color".to_string(),
|
||||
"horizontal_stretch" => "Stretch horizontal HUD".to_string(),
|
||||
"inherit" => "Preset inheritance".to_string(),
|
||||
"io_read" => "Disk read throughput".to_string(),
|
||||
"io_write" => "Disk write throughput".to_string(),
|
||||
"legacy_layout" => "Legacy layout".to_string(),
|
||||
"help" => "Print MangoHud help".to_string(),
|
||||
"log_duration" => "Log duration".to_string(),
|
||||
"log_interval" => "Log interval".to_string(),
|
||||
"log_versioning" => "Versioned log names".to_string(),
|
||||
"mangoapp_steam" => "MangoApp Steam mode".to_string(),
|
||||
"media_player" => "Media player".to_string(),
|
||||
"media_player_format" => "Media player format".to_string(),
|
||||
"media_player_name" => "Media player source".to_string(),
|
||||
"network" => "Network interfaces".to_string(),
|
||||
"network_color" => "Network color".to_string(),
|
||||
"no_display" => "Start hidden".to_string(),
|
||||
"no_small_font" => "Disable small font".to_string(),
|
||||
"offset_x" => "Horizontal offset".to_string(),
|
||||
"offset_y" => "Vertical offset".to_string(),
|
||||
"output_file" => "Output file".to_string(),
|
||||
"output_folder" => "Output folder".to_string(),
|
||||
"pci_dev" => "PCI device".to_string(),
|
||||
"permit_upload" => "Allow uploads".to_string(),
|
||||
"picmip" => "Mip-map bias".to_string(),
|
||||
"preset" => "Preset list".to_string(),
|
||||
"position" => "HUD position".to_string(),
|
||||
"present_mode" => "Present mode".to_string(),
|
||||
"proc_vram" => "Process VRAM".to_string(),
|
||||
"procmem" => "Process memory".to_string(),
|
||||
"procmem_shared" => "Shared process memory".to_string(),
|
||||
"procmem_virt" => "Virtual process memory".to_string(),
|
||||
"ram" => "RAM usage".to_string(),
|
||||
"ram_temp" => "RAM temperature".to_string(),
|
||||
"read_cfg" => "Also read config file".to_string(),
|
||||
"refresh_rate" => "Refresh rate".to_string(),
|
||||
"reload_cfg" => "Reload config".to_string(),
|
||||
"reset_fps_metrics" => "Reset FPS metrics".to_string(),
|
||||
"show_fps_limit" => "Show FPS limit".to_string(),
|
||||
"table_columns" => "Table columns".to_string(),
|
||||
"temp_fahrenheit" => "Use Fahrenheit".to_string(),
|
||||
"text_color" => "Text color".to_string(),
|
||||
"text_outline" => "Text outline".to_string(),
|
||||
"text_outline_color" => "Text outline color".to_string(),
|
||||
"text_outline_thickness" => "Text outline thickness".to_string(),
|
||||
"time_no_label" => "Hide time label".to_string(),
|
||||
"time_format" => "Time format".to_string(),
|
||||
"toggle_fps_limit" => "Cycle FPS limit".to_string(),
|
||||
"toggle_hud" => "Toggle HUD".to_string(),
|
||||
"toggle_hud_position" => "Cycle HUD position".to_string(),
|
||||
"toggle_logging" => "Toggle logging".to_string(),
|
||||
"toggle_preset" => "Cycle preset".to_string(),
|
||||
"upload_log" => "Upload log".to_string(),
|
||||
"upload_logs" => "Upload all logs".to_string(),
|
||||
"version" => "MangoHud version".to_string(),
|
||||
"vkbasalt" => "vkBasalt status".to_string(),
|
||||
"vram" => "VRAM usage".to_string(),
|
||||
"vulkan_driver" => "Vulkan driver".to_string(),
|
||||
"vulkan_present_mode" => "Preferred Vulkan present mode".to_string(),
|
||||
"vsync" => "VSync".to_string(),
|
||||
_ => default_display_title_for_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_summary_for_key(key: &str) -> String {
|
||||
match key {
|
||||
"position" => "Choose which edge or corner MangoHud anchors to on screen.".to_string(),
|
||||
"horizontal" => {
|
||||
"Lay the HUD out in a single horizontal strip instead of the default stacked layout."
|
||||
.to_string()
|
||||
}
|
||||
"horizontal_stretch" => {
|
||||
"Keep a horizontal HUD stretched wide instead of shrinking it to fit its contents."
|
||||
.to_string()
|
||||
}
|
||||
"hud_no_margin" => {
|
||||
"Remove MangoHud's usual edge padding so the HUD hugs the screen border."
|
||||
.to_string()
|
||||
}
|
||||
"hud_compact" => {
|
||||
"Use a tighter layout with less spacing between labels and values.".to_string()
|
||||
}
|
||||
"preset" => {
|
||||
"Apply one or more MangoHud presets by name or built-in preset number."
|
||||
.to_string()
|
||||
}
|
||||
"width" => {
|
||||
"Set a fixed HUD width, or leave it at 0 to let MangoHud size it automatically."
|
||||
.to_string()
|
||||
}
|
||||
"offset_x" => {
|
||||
"Shift the HUD horizontally from its anchor point. Positive offsets also remove the normal edge margin."
|
||||
.to_string()
|
||||
}
|
||||
"offset_y" => {
|
||||
"Shift the HUD vertically from its anchor point. Positive offsets also remove the normal edge margin."
|
||||
.to_string()
|
||||
}
|
||||
"background_alpha" => {
|
||||
"Control how opaque the HUD background panel looks behind the text.".to_string()
|
||||
}
|
||||
"alpha" => "Control the overall opacity of the entire HUD.".to_string(),
|
||||
"round_corners" => {
|
||||
"Round the HUD background corners for a softer panel shape.".to_string()
|
||||
}
|
||||
"font_size" => "Set the base HUD text size in pixels.".to_string(),
|
||||
"font_scale" => {
|
||||
"Scale the whole HUD up or down without changing each font size separately."
|
||||
.to_string()
|
||||
}
|
||||
"text_outline" => {
|
||||
"Draw a stroke around text so it stays readable over bright or noisy scenes."
|
||||
.to_string()
|
||||
}
|
||||
"text_outline_thickness" => {
|
||||
"Control how thick the text outline appears.".to_string()
|
||||
}
|
||||
"fps" => "Show the live FPS counter.".to_string(),
|
||||
"fps_color_change" => {
|
||||
"Turn on color switching for the FPS readout based on the threshold values and colors below."
|
||||
.to_string()
|
||||
}
|
||||
"fps_value" => {
|
||||
"Enter comma-separated FPS cutoffs like 30,60. MangoHud matches these numbers against the FPS threshold colors in order."
|
||||
.to_string()
|
||||
}
|
||||
"fps_color" => {
|
||||
"Enter comma-separated hex colors like FF4D4D,FFD24D,66FF99 to pair with the FPS threshold values."
|
||||
.to_string()
|
||||
}
|
||||
"frametime" => "Show the current frametime readout.".to_string(),
|
||||
"frame_timing" => "Show the frametime graph.".to_string(),
|
||||
"frame_timing_detailed" => "Use the more detailed frametime graph style.".to_string(),
|
||||
"fps_limit" => {
|
||||
"Define one or more FPS caps that MangoHud can cycle through.".to_string()
|
||||
}
|
||||
"fps_limit_method" => {
|
||||
"Choose how MangoHud applies the FPS limiter.".to_string()
|
||||
}
|
||||
"gpu_stats" => {
|
||||
"Show the main GPU stats block, including usage readouts on MangoHud builds that expose them there."
|
||||
.to_string()
|
||||
}
|
||||
"gpu_load_change" => {
|
||||
"Turn on color switching for the GPU load readout based on the threshold values and colors below."
|
||||
.to_string()
|
||||
}
|
||||
"gpu_load_value" => {
|
||||
"Enter comma-separated GPU usage percentages like 50,80. MangoHud matches these numbers against the GPU load threshold colors in order."
|
||||
.to_string()
|
||||
}
|
||||
"gpu_load_color" => {
|
||||
"Enter comma-separated hex colors like 66FF99,FFD24D,FF4D4D to pair with the GPU load threshold values."
|
||||
.to_string()
|
||||
}
|
||||
"gpu_temp" => "Show GPU temperature.".to_string(),
|
||||
"gpu_power" => "Show GPU power draw.".to_string(),
|
||||
"gpu_mem_clock" => "Show GPU memory clock speed.".to_string(),
|
||||
"gpu_mem_temp" => "Show GPU memory temperature.".to_string(),
|
||||
"cpu_custom_temp_sensor" => {
|
||||
"Override which hardware sensor MangoHud uses for CPU temperature.".to_string()
|
||||
}
|
||||
"cpu_load_change" => {
|
||||
"Turn on color switching for the CPU load readout based on the threshold values and colors below."
|
||||
.to_string()
|
||||
}
|
||||
"cpu_load_value" => {
|
||||
"Enter comma-separated CPU usage percentages like 50,80. MangoHud matches these numbers against the CPU load threshold colors in order."
|
||||
.to_string()
|
||||
}
|
||||
"cpu_load_color" => {
|
||||
"Enter comma-separated hex colors like 66FF99,FFD24D,FF4D4D to pair with the CPU load threshold values."
|
||||
.to_string()
|
||||
}
|
||||
"cpu_stats" => {
|
||||
"Show the main CPU stats block, including usage readouts on MangoHud builds that expose them there."
|
||||
.to_string()
|
||||
}
|
||||
"cpu_temp" => "Show CPU temperature.".to_string(),
|
||||
"cpu_power" => "Show CPU power draw.".to_string(),
|
||||
"ram" => "Show system RAM usage.".to_string(),
|
||||
"ram_temp" => "Show RAM temperature when MangoHud can read it.".to_string(),
|
||||
"vram" => "Show VRAM usage.".to_string(),
|
||||
"dx_api" => "Show which DirectX API a Wine or Proton title is using.".to_string(),
|
||||
"network" => "Show network activity for selected interfaces.".to_string(),
|
||||
"io_read" => "Show disk read throughput.".to_string(),
|
||||
"io_write" => "Show disk write throughput.".to_string(),
|
||||
"media_player" => "Show now-playing media information when supported.".to_string(),
|
||||
"battery" => "Show battery information when available.".to_string(),
|
||||
"no_display" => {
|
||||
"Start MangoHud hidden until you toggle it with your HUD keybind.".to_string()
|
||||
}
|
||||
"read_cfg" => {
|
||||
"When using MANGOHUD_CONFIG in the environment, still read the config file too."
|
||||
.to_string()
|
||||
}
|
||||
"inherit" => {
|
||||
"Use MangoHud's preset inheritance directive when building layered presets."
|
||||
.to_string()
|
||||
}
|
||||
"help" => {
|
||||
"Print MangoHud's supported parameters and exit instead of showing the overlay."
|
||||
.to_string()
|
||||
}
|
||||
"full" => {
|
||||
"Turn on most MangoHud stats at once. Good for discovery, but usually too busy for daily use."
|
||||
.to_string()
|
||||
}
|
||||
"fps_only" => {
|
||||
"Show only the FPS-focused view and suppress most other HUD sections.".to_string()
|
||||
}
|
||||
"toggle_hud" => "Show or hide MangoHud while a game is running.".to_string(),
|
||||
"toggle_hud_position" => {
|
||||
"Cycle through MangoHud positions while a game is running.".to_string()
|
||||
}
|
||||
"toggle_preset" => "Cycle through MangoHud presets in game.".to_string(),
|
||||
"toggle_fps_limit" => "Cycle between configured FPS caps in game.".to_string(),
|
||||
"toggle_logging" => "Start or stop MangoHud logging.".to_string(),
|
||||
"reload_cfg" => "Reload the current MangoHud config without restarting the game.".to_string(),
|
||||
"upload_log" => "Upload the current MangoHud log.".to_string(),
|
||||
"upload_logs" => "Upload all recent MangoHud logs.".to_string(),
|
||||
"font_size_secondary" => {
|
||||
"Set the smaller secondary text size used by some MangoHud labels.".to_string()
|
||||
}
|
||||
"vulkan_present_mode" => {
|
||||
"Request a specific Vulkan present mode by name when the backend supports it."
|
||||
.to_string()
|
||||
}
|
||||
"fsr_steam_sharpness" => {
|
||||
"Set Steam FSR sharpness directly when the runtime exposes that control."
|
||||
.to_string()
|
||||
}
|
||||
"hide_engine_names" => {
|
||||
"Hide engine names even when engine-related readouts are enabled.".to_string()
|
||||
}
|
||||
"hide_fps_superscript" => {
|
||||
"Remove the small FPS superscript styling from the counter.".to_string()
|
||||
}
|
||||
"duration" => "Track benchmark duration-style timing data.".to_string(),
|
||||
_ => option_help_for_key(key)
|
||||
.map(|help| help.summary)
|
||||
.unwrap_or_else(|| fallback_summary(key)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_option_help(markdown: &str) -> HashMap<String, OptionHelp> {
|
||||
let mut out = HashMap::new();
|
||||
|
||||
for line in markdown.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with('|') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cells = trimmed
|
||||
.split('|')
|
||||
.map(normalize_cell)
|
||||
.collect::<Vec<String>>();
|
||||
if cells.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Leading/trailing separators create empty cells at [0] and [last].
|
||||
let key = cells[1].as_str();
|
||||
let option_type = cells[2].as_str();
|
||||
let default_value = cells[3].as_str();
|
||||
let notes = cells[4].as_str();
|
||||
|
||||
if key.is_empty()
|
||||
|| key.eq_ignore_ascii_case("key")
|
||||
|| key.chars().all(|ch| ch == '-')
|
||||
|| option_type.chars().all(|ch| ch == '-')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
out.entry(key.to_string()).or_insert_with(|| {
|
||||
let normalized_notes = collapse_ws(notes);
|
||||
OptionHelp {
|
||||
summary: summarize_notes(key, &normalized_notes),
|
||||
notes: normalized_notes,
|
||||
option_type: collapse_ws(option_type),
|
||||
default_value: collapse_ws(default_value),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn normalize_cell(cell: &str) -> String {
|
||||
let trimmed = cell.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let without_backticks = trimmed.replace('`', "");
|
||||
let without_bold = without_backticks.replace("**", "");
|
||||
collapse_ws(&without_bold.replace("<br>", " "))
|
||||
}
|
||||
|
||||
fn collapse_ws(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn summarize_notes(key: &str, notes: &str) -> String {
|
||||
if notes.is_empty() {
|
||||
return fallback_summary(key);
|
||||
}
|
||||
|
||||
let summary = notes
|
||||
.split([';', '.'])
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if summary.len() >= 12 {
|
||||
summary.to_string()
|
||||
} else {
|
||||
notes.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_summary(key: &str) -> String {
|
||||
let text = display_title_for_key(key);
|
||||
format!("Controls {text}")
|
||||
}
|
||||
|
||||
fn default_display_title_for_key(key: &str) -> String {
|
||||
key.split('_')
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| match part {
|
||||
"fps" => "FPS".to_string(),
|
||||
"cpu" => "CPU".to_string(),
|
||||
"gpu" => "GPU".to_string(),
|
||||
"vram" => "VRAM".to_string(),
|
||||
"ram" => "RAM".to_string(),
|
||||
"io" => "I/O".to_string(),
|
||||
"gl" => "OpenGL".to_string(),
|
||||
"fsr" => "FSR".to_string(),
|
||||
"hdr" => "HDR".to_string(),
|
||||
"vk" => "Vulkan".to_string(),
|
||||
"api" => "API".to_string(),
|
||||
other => {
|
||||
let mut chars = other.chars();
|
||||
match chars.next() {
|
||||
Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_markdown_table_rows() {
|
||||
let markdown = r#"
|
||||
| Key | Type | Default | Notes |
|
||||
|-----|------|---------|-------|
|
||||
| `fps` | Flag | present | Show FPS counter |
|
||||
"#;
|
||||
let parsed = parse_option_help(markdown);
|
||||
let fps = parsed.get("fps").expect("fps help");
|
||||
assert_eq!(fps.option_type, "Flag");
|
||||
assert_eq!(fps.default_value, "present");
|
||||
assert_eq!(fps.summary, "Show FPS counter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_real_schema_doc() {
|
||||
let fps = option_help_for_key("fps").expect("help for fps");
|
||||
assert!(!fps.notes.is_empty());
|
||||
assert!(!fps.option_type.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_title_handles_common_technical_terms() {
|
||||
assert_eq!(display_title_for_key("gpu_mem_clock"), "GPU memory clock");
|
||||
assert_eq!(
|
||||
display_title_for_key("fps_limit_method"),
|
||||
"FPS limit method"
|
||||
);
|
||||
assert_eq!(
|
||||
display_title_for_key("toggle_hud_position"),
|
||||
"Cycle HUD position"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_summary_prefers_friendly_ui_copy() {
|
||||
assert!(display_summary_for_key("full").contains("too busy"));
|
||||
assert!(display_summary_for_key("position").contains("anchors"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod help;
|
||||
pub mod normalize;
|
||||
pub mod parser;
|
||||
pub mod resolver;
|
||||
pub mod schema;
|
||||
pub mod types;
|
||||
pub mod validator;
|
||||
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,179 @@
|
||||
use crate::config::parser::Parser;
|
||||
use crate::config::schema::get_schema_entry;
|
||||
use crate::config::types::{AnnotatedConfig, ConfigLine, ConfigValue, OptionType};
|
||||
|
||||
const INVALID_TOP_LEVEL_KEYS: &[&str] = &["gpu_load", "cpu_load"];
|
||||
const LEGACY_OPTION_ALIASES: &[(&str, &str)] = &[
|
||||
("compact", "hud_compact"),
|
||||
("stretch", "horizontal_stretch"),
|
||||
];
|
||||
|
||||
pub fn normalize_legacy_option_values(config: &mut AnnotatedConfig) -> usize {
|
||||
let mut changes = 0usize;
|
||||
|
||||
for (legacy_key, canonical_key) in LEGACY_OPTION_ALIASES {
|
||||
let Some((line_idx, legacy_value)) = config.options.get(*legacy_key).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !config.options.contains_key(*canonical_key) {
|
||||
Parser::set_value(config, canonical_key, legacy_value);
|
||||
}
|
||||
|
||||
config.options.shift_remove(*legacy_key);
|
||||
if let Some(line) = config.lines.get_mut(line_idx) {
|
||||
*line = ConfigLine::Blank;
|
||||
}
|
||||
config.dirty = true;
|
||||
changes += 1;
|
||||
}
|
||||
|
||||
for key in INVALID_TOP_LEVEL_KEYS {
|
||||
if let Some((line_idx, _)) = config.options.shift_remove(*key) {
|
||||
if let Some(line) = config.lines.get_mut(line_idx) {
|
||||
*line = ConfigLine::Blank;
|
||||
}
|
||||
config.dirty = true;
|
||||
changes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let keys = config.options.keys().cloned().collect::<Vec<_>>();
|
||||
|
||||
for key in keys {
|
||||
let Some(schema) = get_schema_entry(&key) else {
|
||||
continue;
|
||||
};
|
||||
let Some((_, current)) = config.options.get(&key).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match (&schema.option_type, current) {
|
||||
(_, ConfigValue::Value(raw)) if matches!(key.as_str(), "offset_x" | "offset_y") => {
|
||||
if let Ok(parsed) = raw.trim().parse::<i32>() {
|
||||
if parsed < 0 {
|
||||
Parser::set_value(
|
||||
config,
|
||||
&key,
|
||||
ConfigValue::Value(parsed.abs().to_string()),
|
||||
);
|
||||
changes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(OptionType::Flag, ConfigValue::Value(raw)) => {
|
||||
let normalized = raw.trim().to_ascii_lowercase();
|
||||
if matches!(normalized.as_str(), "1" | "true" | "yes" | "on") {
|
||||
Parser::set_value(config, &key, ConfigValue::Flag);
|
||||
changes += 1;
|
||||
} else if matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off") {
|
||||
Parser::set_value(config, &key, ConfigValue::Disabled);
|
||||
changes += 1;
|
||||
}
|
||||
}
|
||||
(OptionType::Bool, ConfigValue::Flag) => {
|
||||
Parser::set_value(config, &key, ConfigValue::Value("1".to_string()));
|
||||
changes += 1;
|
||||
}
|
||||
(OptionType::Bool, ConfigValue::Value(raw)) => {
|
||||
let normalized = raw.trim().to_ascii_lowercase();
|
||||
if matches!(normalized.as_str(), "true" | "yes" | "on") {
|
||||
Parser::set_value(config, &key, ConfigValue::Value("1".to_string()));
|
||||
changes += 1;
|
||||
} else if matches!(normalized.as_str(), "false" | "no" | "off") {
|
||||
Parser::set_value(config, &key, ConfigValue::Value("0".to_string()));
|
||||
changes += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
changes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_legacy_option_values;
|
||||
use crate::config::parser::Parser;
|
||||
use crate::config::types::ConfigValue;
|
||||
|
||||
#[test]
|
||||
fn normalizes_flag_zero_to_disabled() {
|
||||
let mut config = Parser::parse_str("horizontal_stretch=0\n", None);
|
||||
let changed = normalize_legacy_option_values(&mut config);
|
||||
assert_eq!(changed, 1);
|
||||
assert!(matches!(
|
||||
config.options.get("horizontal_stretch").map(|item| &item.1),
|
||||
Some(ConfigValue::Disabled)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_flag_one_to_flag() {
|
||||
let mut config = Parser::parse_str("horizontal_stretch=1\n", None);
|
||||
let changed = normalize_legacy_option_values(&mut config);
|
||||
assert_eq!(changed, 1);
|
||||
assert!(matches!(
|
||||
config.options.get("horizontal_stretch").map(|item| &item.1),
|
||||
Some(ConfigValue::Flag)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_invalid_load_keys_from_config() {
|
||||
let mut config = Parser::parse_str("gpu_load\ncpu_load\nfps\n", None);
|
||||
let changed = normalize_legacy_option_values(&mut config);
|
||||
assert_eq!(changed, 2);
|
||||
assert!(!config.options.contains_key("gpu_load"));
|
||||
assert!(!config.options.contains_key("cpu_load"));
|
||||
let serialized = Parser::to_string(&config);
|
||||
assert!(!serialized.contains("gpu_load"));
|
||||
assert!(!serialized.contains("cpu_load"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_negative_offsets_to_positive_values() {
|
||||
let mut config = Parser::parse_str("offset_x=-12\noffset_y=-7\n", None);
|
||||
let changed = normalize_legacy_option_values(&mut config);
|
||||
assert_eq!(changed, 2);
|
||||
assert!(matches!(
|
||||
config.options.get("offset_x").map(|item| &item.1),
|
||||
Some(ConfigValue::Value(value)) if value == "12"
|
||||
));
|
||||
assert!(matches!(
|
||||
config.options.get("offset_y").map(|item| &item.1),
|
||||
Some(ConfigValue::Value(value)) if value == "7"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_compact_to_hud_compact() {
|
||||
let mut config = Parser::parse_str("compact\nfps\n", None);
|
||||
let changed = normalize_legacy_option_values(&mut config);
|
||||
assert_eq!(changed, 1);
|
||||
assert!(!config.options.contains_key("compact"));
|
||||
assert!(matches!(
|
||||
config.options.get("hud_compact").map(|item| &item.1),
|
||||
Some(ConfigValue::Flag)
|
||||
));
|
||||
let serialized = Parser::to_string(&config);
|
||||
assert!(!serialized.contains("\ncompact\n"));
|
||||
assert!(serialized.contains("hud_compact"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_stretch_to_horizontal_stretch_without_overwriting_existing_key() {
|
||||
let mut config = Parser::parse_str("stretch=0\nhorizontal_stretch=0\n", None);
|
||||
let changed = normalize_legacy_option_values(&mut config);
|
||||
assert_eq!(changed, 2);
|
||||
assert!(!config.options.contains_key("stretch"));
|
||||
assert!(matches!(
|
||||
config.options.get("horizontal_stretch").map(|item| &item.1),
|
||||
Some(ConfigValue::Disabled)
|
||||
));
|
||||
let serialized = Parser::to_string(&config);
|
||||
assert!(!serialized.lines().any(|line| line.trim() == "stretch=0"));
|
||||
assert_eq!(serialized.matches("horizontal_stretch=0").count(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
//! MangoHud config file parser and writer.
|
||||
|
||||
use crate::config::types::{AnnotatedConfig, ConfigLine, ConfigValue};
|
||||
use anyhow::{Context, Result};
|
||||
use indexmap::IndexMap;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
static KEY_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$").expect("valid key regex"));
|
||||
|
||||
pub struct Parser;
|
||||
|
||||
impl Parser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Parse a config file from disk.
|
||||
pub fn read(path: &Path) -> Result<AnnotatedConfig> {
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read config file {}", path.display()))?;
|
||||
Ok(Self::parse_str(&content, Some(path.to_path_buf())))
|
||||
}
|
||||
|
||||
/// Parse config from a string (for env var inline and tests).
|
||||
pub fn parse_str(content: &str, path: Option<PathBuf>) -> AnnotatedConfig {
|
||||
let mut lines = Vec::new();
|
||||
let mut options: IndexMap<String, (usize, ConfigValue)> = IndexMap::new();
|
||||
|
||||
for raw_line in content.lines() {
|
||||
let idx = lines.len();
|
||||
if raw_line.trim().is_empty() {
|
||||
lines.push(ConfigLine::Blank);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(comment) = raw_line.strip_prefix('#') {
|
||||
let candidate = comment.trim_start();
|
||||
if let Some((key, value)) = parse_option_candidate(candidate) {
|
||||
let line = ConfigLine::CommentedOption {
|
||||
key: key.clone(),
|
||||
value: value.clone(),
|
||||
raw: raw_line.to_string(),
|
||||
};
|
||||
if let Some((old_idx, _)) =
|
||||
options.insert(key.clone(), (idx, ConfigValue::Disabled))
|
||||
{
|
||||
if let Some(old_line) = lines.get_mut(old_idx) {
|
||||
*old_line = ConfigLine::Blank;
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
} else {
|
||||
lines.push(ConfigLine::Comment(raw_line.to_string()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, value)) = parse_option_candidate(raw_line) {
|
||||
let cfg_value = match value.clone() {
|
||||
Some(v) => ConfigValue::Value(v),
|
||||
None => ConfigValue::Flag,
|
||||
};
|
||||
let line = ConfigLine::Option {
|
||||
key: key.clone(),
|
||||
value,
|
||||
raw: raw_line.to_string(),
|
||||
};
|
||||
if let Some((old_idx, _)) = options.insert(key.clone(), (idx, cfg_value.clone())) {
|
||||
if let Some(old_line) = lines.get_mut(old_idx) {
|
||||
*old_line = ConfigLine::Blank;
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(ConfigLine::Comment(raw_line.to_string()));
|
||||
}
|
||||
|
||||
AnnotatedConfig {
|
||||
lines,
|
||||
options,
|
||||
path,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an AnnotatedConfig back to disk safely.
|
||||
pub fn write(config: &AnnotatedConfig) -> Result<()> {
|
||||
let path = config
|
||||
.path
|
||||
.as_ref()
|
||||
.context("cannot write config without a backing file path")?;
|
||||
let backup_path = PathBuf::from(format!("{}.mangotune.bak", path.display()));
|
||||
let tmp_path = PathBuf::from(format!("{}.mangotune.tmp", path.display()));
|
||||
let content = Self::to_string(config);
|
||||
debug_log(&format!("parser::write begin path={}", path.display()));
|
||||
let mut backup_created = false;
|
||||
|
||||
if path.exists() {
|
||||
if backup_path.exists() {
|
||||
debug_log(&format!(
|
||||
"parser::write removing stale backup {}",
|
||||
backup_path.display()
|
||||
));
|
||||
if let Err(err) = remove_existing_path(&backup_path) {
|
||||
debug_log(&format!(
|
||||
"parser::write could not remove stale backup {}: {err}",
|
||||
backup_path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
debug_log(&format!(
|
||||
"parser::write copy backup {} -> {}",
|
||||
path.display(),
|
||||
backup_path.display()
|
||||
));
|
||||
match fs::copy(path, &backup_path) {
|
||||
Ok(_) => backup_created = true,
|
||||
Err(err) => debug_log(&format!(
|
||||
"parser::write backup skipped for {}: {err}",
|
||||
backup_path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
debug_log(&format!("parser::write write temp {}", tmp_path.display()));
|
||||
let write_res = fs::write(&tmp_path, content)
|
||||
.with_context(|| format!("failed writing temp config {}", tmp_path.display()))
|
||||
.and_then(|_| {
|
||||
debug_log(&format!(
|
||||
"parser::write rename temp {} -> {}",
|
||||
tmp_path.display(),
|
||||
path.display()
|
||||
));
|
||||
fs::rename(&tmp_path, path).with_context(|| {
|
||||
format!(
|
||||
"failed to atomically replace config {} with {}",
|
||||
path.display(),
|
||||
tmp_path.display()
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
if let Err(err) = write_res {
|
||||
debug_log(&format!("parser::write failure: {err}"));
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
if backup_created && backup_path.exists() {
|
||||
debug_log(&format!(
|
||||
"parser::write restoring backup {} -> {}",
|
||||
backup_path.display(),
|
||||
path.display()
|
||||
));
|
||||
let _ = fs::copy(&backup_path, path);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
debug_log(&format!("parser::write success path={}", path.display()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a specific key's value in the config lines.
|
||||
pub fn set_value(config: &mut AnnotatedConfig, key: &str, value: ConfigValue) {
|
||||
if let Some((line_idx, _)) = config.options.get(key).cloned() {
|
||||
if let Some(line) = config.lines.get_mut(line_idx) {
|
||||
let prior = extract_prior_value(line);
|
||||
*line = line_from_value(key, &value, prior);
|
||||
match &value {
|
||||
ConfigValue::Absent => {
|
||||
config.options.shift_remove(key);
|
||||
}
|
||||
ConfigValue::Disabled => {
|
||||
config
|
||||
.options
|
||||
.insert(key.to_string(), (line_idx, ConfigValue::Disabled));
|
||||
}
|
||||
ConfigValue::Flag => {
|
||||
config
|
||||
.options
|
||||
.insert(key.to_string(), (line_idx, ConfigValue::Flag));
|
||||
}
|
||||
ConfigValue::Value(v) => {
|
||||
config
|
||||
.options
|
||||
.insert(key.to_string(), (line_idx, ConfigValue::Value(v.clone())));
|
||||
}
|
||||
}
|
||||
config.dirty = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match value {
|
||||
ConfigValue::Absent => {
|
||||
config.options.shift_remove(key);
|
||||
}
|
||||
ConfigValue::Flag | ConfigValue::Value(_) | ConfigValue::Disabled => {
|
||||
let line = line_from_value(key, &value, None);
|
||||
let idx = config.lines.len();
|
||||
config.lines.push(line);
|
||||
config.options.insert(key.to_string(), (idx, value));
|
||||
config.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize config to a string.
|
||||
pub fn to_string(config: &AnnotatedConfig) -> String {
|
||||
let mut out = String::new();
|
||||
for line in &config.lines {
|
||||
match line {
|
||||
ConfigLine::Comment(raw) => out.push_str(raw),
|
||||
ConfigLine::Blank => {}
|
||||
ConfigLine::Option { key, value, .. } => {
|
||||
out.push_str(key);
|
||||
if let Some(v) = value {
|
||||
out.push('=');
|
||||
out.push_str(v);
|
||||
}
|
||||
}
|
||||
ConfigLine::CommentedOption { key, value, .. } => {
|
||||
out.push_str("# ");
|
||||
out.push_str(key);
|
||||
if let Some(v) = value {
|
||||
out.push('=');
|
||||
out.push_str(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn move_option_before(
|
||||
config: &mut AnnotatedConfig,
|
||||
moving_key: &str,
|
||||
anchor_key: &str,
|
||||
) -> bool {
|
||||
move_option_relative(config, moving_key, anchor_key, true)
|
||||
}
|
||||
|
||||
pub fn move_option_after(
|
||||
config: &mut AnnotatedConfig,
|
||||
moving_key: &str,
|
||||
anchor_key: &str,
|
||||
) -> bool {
|
||||
move_option_relative(config, moving_key, anchor_key, false)
|
||||
}
|
||||
|
||||
pub fn move_option_group_before(
|
||||
config: &mut AnnotatedConfig,
|
||||
moving_keys: &[String],
|
||||
anchor_keys: &[String],
|
||||
) -> bool {
|
||||
move_option_group_relative(config, moving_keys, anchor_keys, true)
|
||||
}
|
||||
|
||||
pub fn move_option_group_after(
|
||||
config: &mut AnnotatedConfig,
|
||||
moving_keys: &[String],
|
||||
anchor_keys: &[String],
|
||||
) -> bool {
|
||||
move_option_group_relative(config, moving_keys, anchor_keys, false)
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_existing_path(path: &Path) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.with_context(|| format!("failed to inspect existing backup {}", path.display()))?;
|
||||
if metadata.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
.with_context(|| format!("failed to remove backup directory {}", path.display()))?;
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
.with_context(|| format!("failed to remove backup file {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn debug_log(message: &str) {
|
||||
crate::debug_log::record(message);
|
||||
}
|
||||
|
||||
fn move_option_relative(
|
||||
config: &mut AnnotatedConfig,
|
||||
moving_key: &str,
|
||||
anchor_key: &str,
|
||||
insert_before: bool,
|
||||
) -> bool {
|
||||
if moving_key == anchor_key {
|
||||
return false;
|
||||
}
|
||||
let Some((moving_idx, _)) = config.options.get(moving_key).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let Some((anchor_idx, _)) = config.options.get(anchor_key).cloned() else {
|
||||
return false;
|
||||
};
|
||||
if moving_idx >= config.lines.len() || anchor_idx >= config.lines.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let moving_line = config.lines.remove(moving_idx);
|
||||
let mut insertion_idx = anchor_idx;
|
||||
if moving_idx < anchor_idx {
|
||||
insertion_idx = insertion_idx.saturating_sub(1);
|
||||
}
|
||||
if !insert_before {
|
||||
insertion_idx += 1;
|
||||
}
|
||||
insertion_idx = insertion_idx.min(config.lines.len());
|
||||
config.lines.insert(insertion_idx, moving_line);
|
||||
rebuild_option_index(config);
|
||||
config.dirty = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn move_option_group_relative(
|
||||
config: &mut AnnotatedConfig,
|
||||
moving_keys: &[String],
|
||||
anchor_keys: &[String],
|
||||
insert_before: bool,
|
||||
) -> bool {
|
||||
use std::collections::HashSet;
|
||||
|
||||
if moving_keys.is_empty() || anchor_keys.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let moving_set = moving_keys
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
let anchor_set = anchor_keys
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
if !moving_set.is_disjoint(&anchor_set) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut moving_entries = moving_keys
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
config
|
||||
.options
|
||||
.get(key)
|
||||
.cloned()
|
||||
.map(|(line_idx, _)| (line_idx, key.clone()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut anchor_entries = anchor_keys
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
config
|
||||
.options
|
||||
.get(key)
|
||||
.cloned()
|
||||
.map(|(line_idx, _)| (line_idx, key.clone()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if moving_entries.is_empty() || anchor_entries.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
moving_entries.sort_by_key(|(idx, _)| *idx);
|
||||
anchor_entries.sort_by_key(|(idx, _)| *idx);
|
||||
|
||||
let moving_indices = moving_entries
|
||||
.iter()
|
||||
.map(|(idx, _)| *idx)
|
||||
.collect::<Vec<_>>();
|
||||
let moving_lines = moving_indices
|
||||
.iter()
|
||||
.map(|idx| config.lines[*idx].clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for idx in moving_indices.iter().rev() {
|
||||
config.lines.remove(*idx);
|
||||
}
|
||||
|
||||
let anchor_target_idx = if insert_before {
|
||||
anchor_entries.first().map(|(idx, _)| *idx).unwrap_or(0)
|
||||
} else {
|
||||
anchor_entries.last().map(|(idx, _)| *idx + 1).unwrap_or(0)
|
||||
};
|
||||
let removed_before_anchor = moving_indices
|
||||
.iter()
|
||||
.filter(|idx| **idx < anchor_target_idx)
|
||||
.count();
|
||||
let insertion_idx = anchor_target_idx
|
||||
.saturating_sub(removed_before_anchor)
|
||||
.min(config.lines.len());
|
||||
|
||||
for (offset, line) in moving_lines.into_iter().enumerate() {
|
||||
config.lines.insert(insertion_idx + offset, line);
|
||||
}
|
||||
|
||||
rebuild_option_index(config);
|
||||
config.dirty = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn rebuild_option_index(config: &mut AnnotatedConfig) {
|
||||
let mut options: IndexMap<String, (usize, ConfigValue)> = IndexMap::new();
|
||||
let mut duplicate_indices = Vec::new();
|
||||
for (idx, line) in config.lines.iter().enumerate() {
|
||||
let Some((key, value)) = option_state_from_line(line) else {
|
||||
continue;
|
||||
};
|
||||
if let Some((old_idx, _)) = options.insert(key, (idx, value)) {
|
||||
duplicate_indices.push(old_idx);
|
||||
}
|
||||
}
|
||||
for old_idx in duplicate_indices {
|
||||
if let Some(old_line) = config.lines.get_mut(old_idx) {
|
||||
*old_line = ConfigLine::Blank;
|
||||
}
|
||||
}
|
||||
config.options = options;
|
||||
}
|
||||
|
||||
fn option_state_from_line(line: &ConfigLine) -> Option<(String, ConfigValue)> {
|
||||
match line {
|
||||
ConfigLine::Option { key, value, .. } => Some((
|
||||
key.clone(),
|
||||
match value {
|
||||
Some(v) => ConfigValue::Value(v.clone()),
|
||||
None => ConfigValue::Flag,
|
||||
},
|
||||
)),
|
||||
ConfigLine::CommentedOption { key, .. } => Some((key.clone(), ConfigValue::Disabled)),
|
||||
ConfigLine::Comment(_) | ConfigLine::Blank => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Parser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_option_candidate(line: &str) -> Option<(String, Option<String>)> {
|
||||
if let Some((lhs, rhs)) = line.split_once('=') {
|
||||
let key = lhs.trim();
|
||||
if !KEY_RE.is_match(key) {
|
||||
return None;
|
||||
}
|
||||
let value = rhs.trim().to_string();
|
||||
return Some((key.to_string(), Some(value)));
|
||||
}
|
||||
|
||||
let key = line.trim();
|
||||
if KEY_RE.is_match(key) {
|
||||
return Some((key.to_string(), None));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_prior_value(line: &ConfigLine) -> Option<String> {
|
||||
match line {
|
||||
ConfigLine::Option { value, .. } | ConfigLine::CommentedOption { value, .. } => {
|
||||
value.clone()
|
||||
}
|
||||
ConfigLine::Comment(_) | ConfigLine::Blank => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn line_from_value(key: &str, value: &ConfigValue, prior_value: Option<String>) -> ConfigLine {
|
||||
match value {
|
||||
ConfigValue::Flag => ConfigLine::Option {
|
||||
key: key.to_string(),
|
||||
value: None,
|
||||
raw: key.to_string(),
|
||||
},
|
||||
ConfigValue::Value(v) => ConfigLine::Option {
|
||||
key: key.to_string(),
|
||||
value: Some(v.clone()),
|
||||
raw: format!("{key}={v}"),
|
||||
},
|
||||
ConfigValue::Disabled if disabled_flag_requires_explicit_zero(key) => ConfigLine::Option {
|
||||
key: key.to_string(),
|
||||
value: Some("0".to_string()),
|
||||
raw: format!("{key}=0"),
|
||||
},
|
||||
ConfigValue::Disabled | ConfigValue::Absent => {
|
||||
let raw = match &prior_value {
|
||||
Some(v) if !v.is_empty() => format!("# {key}={v}"),
|
||||
_ => format!("# {key}"),
|
||||
};
|
||||
ConfigLine::CommentedOption {
|
||||
key: key.to_string(),
|
||||
value: prior_value,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flag_defaults_to_enabled(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"cpu_stats"
|
||||
| "fps"
|
||||
| "frame_timing"
|
||||
| "frametime"
|
||||
| "gpu_stats"
|
||||
| "horizontal_stretch"
|
||||
| "legacy_layout"
|
||||
| "text_outline"
|
||||
)
|
||||
}
|
||||
|
||||
fn disabled_flag_requires_explicit_zero(key: &str) -> bool {
|
||||
flag_defaults_to_enabled(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::schema::get_schema_entry;
|
||||
use crate::config::schema::MANGOHUD_SCHEMA;
|
||||
use crate::config::types::{OptionType, ValidationResult};
|
||||
use crate::config::validator;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn parse_all_line_types() {
|
||||
let content = "# comment\n\nfps=60\nframetime\n# gpu_temp\n# cpu_color=FF0000\n";
|
||||
let parsed = Parser::parse_str(content, None);
|
||||
assert_eq!(parsed.lines.len(), 6);
|
||||
assert_eq!(
|
||||
parsed.options.get("fps").map(|v| &v.1),
|
||||
Some(&ConfigValue::Value("60".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.options.get("frametime").map(|v| &v.1),
|
||||
Some(&ConfigValue::Flag)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.options.get("gpu_temp").map(|v| &v.1),
|
||||
Some(&ConfigValue::Disabled)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.options.get("cpu_color").map(|v| &v.1),
|
||||
Some(&ConfigValue::Disabled)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_parse_write_parse_values_match() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let path = dir.path().join("MangoHud.conf");
|
||||
let original = "# header\nfps=60\n# gpu_temp\nframetime\n";
|
||||
fs::write(&path, original).expect("write fixture");
|
||||
|
||||
let parsed = Parser::read(&path).expect("read");
|
||||
Parser::write(&parsed).expect("write");
|
||||
let reparsed = Parser::read(&path).expect("re-read");
|
||||
|
||||
assert_eq!(parsed.options, reparsed.options);
|
||||
assert_eq!(parsed.lines, reparsed.lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_value_updates_existing_key_preserving_surrounding_lines() {
|
||||
let mut cfg = Parser::parse_str("# top\nfps=60\n# bottom\n", None);
|
||||
Parser::set_value(&mut cfg, "fps", ConfigValue::Value("120".into()));
|
||||
let out = Parser::to_string(&cfg);
|
||||
assert!(out.contains("# top\nfps=120\n# bottom\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_value_adds_new_key_at_end() {
|
||||
let mut cfg = Parser::parse_str("fps=60\n", None);
|
||||
Parser::set_value(&mut cfg, "gpu_temp", ConfigValue::Flag);
|
||||
let out = Parser::to_string(&cfg);
|
||||
assert!(out.ends_with("fps=60\ngpu_temp\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_value_disable_key_comments_it_out() {
|
||||
let mut cfg = Parser::parse_str("gpu_temp\n", None);
|
||||
Parser::set_value(&mut cfg, "gpu_temp", ConfigValue::Disabled);
|
||||
let out = Parser::to_string(&cfg);
|
||||
assert_eq!(out, "# gpu_temp\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_value_disable_horizontal_stretch_writes_explicit_zero() {
|
||||
let mut cfg = Parser::parse_str("horizontal_stretch\n", None);
|
||||
Parser::set_value(&mut cfg, "horizontal_stretch", ConfigValue::Disabled);
|
||||
let out = Parser::to_string(&cfg);
|
||||
assert_eq!(out, "horizontal_stretch=0\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_value_disable_default_on_flags_writes_explicit_zero() {
|
||||
let mut cfg =
|
||||
Parser::parse_str("fps\nframetime\nframe_timing\ngpu_stats\ncpu_stats\n", None);
|
||||
Parser::set_value(&mut cfg, "fps", ConfigValue::Disabled);
|
||||
Parser::set_value(&mut cfg, "frametime", ConfigValue::Disabled);
|
||||
Parser::set_value(&mut cfg, "frame_timing", ConfigValue::Disabled);
|
||||
Parser::set_value(&mut cfg, "gpu_stats", ConfigValue::Disabled);
|
||||
Parser::set_value(&mut cfg, "cpu_stats", ConfigValue::Disabled);
|
||||
let out = Parser::to_string(&cfg);
|
||||
assert!(out.contains("fps=0\n"));
|
||||
assert!(out.contains("frametime=0\n"));
|
||||
assert!(out.contains("frame_timing=0\n"));
|
||||
assert!(out.contains("gpu_stats=0\n"));
|
||||
assert!(out.contains("cpu_stats=0\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_key_last_value_wins() {
|
||||
let cfg = Parser::parse_str("fps=30\nfps=60\n", None);
|
||||
assert_eq!(
|
||||
cfg.options.get("fps").map(|v| &v.1),
|
||||
Some(&ConfigValue::Value("60".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_key_does_not_round_trip_old_line() {
|
||||
let cfg = Parser::parse_str("fps=30\nfps=60\n", None);
|
||||
assert_eq!(Parser::to_string(&cfg), "\nfps=60\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_leading_and_trailing_value_whitespace() {
|
||||
let cfg = Parser::parse_str("fps= 60 \n", None);
|
||||
assert_eq!(
|
||||
cfg.options.get("fps").map(|v| &v.1),
|
||||
Some(&ConfigValue::Value("60".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_recovers_when_stale_backup_path_is_a_directory() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let path = dir.path().join("MangoHud.conf");
|
||||
let backup = dir.path().join("MangoHud.conf.mangotune.bak");
|
||||
|
||||
fs::write(&path, "fps=60\n").expect("write config");
|
||||
fs::create_dir(&backup).expect("create stale backup dir");
|
||||
|
||||
let parsed = Parser::read(&path).expect("read");
|
||||
Parser::write(&parsed).expect("write with stale backup dir");
|
||||
|
||||
let written = fs::read_to_string(&path).expect("read output");
|
||||
assert_eq!(written, "fps=60\n");
|
||||
assert!(backup.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_option_before_reorders_lines() {
|
||||
let mut cfg = Parser::parse_str("fps\ngpu_stats\ncpu_stats\n", None);
|
||||
assert!(Parser::move_option_before(
|
||||
&mut cfg,
|
||||
"cpu_stats",
|
||||
"gpu_stats"
|
||||
));
|
||||
assert_eq!(Parser::to_string(&cfg), "fps\ncpu_stats\ngpu_stats\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_option_after_reorders_lines() {
|
||||
let mut cfg = Parser::parse_str("fps\ngpu_stats\ncpu_stats\n", None);
|
||||
assert!(Parser::move_option_after(&mut cfg, "fps", "gpu_stats"));
|
||||
assert_eq!(Parser::to_string(&cfg), "gpu_stats\nfps\ncpu_stats\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_comments_are_preserved() {
|
||||
let cfg = Parser::parse_str("# Привет мир\nfps=60\n", None);
|
||||
match &cfg.lines[0] {
|
||||
ConfigLine::Comment(text) => assert_eq!(text, "# Привет мир"),
|
||||
_ => panic!("first line should be comment"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_schema_representative_round_trip_and_validate() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let path = dir.path().join("MangoHud.conf");
|
||||
let mut content = String::from("# generated-by-test\n");
|
||||
|
||||
for entry in MANGOHUD_SCHEMA.iter() {
|
||||
if let Some(line) = representative_line_for_entry(entry, dir.path()) {
|
||||
content.push_str(&line);
|
||||
content.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(&path, &content).expect("write fixture");
|
||||
let parsed = Parser::read(&path).expect("read");
|
||||
for (key, (_, value)) in &parsed.options {
|
||||
if let Some(schema) = get_schema_entry(key) {
|
||||
let result = validator::validate_value(key, value, schema);
|
||||
assert!(
|
||||
!matches!(result, ValidationResult::Error(_)),
|
||||
"generated value should type-validate for key '{}': {:?}",
|
||||
key,
|
||||
result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Parser::write(&parsed).expect("write");
|
||||
let reparsed = Parser::read(&path).expect("re-read");
|
||||
assert_eq!(parsed.options, reparsed.options);
|
||||
}
|
||||
|
||||
fn representative_line_for_entry(
|
||||
entry: &crate::config::types::SchemaEntry,
|
||||
temp_root: &std::path::Path,
|
||||
) -> Option<String> {
|
||||
let value = match entry.key {
|
||||
"fps_metrics" | "benchmark_percentiles" => ConfigValue::Value("AVG,95".to_string()),
|
||||
"time_format" => ConfigValue::Value("%H:%M:%S".to_string()),
|
||||
"pci_dev" => ConfigValue::Value("0000:01:00.0".to_string()),
|
||||
"ftrace" => ConfigValue::Value("histogram/foo/bar".to_string()),
|
||||
"control" => ConfigValue::Value("-1".to_string()),
|
||||
"fps_color" | "gpu_load_color" | "cpu_load_color" => {
|
||||
ConfigValue::Value("FF4D4D,FFD24D,66FF99".to_string())
|
||||
}
|
||||
_ => match &entry.option_type {
|
||||
OptionType::Flag => ConfigValue::Flag,
|
||||
OptionType::Bool => ConfigValue::Value("1".to_string()),
|
||||
OptionType::Int { min, .. } => ConfigValue::Value(min.to_string()),
|
||||
OptionType::Float { min, .. } => ConfigValue::Value(min.to_string()),
|
||||
OptionType::Str { .. } => ConfigValue::Value("sample".to_string()),
|
||||
OptionType::Color => ConfigValue::Value("A1B2C3".to_string()),
|
||||
OptionType::Enum { variants } => {
|
||||
ConfigValue::Value(variants.first().cloned().unwrap_or_default())
|
||||
}
|
||||
OptionType::FpsLimitList => ConfigValue::Value("0,60,120".to_string()),
|
||||
OptionType::KeyBind => ConfigValue::Value("Shift_R+F12".to_string()),
|
||||
OptionType::CommaSepInts => ConfigValue::Value("1,2,3".to_string()),
|
||||
OptionType::CommaSepFloats => ConfigValue::Value("0.5,1.5".to_string()),
|
||||
OptionType::CommaSepStrings { valid_values } => {
|
||||
let value = valid_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.first().cloned())
|
||||
.unwrap_or_else(|| "sample".to_string());
|
||||
ConfigValue::Value(value)
|
||||
}
|
||||
OptionType::Path {
|
||||
must_exist,
|
||||
must_be_writable: _,
|
||||
} => {
|
||||
let path = if *must_exist {
|
||||
temp_root.to_path_buf()
|
||||
} else {
|
||||
temp_root.join("generated-path")
|
||||
};
|
||||
ConfigValue::Value(path.display().to_string())
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
match value {
|
||||
ConfigValue::Flag => Some(entry.key.to_string()),
|
||||
ConfigValue::Value(v) => Some(format!("{}={v}", entry.key)),
|
||||
ConfigValue::Absent | ConfigValue::Disabled => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
use crate::config::parser::Parser;
|
||||
use crate::config::types::{AnnotatedConfig, ConfigValue};
|
||||
use crate::system::paths::XdgPaths;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
#[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),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigConflict {
|
||||
pub key: String,
|
||||
pub winning_layer_priority: u8,
|
||||
pub winning_value: ConfigValue,
|
||||
pub shadowed: Vec<(u8, ConfigValue)>,
|
||||
}
|
||||
|
||||
pub struct Resolver;
|
||||
|
||||
impl Resolver {
|
||||
pub async fn discover(xdg: &XdgPaths) -> Result<Vec<ConfigLayer>> {
|
||||
let mut layers = Vec::new();
|
||||
|
||||
if let Ok(raw) = env::var("MANGOHUD_CONFIGFILE") {
|
||||
let path = expand_env_path(raw.trim());
|
||||
if path.exists() {
|
||||
let parsed = Parser::read(&path).ok();
|
||||
layers.push(ConfigLayer {
|
||||
path: Some(path.clone()),
|
||||
source_type: LayerSource::EnvFile(path),
|
||||
priority: 5,
|
||||
exists: true,
|
||||
is_editable: false,
|
||||
config: parsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(raw_inline) = env::var("MANGOHUD_CONFIG") {
|
||||
let inline_text = normalize_env_inline(&raw_inline);
|
||||
let parsed = Parser::parse_str(&inline_text, None);
|
||||
layers.push(ConfigLayer {
|
||||
path: None,
|
||||
source_type: LayerSource::EnvInline(raw_inline),
|
||||
priority: 5,
|
||||
exists: true,
|
||||
is_editable: false,
|
||||
config: Some(parsed),
|
||||
});
|
||||
}
|
||||
|
||||
let global_exists = xdg.global_config.exists();
|
||||
layers.push(ConfigLayer {
|
||||
path: Some(xdg.global_config.clone()),
|
||||
source_type: LayerSource::GlobalXdg,
|
||||
priority: 2,
|
||||
exists: global_exists,
|
||||
is_editable: true,
|
||||
config: if global_exists {
|
||||
Parser::read(&xdg.global_config).ok()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
|
||||
if xdg.mangohud_dir.exists() {
|
||||
let mut per_app = Vec::new();
|
||||
for entry in fs::read_dir(&xdg.mangohud_dir)
|
||||
.with_context(|| format!("failed to list {}", xdg.mangohud_dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("conf") {
|
||||
continue;
|
||||
}
|
||||
if path.file_name().and_then(|f| f.to_str()) == Some("MangoHud.conf") {
|
||||
continue;
|
||||
}
|
||||
if Self::should_ignore_per_app_candidate(&path) {
|
||||
continue;
|
||||
}
|
||||
let app_name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
per_app.push((app_name, path));
|
||||
}
|
||||
per_app.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
for (app_name, path) in per_app {
|
||||
layers.push(ConfigLayer {
|
||||
path: Some(path.clone()),
|
||||
source_type: LayerSource::PerAppXdg(app_name),
|
||||
priority: 3,
|
||||
exists: true,
|
||||
is_editable: true,
|
||||
config: Parser::read(&path).ok(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for path in Self::scan_game_dirs().await {
|
||||
layers.push(ConfigLayer {
|
||||
path: Some(path.clone()),
|
||||
source_type: LayerSource::AppLocal(path.clone()),
|
||||
priority: 4,
|
||||
exists: true,
|
||||
is_editable: true,
|
||||
config: Parser::read(&path).ok(),
|
||||
});
|
||||
}
|
||||
|
||||
layers.push(ConfigLayer {
|
||||
path: None,
|
||||
source_type: LayerSource::CompiledDefault,
|
||||
priority: 1,
|
||||
exists: true,
|
||||
is_editable: false,
|
||||
config: None,
|
||||
});
|
||||
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
fn should_ignore_per_app_candidate(path: &Path) -> bool {
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
stem == "MangoHud_backup_mnghdc"
|
||||
|| stem.ends_with("_backup_mnghdc")
|
||||
|| stem.ends_with(".backup")
|
||||
|| stem.ends_with("-backup")
|
||||
}
|
||||
|
||||
pub fn find_conflicts(layers: &[ConfigLayer]) -> Vec<ConfigConflict> {
|
||||
let mut per_key: HashMap<String, Vec<(u8, usize, ConfigValue)>> = HashMap::new();
|
||||
for (idx, layer) in layers.iter().enumerate() {
|
||||
let Some(config) = &layer.config else {
|
||||
continue;
|
||||
};
|
||||
for (key, (_, value)) in &config.options {
|
||||
if matches!(value, ConfigValue::Absent | ConfigValue::Disabled) {
|
||||
continue;
|
||||
}
|
||||
per_key
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.push((layer.priority, idx, value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut conflicts = Vec::new();
|
||||
for (key, mut values) in per_key {
|
||||
if values.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
values.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
|
||||
let winning = values[0].clone();
|
||||
let shadowed: Vec<(u8, ConfigValue)> = values
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(|(prio, _, value)| {
|
||||
if *value != winning.2 {
|
||||
Some((*prio, value.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !shadowed.is_empty() {
|
||||
conflicts.push(ConfigConflict {
|
||||
key,
|
||||
winning_layer_priority: winning.0,
|
||||
winning_value: winning.2,
|
||||
shadowed,
|
||||
});
|
||||
}
|
||||
}
|
||||
conflicts.sort_by(|a, b| a.key.cmp(&b.key));
|
||||
conflicts
|
||||
}
|
||||
|
||||
pub fn effective_value(key: &str, layers: &[ConfigLayer]) -> ConfigValue {
|
||||
let mut best: Option<(u8, usize, ConfigValue)> = None;
|
||||
for (idx, layer) in layers.iter().enumerate() {
|
||||
let Some(config) = &layer.config else {
|
||||
continue;
|
||||
};
|
||||
let Some((_, value)) = config.options.get(key) else {
|
||||
continue;
|
||||
};
|
||||
if matches!(value, ConfigValue::Absent | ConfigValue::Disabled) {
|
||||
continue;
|
||||
}
|
||||
match &best {
|
||||
Some((prio, best_idx, _))
|
||||
if *prio > layer.priority || (*prio == layer.priority && *best_idx > idx) => {}
|
||||
_ => {
|
||||
best = Some((layer.priority, idx, value.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, _, v)| v).unwrap_or(ConfigValue::Absent)
|
||||
}
|
||||
|
||||
pub fn layer_label(source: &LayerSource) -> String {
|
||||
match source {
|
||||
LayerSource::CompiledDefault => "Built-in defaults".to_string(),
|
||||
LayerSource::GlobalXdg => "Saved global config".to_string(),
|
||||
LayerSource::PerAppXdg(app) => format!("Per-app ({app})"),
|
||||
LayerSource::AppLocal(path) => format!("App-local ({})", path.display()),
|
||||
LayerSource::EnvFile(path) => format!("ENV file ({})", path.display()),
|
||||
LayerSource::EnvInline(_) => "ENV inline ($MANGOHUD_CONFIG)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_per_app_config(app_name: &str, xdg: &XdgPaths) -> Result<PathBuf> {
|
||||
if !is_valid_app_name(app_name) {
|
||||
return Err(anyhow!(
|
||||
"invalid app name '{app_name}', allowed: alphanumeric, '-' and '_'"
|
||||
));
|
||||
}
|
||||
|
||||
fs::create_dir_all(&xdg.mangohud_dir).with_context(|| {
|
||||
format!("failed to create config dir {}", xdg.mangohud_dir.display())
|
||||
})?;
|
||||
let path = xdg.mangohud_dir.join(format!("{app_name}.conf"));
|
||||
if !path.exists() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let header = format!(
|
||||
"### MangoHud configuration - managed by MangoTune\n### Created: {now}\n### App: {app_name}\n"
|
||||
);
|
||||
fs::write(&path, header)
|
||||
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
async fn scan_game_dirs() -> Vec<PathBuf> {
|
||||
let home = env::var("HOME").unwrap_or_else(|_| "/".to_string());
|
||||
let roots = vec![
|
||||
PathBuf::from(&home).join(".steam/steam/steamapps/common"),
|
||||
PathBuf::from(&home).join(".local/share/Steam/steamapps/common"),
|
||||
PathBuf::from(&home).join("Games"),
|
||||
PathBuf::from(&home)
|
||||
.join(".var/app/com.valvesoftware.Steam/data/Steam/steamapps/common"),
|
||||
];
|
||||
|
||||
let mut found = Vec::new();
|
||||
for root in roots {
|
||||
if !root.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = fs::read_dir(&root) {
|
||||
for entry in entries.flatten() {
|
||||
let cfg = entry.path().join("MangoHud.conf");
|
||||
if cfg.exists() {
|
||||
found.push(cfg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found.dedup();
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_env_path(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
return PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/".to_string()));
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
return PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/".to_string())).join(rest);
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
fn normalize_env_inline(raw: &str) -> String {
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn is_valid_app_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::tempdir;
|
||||
|
||||
static ENV_TEST_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_reads_xdg_override() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let xdg_root = dir.path().join("xdg");
|
||||
let mangohud_dir = xdg_root.join("MangoHud");
|
||||
fs::create_dir_all(&mangohud_dir).expect("mkdir");
|
||||
fs::write(mangohud_dir.join("MangoHud.conf"), "fps=60\n").expect("write global");
|
||||
fs::write(mangohud_dir.join("testgame.conf"), "fps=120\n").expect("write per-app");
|
||||
|
||||
let paths = XdgPaths {
|
||||
config_home: xdg_root.clone(),
|
||||
mangohud_dir: mangohud_dir.clone(),
|
||||
global_config: mangohud_dir.join("MangoHud.conf"),
|
||||
data_home: dir.path().join("data"),
|
||||
};
|
||||
let layers = Resolver::discover(&paths).await.expect("discover");
|
||||
assert!(layers
|
||||
.iter()
|
||||
.any(|layer| matches!(layer.source_type, LayerSource::GlobalXdg)));
|
||||
assert!(layers.iter().any(|layer| {
|
||||
matches!(layer.source_type, LayerSource::PerAppXdg(ref app) if app == "testgame")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_reads_env_inline_layer() {
|
||||
let _guard = ENV_TEST_LOCK.lock().expect("env test lock");
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let paths = XdgPaths {
|
||||
config_home: dir.path().join("cfg"),
|
||||
mangohud_dir: dir.path().join("cfg/MangoHud"),
|
||||
global_config: dir.path().join("cfg/MangoHud/MangoHud.conf"),
|
||||
data_home: dir.path().join("data"),
|
||||
};
|
||||
|
||||
env::set_var("MANGOHUD_CONFIG", "fps=144,gpu_stats=0");
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime");
|
||||
let layers = runtime
|
||||
.block_on(Resolver::discover(&paths))
|
||||
.expect("discover");
|
||||
env::remove_var("MANGOHUD_CONFIG");
|
||||
|
||||
let env_layer = layers
|
||||
.iter()
|
||||
.find(|layer| matches!(layer.source_type, LayerSource::EnvInline(_)))
|
||||
.expect("env inline layer missing");
|
||||
assert_eq!(env_layer.priority, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_conflicts_between_layers() {
|
||||
let global = ConfigLayer {
|
||||
path: None,
|
||||
source_type: LayerSource::GlobalXdg,
|
||||
priority: 2,
|
||||
exists: true,
|
||||
is_editable: true,
|
||||
config: Some(Parser::parse_str("fps=60\n", None)),
|
||||
};
|
||||
let per_app = ConfigLayer {
|
||||
path: None,
|
||||
source_type: LayerSource::PerAppXdg("game".to_string()),
|
||||
priority: 3,
|
||||
exists: true,
|
||||
is_editable: true,
|
||||
config: Some(Parser::parse_str("fps=120\n", None)),
|
||||
};
|
||||
let conflicts = Resolver::find_conflicts(&[global, per_app]);
|
||||
assert!(conflicts.iter().any(|conflict| conflict.key == "fps"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_value_prefers_env_over_file() {
|
||||
let file_layer = ConfigLayer {
|
||||
path: None,
|
||||
source_type: LayerSource::GlobalXdg,
|
||||
priority: 2,
|
||||
exists: true,
|
||||
is_editable: true,
|
||||
config: Some(Parser::parse_str("fps=60\n", None)),
|
||||
};
|
||||
let env_layer = ConfigLayer {
|
||||
path: None,
|
||||
source_type: LayerSource::EnvInline("fps=144".to_string()),
|
||||
priority: 5,
|
||||
exists: true,
|
||||
is_editable: false,
|
||||
config: Some(Parser::parse_str("fps=144\n", None)),
|
||||
};
|
||||
let value = Resolver::effective_value("fps", &[file_layer, env_layer]);
|
||||
assert_eq!(value, ConfigValue::Value("144".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_backup_style_per_app_candidates() {
|
||||
assert!(Resolver::should_ignore_per_app_candidate(&PathBuf::from(
|
||||
"/tmp/MangoHud_backup_mnghdc.conf"
|
||||
)));
|
||||
assert!(Resolver::should_ignore_per_app_candidate(&PathBuf::from(
|
||||
"/tmp/game_backup_mnghdc.conf"
|
||||
)));
|
||||
assert!(Resolver::should_ignore_per_app_candidate(&PathBuf::from(
|
||||
"/tmp/game-backup.conf"
|
||||
)));
|
||||
assert!(!Resolver::should_ignore_per_app_candidate(&PathBuf::from(
|
||||
"/tmp/My-Default.conf"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_per_app_config_writes_header() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let xdg = XdgPaths {
|
||||
config_home: dir.path().join("cfg"),
|
||||
mangohud_dir: dir.path().join("cfg/MangoHud"),
|
||||
global_config: dir.path().join("cfg/MangoHud/MangoHud.conf"),
|
||||
data_home: dir.path().join("data"),
|
||||
};
|
||||
|
||||
let path = Resolver::create_per_app_config("my_game", &xdg).expect("create per app");
|
||||
let content = fs::read_to_string(path).expect("read file");
|
||||
assert!(content.contains("### MangoHud configuration - managed by MangoTune"));
|
||||
assert!(content.contains("### App: my_game"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A single line from a MangoHud config file, preserving its original text.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConfigLine {
|
||||
Comment(String),
|
||||
Blank,
|
||||
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,
|
||||
Flag,
|
||||
Value(String),
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// 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, Eq, Hash)]
|
||||
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),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// The full in-memory representation of a parsed config file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnnotatedConfig {
|
||||
/// Ordered list of lines as they appear in the file.
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
use crate::config::schema::get_schema_entry;
|
||||
use crate::config::types::{
|
||||
AnnotatedConfig, Category, ConfigValue, OptionType, SchemaEntry, ValidationResult,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
static COLOR_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^[0-9A-Fa-f]{6}$").expect("valid color regex"));
|
||||
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]$")
|
||||
.expect("valid pci regex")
|
||||
});
|
||||
static KEYBIND_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^[^\s+]+(?:\+[^\s+]+)*$").expect("valid keybind regex"));
|
||||
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_]+)?)*$")
|
||||
.expect("valid ftrace regex")
|
||||
});
|
||||
|
||||
pub fn validate_value(key: &str, value: &ConfigValue, schema: &SchemaEntry) -> ValidationResult {
|
||||
if matches!(value, ConfigValue::Absent | ConfigValue::Disabled) {
|
||||
return ValidationResult::Ok;
|
||||
}
|
||||
|
||||
let text = match value {
|
||||
ConfigValue::Value(v) => Some(v.as_str()),
|
||||
ConfigValue::Flag => None,
|
||||
ConfigValue::Absent | ConfigValue::Disabled => None,
|
||||
};
|
||||
|
||||
let type_result = match &schema.option_type {
|
||||
OptionType::Flag => {
|
||||
if matches!(value, ConfigValue::Flag) {
|
||||
ValidationResult::Ok
|
||||
} else {
|
||||
ValidationResult::Error(format!("'{key}' must be set as a bare flag"))
|
||||
}
|
||||
}
|
||||
OptionType::Bool => match text {
|
||||
Some("0") | Some("1") => ValidationResult::Ok,
|
||||
_ => ValidationResult::Error(format!("'{key}' must be 0 or 1")),
|
||||
},
|
||||
OptionType::Int { min, max } => parse_int_in_range(key, text, *min, *max),
|
||||
OptionType::Float { min, max } => parse_float_in_range(key, text, *min, *max),
|
||||
OptionType::Str { max_len } => match text {
|
||||
Some(v) if v.len() <= *max_len => ValidationResult::Ok,
|
||||
Some(_) => ValidationResult::Error(format!("'{key}' exceeds max length {}", max_len)),
|
||||
None => ValidationResult::Error(format!("'{key}' requires a value")),
|
||||
},
|
||||
OptionType::Color => match text {
|
||||
Some(v) if COLOR_RE.is_match(v) => ValidationResult::Ok,
|
||||
_ => {
|
||||
ValidationResult::Error(format!("'{key}' must be a 6-character hex color (RRGGBB)"))
|
||||
}
|
||||
},
|
||||
OptionType::Enum { variants } => match text {
|
||||
Some(v) if variants.iter().any(|item| item == v) => ValidationResult::Ok,
|
||||
Some(v) => ValidationResult::Error(format!(
|
||||
"'{key}' has invalid variant '{v}', expected one of: {}",
|
||||
variants.join(", ")
|
||||
)),
|
||||
None => ValidationResult::Error(format!("'{key}' requires a value")),
|
||||
},
|
||||
OptionType::FpsLimitList => match text {
|
||||
Some(v) => validate_non_negative_csv_ints(key, v),
|
||||
None => ValidationResult::Error(format!("'{key}' requires a value")),
|
||||
},
|
||||
OptionType::KeyBind => match text {
|
||||
Some(v) if KEYBIND_RE.is_match(v) => ValidationResult::Ok,
|
||||
Some(_) => ValidationResult::Error(format!("'{key}' has an invalid keybind format")),
|
||||
None => ValidationResult::Error(format!("'{key}' requires a value")),
|
||||
},
|
||||
OptionType::CommaSepInts => match text {
|
||||
Some(v) => validate_csv_ints(key, v),
|
||||
None => ValidationResult::Error(format!("'{key}' requires a value")),
|
||||
},
|
||||
OptionType::CommaSepFloats => match text {
|
||||
Some(v) => validate_csv_floats(key, v),
|
||||
None => ValidationResult::Error(format!("'{key}' requires a value")),
|
||||
},
|
||||
OptionType::CommaSepStrings { valid_values } => match text {
|
||||
Some(v) => validate_csv_strings(key, v, valid_values.as_deref()),
|
||||
None => ValidationResult::Error(format!("'{key}' requires a value")),
|
||||
},
|
||||
OptionType::Path {
|
||||
must_exist,
|
||||
must_be_writable,
|
||||
} => match text {
|
||||
Some(v) => validate_path(key, v, *must_exist, *must_be_writable),
|
||||
None => ValidationResult::Error(format!("'{key}' requires a value")),
|
||||
},
|
||||
};
|
||||
|
||||
if !matches!(type_result, ValidationResult::Ok) {
|
||||
return type_result;
|
||||
}
|
||||
|
||||
// Key-specific validations.
|
||||
match key {
|
||||
"fps_metrics" => match text {
|
||||
Some(v) => validate_percentile_list(key, v),
|
||||
None => ValidationResult::Ok,
|
||||
},
|
||||
"benchmark_percentiles" => match text {
|
||||
Some(v) => match validate_percentile_list(key, v) {
|
||||
ValidationResult::Ok => ValidationResult::Warning(
|
||||
"benchmark_percentiles is a legacy MangoHud option; prefer fps_metrics."
|
||||
.to_string(),
|
||||
),
|
||||
other => other,
|
||||
},
|
||||
None => ValidationResult::Ok,
|
||||
},
|
||||
"time_format" => match text {
|
||||
Some(v) => validate_time_format(v),
|
||||
None => ValidationResult::Ok,
|
||||
},
|
||||
"pci_dev" => match text {
|
||||
Some(v) if v.is_empty() || PCI_DEV_RE.is_match(v) => ValidationResult::Ok,
|
||||
Some(_) => {
|
||||
ValidationResult::Error(format!("'{key}' must match domain:bus:slot.function"))
|
||||
}
|
||||
None => ValidationResult::Ok,
|
||||
},
|
||||
"ftrace" => match text {
|
||||
Some(v) if v.is_empty() || FTRACE_RE.is_match(v) => ValidationResult::Ok,
|
||||
Some(_) => ValidationResult::Error("invalid ftrace format".to_string()),
|
||||
None => ValidationResult::Ok,
|
||||
},
|
||||
"control" => match text {
|
||||
Some("-1") => ValidationResult::Ok,
|
||||
Some(v) if !v.trim().is_empty() && !v.chars().any(char::is_whitespace) => {
|
||||
ValidationResult::Ok
|
||||
}
|
||||
Some(_) => ValidationResult::Error(
|
||||
"'control' must be -1 or a non-whitespace socket name".to_string(),
|
||||
),
|
||||
None => ValidationResult::Ok,
|
||||
},
|
||||
"fps_color" | "gpu_load_color" | "cpu_load_color" => match text {
|
||||
Some(v) => validate_csv_colors(key, v),
|
||||
None => ValidationResult::Ok,
|
||||
},
|
||||
_ => ValidationResult::Ok,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_all(config: &AnnotatedConfig) -> HashMap<String, ValidationResult> {
|
||||
let mut issues = HashMap::new();
|
||||
|
||||
for (key, (_, value)) in &config.options {
|
||||
if let Some(schema) = get_schema_entry(key) {
|
||||
let result = validate_value(key, value, schema);
|
||||
if !matches!(result, ValidationResult::Ok) {
|
||||
issues.insert(key.clone(), result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (dep_key, dep_missing) in check_dependencies(config) {
|
||||
let dependency_result = dependency_validation_result(&dep_key, &dep_missing);
|
||||
match issues.get(&dep_key) {
|
||||
Some(ValidationResult::Error(_)) => {}
|
||||
Some(ValidationResult::Warning(_))
|
||||
if matches!(dependency_result, ValidationResult::Error(_)) =>
|
||||
{
|
||||
issues.insert(dep_key, dependency_result);
|
||||
}
|
||||
None => {
|
||||
issues.insert(dep_key, dependency_result);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for (a, b) in check_conflicts(config) {
|
||||
issues
|
||||
.entry(a.clone())
|
||||
.or_insert_with(|| ValidationResult::Error(format!("conflicts with '{b}'")));
|
||||
issues
|
||||
.entry(b.clone())
|
||||
.or_insert_with(|| ValidationResult::Error(format!("conflicts with '{a}'")));
|
||||
}
|
||||
|
||||
apply_threshold_shape_checks(config, &mut issues);
|
||||
|
||||
issues
|
||||
}
|
||||
|
||||
fn dependency_validation_result(dependent_key: &str, missing_dependency: &str) -> ValidationResult {
|
||||
let Some(schema) = get_schema_entry(dependent_key) else {
|
||||
return ValidationResult::Error(format!("missing dependency '{missing_dependency}'"));
|
||||
};
|
||||
|
||||
let hard_dependency = matches!(schema.option_type, OptionType::Flag | OptionType::Bool);
|
||||
if hard_dependency {
|
||||
ValidationResult::Error(format!("missing dependency '{missing_dependency}'"))
|
||||
} else {
|
||||
ValidationResult::Warning(format!("ignored until '{missing_dependency}' is enabled"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_dependencies(config: &AnnotatedConfig) -> Vec<(String, String)> {
|
||||
let mut missing = Vec::new();
|
||||
for (key, (_, value)) in &config.options {
|
||||
if !is_active(value) {
|
||||
continue;
|
||||
}
|
||||
let Some(schema) = get_schema_entry(key) else {
|
||||
continue;
|
||||
};
|
||||
for dep in schema.dependencies {
|
||||
let dep_active = config
|
||||
.options
|
||||
.get(*dep)
|
||||
.map(|(_, v)| is_active(v))
|
||||
.unwrap_or(false);
|
||||
if !dep_active {
|
||||
missing.push((key.clone(), (*dep).to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
pub fn check_conflicts(config: &AnnotatedConfig) -> Vec<(String, String)> {
|
||||
let mut conflicts = HashSet::new();
|
||||
let active: Vec<(&str, &ConfigValue)> = config
|
||||
.options
|
||||
.iter()
|
||||
.filter(|(_, (_, v))| is_active(v))
|
||||
.map(|(k, (_, v))| (k.as_str(), v))
|
||||
.collect();
|
||||
|
||||
for (key, _) in &active {
|
||||
if let Some(schema) = get_schema_entry(key) {
|
||||
for conflict in schema.conflicts_with {
|
||||
if active.iter().any(|(candidate, _)| candidate == conflict) {
|
||||
conflicts.insert(sorted_pair(key, conflict));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special-case: fps_only conflicts with any other display category option.
|
||||
let fps_only_active = active.iter().any(|(k, _)| *k == "fps_only");
|
||||
if fps_only_active {
|
||||
for (key, _) in &active {
|
||||
if *key == "fps_only" {
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = get_schema_entry(key) {
|
||||
if is_display_category(&entry.category) {
|
||||
conflicts.insert(sorted_pair("fps_only", key));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conflicts.into_iter().collect()
|
||||
}
|
||||
|
||||
pub fn is_saveable(config: &AnnotatedConfig) -> bool {
|
||||
!validate_all(config)
|
||||
.values()
|
||||
.any(|result| matches!(result, ValidationResult::Error(_)))
|
||||
}
|
||||
|
||||
fn sorted_pair(a: &str, b: &str) -> (String, String) {
|
||||
if a <= b {
|
||||
(a.to_string(), b.to_string())
|
||||
} else {
|
||||
(b.to_string(), a.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_display_category(category: &Category) -> bool {
|
||||
matches!(
|
||||
category,
|
||||
Category::DisplayFps
|
||||
| Category::DisplayGpu
|
||||
| Category::DisplayCpu
|
||||
| Category::DisplayMemory
|
||||
| Category::DisplayIoNetwork
|
||||
| Category::DisplayMisc
|
||||
| Category::DisplayGraphs
|
||||
| Category::DisplayBattery
|
||||
| Category::DisplayMediaPlayer
|
||||
| Category::DisplayGamescope
|
||||
| Category::DisplaySteamDeck
|
||||
| Category::DisplayTimeText
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_int_in_range(key: &str, value: Option<&str>, min: i64, max: i64) -> ValidationResult {
|
||||
match value.and_then(|v| v.parse::<i64>().ok()) {
|
||||
Some(parsed) if parsed >= min && parsed <= max => ValidationResult::Ok,
|
||||
Some(_) => ValidationResult::Error(format!("'{key}' must be in range [{min}, {max}]")),
|
||||
None => ValidationResult::Error(format!("'{key}' must be a valid integer")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_float_in_range(key: &str, value: Option<&str>, min: f64, max: f64) -> ValidationResult {
|
||||
match value.and_then(|v| v.parse::<f64>().ok()) {
|
||||
Some(parsed) if parsed >= min && parsed <= max => ValidationResult::Ok,
|
||||
Some(_) => ValidationResult::Error(format!("'{key}' must be in range [{min}, {max}]")),
|
||||
None => ValidationResult::Error(format!("'{key}' must be a valid float")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_non_negative_csv_ints(key: &str, value: &str) -> ValidationResult {
|
||||
for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
let Ok(parsed) = part.parse::<i64>() else {
|
||||
return ValidationResult::Error(format!("'{key}' contains non-integer '{part}'"));
|
||||
};
|
||||
if parsed < 0 {
|
||||
return ValidationResult::Error(format!("'{key}' cannot contain negative values"));
|
||||
}
|
||||
}
|
||||
ValidationResult::Ok
|
||||
}
|
||||
|
||||
fn validate_csv_ints(key: &str, value: &str) -> ValidationResult {
|
||||
for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if part.parse::<i64>().is_err() {
|
||||
return ValidationResult::Error(format!("'{key}' contains non-integer '{part}'"));
|
||||
}
|
||||
}
|
||||
ValidationResult::Ok
|
||||
}
|
||||
|
||||
fn validate_csv_floats(key: &str, value: &str) -> ValidationResult {
|
||||
for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if part.parse::<f64>().is_err() {
|
||||
return ValidationResult::Error(format!("'{key}' contains non-float '{part}'"));
|
||||
}
|
||||
}
|
||||
ValidationResult::Ok
|
||||
}
|
||||
|
||||
fn validate_csv_strings(
|
||||
key: &str,
|
||||
value: &str,
|
||||
valid_values: Option<&[String]>,
|
||||
) -> ValidationResult {
|
||||
if value.trim().is_empty() {
|
||||
return ValidationResult::Ok;
|
||||
}
|
||||
|
||||
if let Some(allowed) = valid_values {
|
||||
for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if !allowed.iter().any(|allowed| allowed == part) {
|
||||
return ValidationResult::Error(format!("'{key}' contains invalid value '{part}'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ValidationResult::Ok
|
||||
}
|
||||
|
||||
fn validate_csv_colors(key: &str, value: &str) -> ValidationResult {
|
||||
for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if !COLOR_RE.is_match(part) {
|
||||
return ValidationResult::Error(format!(
|
||||
"'{key}' contains invalid color '{part}' (expected RRGGBB)"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
ValidationResult::Ok
|
||||
}
|
||||
|
||||
fn validate_path(
|
||||
key: &str,
|
||||
value: &str,
|
||||
must_exist: bool,
|
||||
must_be_writable: bool,
|
||||
) -> ValidationResult {
|
||||
if value.trim().is_empty() {
|
||||
return ValidationResult::Ok;
|
||||
}
|
||||
|
||||
let path = Path::new(value);
|
||||
if must_exist && !path.exists() {
|
||||
return ValidationResult::Error(format!("'{key}' path does not exist"));
|
||||
}
|
||||
|
||||
if must_be_writable {
|
||||
let target = if path.exists() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
match path.parent() {
|
||||
Some(parent) => parent.to_path_buf(),
|
||||
None => return ValidationResult::Error(format!("'{key}' has invalid path parent")),
|
||||
}
|
||||
};
|
||||
|
||||
match fs::metadata(&target) {
|
||||
Ok(metadata) if metadata.permissions().readonly() => {
|
||||
return ValidationResult::Error(format!("'{key}' path is not writable"));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
return ValidationResult::Error(format!("'{key}' cannot access path metadata"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ValidationResult::Ok
|
||||
}
|
||||
|
||||
fn validate_percentile_list(key: &str, value: &str) -> ValidationResult {
|
||||
for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if part.eq_ignore_ascii_case("AVG") {
|
||||
continue;
|
||||
}
|
||||
let Ok(parsed) = part.parse::<f64>() else {
|
||||
return ValidationResult::Error(format!(
|
||||
"'{key}' contains invalid percentile '{part}'"
|
||||
));
|
||||
};
|
||||
if !(0.0..=100.0).contains(&parsed) {
|
||||
return ValidationResult::Error(format!(
|
||||
"'{key}' percentile '{part}' is out of range [0,100]"
|
||||
));
|
||||
}
|
||||
}
|
||||
ValidationResult::Ok
|
||||
}
|
||||
|
||||
fn validate_time_format(value: &str) -> ValidationResult {
|
||||
if value.is_empty() {
|
||||
return ValidationResult::Ok;
|
||||
}
|
||||
|
||||
let mut chars = value.chars().peekable();
|
||||
let mut has_specifier = false;
|
||||
let allowed = "aAbBcdDeFgGhHIjmMnprRStTuUVwWxXyYzZ%";
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '%' {
|
||||
continue;
|
||||
}
|
||||
let Some(next) = chars.next() else {
|
||||
return ValidationResult::Warning("time_format ends with '%'".to_string());
|
||||
};
|
||||
has_specifier = true;
|
||||
if !allowed.contains(next) {
|
||||
return ValidationResult::Warning(format!(
|
||||
"time_format uses unknown specifier '%{next}'"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !has_specifier {
|
||||
return ValidationResult::Warning("time_format has no strftime specifiers".to_string());
|
||||
}
|
||||
ValidationResult::Ok
|
||||
}
|
||||
|
||||
fn is_active(value: &ConfigValue) -> bool {
|
||||
match value {
|
||||
ConfigValue::Absent | ConfigValue::Disabled => false,
|
||||
ConfigValue::Flag => true,
|
||||
ConfigValue::Value(v) => {
|
||||
let trimmed = v.trim();
|
||||
!(trimmed.is_empty() || trimmed == "0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_threshold_shape_checks(
|
||||
config: &AnnotatedConfig,
|
||||
issues: &mut HashMap<String, ValidationResult>,
|
||||
) {
|
||||
for (toggle_key, value_key, color_key) in [
|
||||
("fps_color_change", "fps_value", "fps_color"),
|
||||
("gpu_load_change", "gpu_load_value", "gpu_load_color"),
|
||||
("cpu_load_change", "cpu_load_value", "cpu_load_color"),
|
||||
] {
|
||||
let toggle_active = config
|
||||
.options
|
||||
.get(toggle_key)
|
||||
.map(|(_, value)| is_active(value))
|
||||
.unwrap_or(false);
|
||||
if !toggle_active {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value_count = csv_value_count(config, value_key);
|
||||
let color_count = csv_value_count(config, color_key);
|
||||
if value_count == 0 || color_count == 0 || value_count == color_count {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = format!(
|
||||
"'{}' and '{}' must contain the same number of entries while '{}' is enabled",
|
||||
value_key, color_key, toggle_key
|
||||
);
|
||||
issues.insert(
|
||||
value_key.to_string(),
|
||||
ValidationResult::Error(message.clone()),
|
||||
);
|
||||
issues.insert(color_key.to_string(), ValidationResult::Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
fn csv_value_count(config: &AnnotatedConfig, key: &str) -> usize {
|
||||
config
|
||||
.options
|
||||
.get(key)
|
||||
.and_then(|(_, value)| match value {
|
||||
ConfigValue::Value(text) => Some(text),
|
||||
ConfigValue::Flag | ConfigValue::Absent | ConfigValue::Disabled => None,
|
||||
})
|
||||
.map(|text| {
|
||||
text.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::schema::MANGOHUD_SCHEMA;
|
||||
use crate::config::types::{AnnotatedConfig, ConfigLine, ConfigValue, OptionType, SchemaEntry};
|
||||
use indexmap::IndexMap;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn schema_for(option_type: OptionType) -> SchemaEntry {
|
||||
SchemaEntry {
|
||||
key: "test_key",
|
||||
option_type,
|
||||
description: "test",
|
||||
category: Category::Performance,
|
||||
dependencies: &[],
|
||||
conflicts_with: &[],
|
||||
gpu_vendor_only: crate::config::types::GpuVendor::Any,
|
||||
gamescope_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn config_with(key: &str, value: ConfigValue) -> AnnotatedConfig {
|
||||
let mut options = IndexMap::new();
|
||||
options.insert(key.to_string(), (0, value));
|
||||
AnnotatedConfig {
|
||||
lines: vec![ConfigLine::Blank],
|
||||
options,
|
||||
path: None,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_bool_and_int_boundaries() {
|
||||
let bool_schema = schema_for(OptionType::Bool);
|
||||
assert_eq!(
|
||||
validate_value("b", &ConfigValue::Value("1".into()), &bool_schema),
|
||||
ValidationResult::Ok
|
||||
);
|
||||
assert!(matches!(
|
||||
validate_value("b", &ConfigValue::Value("2".into()), &bool_schema),
|
||||
ValidationResult::Error(_)
|
||||
));
|
||||
|
||||
let int_schema = schema_for(OptionType::Int { min: 1, max: 3 });
|
||||
assert_eq!(
|
||||
validate_value("i", &ConfigValue::Value("1".into()), &int_schema),
|
||||
ValidationResult::Ok
|
||||
);
|
||||
assert_eq!(
|
||||
validate_value("i", &ConfigValue::Value("3".into()), &int_schema),
|
||||
ValidationResult::Ok
|
||||
);
|
||||
assert!(matches!(
|
||||
validate_value("i", &ConfigValue::Value("4".into()), &int_schema),
|
||||
ValidationResult::Error(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_float_color_enum_keybind_and_lists() {
|
||||
let float_schema = schema_for(OptionType::Float { min: 0.0, max: 1.0 });
|
||||
assert_eq!(
|
||||
validate_value("f", &ConfigValue::Value("0.5".into()), &float_schema),
|
||||
ValidationResult::Ok
|
||||
);
|
||||
assert!(matches!(
|
||||
validate_value("f", &ConfigValue::Value("1.5".into()), &float_schema),
|
||||
ValidationResult::Error(_)
|
||||
));
|
||||
|
||||
let color_schema = schema_for(OptionType::Color);
|
||||
assert_eq!(
|
||||
validate_value("c", &ConfigValue::Value("AABBCC".into()), &color_schema),
|
||||
ValidationResult::Ok
|
||||
);
|
||||
assert!(matches!(
|
||||
validate_value("c", &ConfigValue::Value("GGGGGG".into()), &color_schema),
|
||||
ValidationResult::Error(_)
|
||||
));
|
||||
|
||||
let enum_schema = schema_for(OptionType::Enum {
|
||||
variants: vec!["a".into(), "b".into()],
|
||||
});
|
||||
assert_eq!(
|
||||
validate_value("e", &ConfigValue::Value("a".into()), &enum_schema),
|
||||
ValidationResult::Ok
|
||||
);
|
||||
assert!(matches!(
|
||||
validate_value("e", &ConfigValue::Value("c".into()), &enum_schema),
|
||||
ValidationResult::Error(_)
|
||||
));
|
||||
|
||||
let kb_schema = schema_for(OptionType::KeyBind);
|
||||
assert_eq!(
|
||||
validate_value("k", &ConfigValue::Value("Shift_R+F12".into()), &kb_schema),
|
||||
ValidationResult::Ok
|
||||
);
|
||||
assert_eq!(
|
||||
validate_value("k", &ConfigValue::Value("R_Shift+F11".into()), &kb_schema),
|
||||
ValidationResult::Ok
|
||||
);
|
||||
assert_eq!(
|
||||
validate_value("k", &ConfigValue::Value("Page_Up".into()), &kb_schema),
|
||||
ValidationResult::Ok
|
||||
);
|
||||
assert!(matches!(
|
||||
validate_value("k", &ConfigValue::Value("bad key".into()), &kb_schema),
|
||||
ValidationResult::Error(_)
|
||||
));
|
||||
|
||||
let fps_schema = schema_for(OptionType::FpsLimitList);
|
||||
assert_eq!(
|
||||
validate_value(
|
||||
"fps_limit",
|
||||
&ConfigValue::Value("0,30,60".into()),
|
||||
&fps_schema
|
||||
),
|
||||
ValidationResult::Ok
|
||||
);
|
||||
assert!(matches!(
|
||||
validate_value(
|
||||
"fps_limit",
|
||||
&ConfigValue::Value("30,-1".into()),
|
||||
&fps_schema
|
||||
),
|
||||
ValidationResult::Error(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_and_conflict_checks_work() {
|
||||
let mut cfg = AnnotatedConfig {
|
||||
lines: vec![],
|
||||
options: IndexMap::new(),
|
||||
path: None,
|
||||
dirty: false,
|
||||
};
|
||||
cfg.options
|
||||
.insert("gpu_mem_clock".to_string(), (0, ConfigValue::Flag));
|
||||
cfg.options
|
||||
.insert("fps_only".to_string(), (1, ConfigValue::Flag));
|
||||
cfg.options
|
||||
.insert("fps".to_string(), (2, ConfigValue::Flag));
|
||||
|
||||
let deps = check_dependencies(&cfg);
|
||||
assert!(deps
|
||||
.iter()
|
||||
.any(|(dependent, required)| dependent == "gpu_mem_clock" && required == "vram"));
|
||||
|
||||
let conflicts = check_conflicts(&cfg);
|
||||
assert!(conflicts
|
||||
.iter()
|
||||
.any(|(a, b)| { (a == "fps" && b == "fps_only") || (a == "fps_only" && b == "fps") }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_flags_require_supporting_values() {
|
||||
let mut cfg = AnnotatedConfig {
|
||||
lines: vec![],
|
||||
options: IndexMap::new(),
|
||||
path: None,
|
||||
dirty: false,
|
||||
};
|
||||
cfg.options
|
||||
.insert("fps_color_change".to_string(), (0, ConfigValue::Flag));
|
||||
cfg.options
|
||||
.insert("fps".to_string(), (1, ConfigValue::Flag));
|
||||
|
||||
let deps = check_dependencies(&cfg);
|
||||
assert!(deps.iter().any(
|
||||
|(dependent, required)| dependent == "fps_color_change" && required == "fps_value"
|
||||
));
|
||||
assert!(deps.iter().any(
|
||||
|(dependent, required)| dependent == "fps_color_change" && required == "fps_color"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_flags_require_matching_value_and_color_counts() {
|
||||
let mut cfg = AnnotatedConfig {
|
||||
lines: vec![],
|
||||
options: IndexMap::new(),
|
||||
path: None,
|
||||
dirty: false,
|
||||
};
|
||||
cfg.options
|
||||
.insert("fps".to_string(), (0, ConfigValue::Flag));
|
||||
cfg.options
|
||||
.insert("fps_color_change".to_string(), (1, ConfigValue::Flag));
|
||||
cfg.options.insert(
|
||||
"fps_value".to_string(),
|
||||
(2, ConfigValue::Value("30,60".into())),
|
||||
);
|
||||
cfg.options.insert(
|
||||
"fps_color".to_string(),
|
||||
(3, ConfigValue::Value("FF4D4D,FFD24D,66FF99".into())),
|
||||
);
|
||||
|
||||
let issues = validate_all(&cfg);
|
||||
assert!(matches!(
|
||||
issues.get("fps_value"),
|
||||
Some(ValidationResult::Error(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
issues.get("fps_color"),
|
||||
Some(ValidationResult::Error(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_color_lists_validate_each_hex_entry() {
|
||||
let schema = schema_for(OptionType::CommaSepStrings { valid_values: None });
|
||||
let result = validate_value(
|
||||
"fps_color",
|
||||
&ConfigValue::Value("FF4D4D,BADHEX,66FF99".into()),
|
||||
&schema,
|
||||
);
|
||||
assert!(matches!(result, ValidationResult::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dormant_string_dependency_warns_instead_of_blocking_save() {
|
||||
let mut cfg = AnnotatedConfig {
|
||||
lines: vec![],
|
||||
options: IndexMap::new(),
|
||||
path: None,
|
||||
dirty: false,
|
||||
};
|
||||
cfg.options.insert(
|
||||
"media_player_format".to_string(),
|
||||
(0, ConfigValue::Value("{title};{artist}".into())),
|
||||
);
|
||||
|
||||
let issues = validate_all(&cfg);
|
||||
assert!(matches!(
|
||||
issues.get("media_player_format"),
|
||||
Some(ValidationResult::Warning(_))
|
||||
));
|
||||
assert!(is_saveable(&cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saveable_false_on_errors_true_on_warnings() {
|
||||
let cfg = config_with("fps", ConfigValue::Value("bad".into()));
|
||||
assert!(!is_saveable(&cfg));
|
||||
|
||||
let warning_schema = schema_for(OptionType::Str { max_len: 16 });
|
||||
let warning = validate_value(
|
||||
"time_format",
|
||||
&ConfigValue::Value("invalid".into()),
|
||||
&warning_schema,
|
||||
);
|
||||
assert!(matches!(
|
||||
warning,
|
||||
ValidationResult::Ok | ValidationResult::Warning(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benchmark_percentiles_warns_but_validates() {
|
||||
let schema = schema_for(OptionType::CommaSepStrings { valid_values: None });
|
||||
let result = validate_value(
|
||||
"benchmark_percentiles",
|
||||
&ConfigValue::Value("97,AVG".into()),
|
||||
&schema,
|
||||
);
|
||||
assert!(matches!(result, ValidationResult::Warning(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_entries_are_unique() {
|
||||
let mut seen = HashSet::new();
|
||||
for entry in MANGOHUD_SCHEMA.iter() {
|
||||
assert!(seen.insert(entry.key), "duplicate schema key {}", entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_schema_entry_accepts_representative_value() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
for entry in MANGOHUD_SCHEMA.iter() {
|
||||
let value = representative_value(entry, temp.path());
|
||||
let result = validate_value(entry.key, &value, entry);
|
||||
assert!(
|
||||
!matches!(result, ValidationResult::Error(_)),
|
||||
"representative value should validate for key '{}': {:?}",
|
||||
entry.key,
|
||||
result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn representative_value(entry: &SchemaEntry, temp_root: &std::path::Path) -> ConfigValue {
|
||||
match entry.key {
|
||||
"fps_metrics" | "benchmark_percentiles" => ConfigValue::Value("AVG,95".to_string()),
|
||||
"time_format" => ConfigValue::Value("%H:%M:%S".to_string()),
|
||||
"pci_dev" => ConfigValue::Value("0000:01:00.0".to_string()),
|
||||
"ftrace" => ConfigValue::Value("histogram/foo/bar".to_string()),
|
||||
"control" => ConfigValue::Value("-1".to_string()),
|
||||
"fps_color" | "gpu_load_color" | "cpu_load_color" => {
|
||||
ConfigValue::Value("FF4D4D,FFD24D,66FF99".to_string())
|
||||
}
|
||||
_ => match &entry.option_type {
|
||||
OptionType::Flag => ConfigValue::Flag,
|
||||
OptionType::Bool => ConfigValue::Value("1".to_string()),
|
||||
OptionType::Int { min, .. } => ConfigValue::Value(min.to_string()),
|
||||
OptionType::Float { min, .. } => ConfigValue::Value(min.to_string()),
|
||||
OptionType::Str { .. } => ConfigValue::Value("sample".to_string()),
|
||||
OptionType::Color => ConfigValue::Value("A1B2C3".to_string()),
|
||||
OptionType::Enum { variants } => {
|
||||
ConfigValue::Value(variants.first().cloned().unwrap_or_default())
|
||||
}
|
||||
OptionType::FpsLimitList => ConfigValue::Value("0,60,120".to_string()),
|
||||
OptionType::KeyBind => ConfigValue::Value("Shift_R+F12".to_string()),
|
||||
OptionType::CommaSepInts => ConfigValue::Value("1,2,3".to_string()),
|
||||
OptionType::CommaSepFloats => ConfigValue::Value("0.5,1.5".to_string()),
|
||||
OptionType::CommaSepStrings { valid_values } => {
|
||||
let value = valid_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.first().cloned())
|
||||
.unwrap_or_else(|| "sample".to_string());
|
||||
ConfigValue::Value(value)
|
||||
}
|
||||
OptionType::Path {
|
||||
must_exist,
|
||||
must_be_writable: _,
|
||||
} => {
|
||||
let path = if *must_exist {
|
||||
temp_root.to_path_buf()
|
||||
} else {
|
||||
temp_root.join("generated-path")
|
||||
};
|
||||
ConfigValue::Value(path.display().to_string())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const MAX_LOG_LINES: usize = 500;
|
||||
|
||||
static LOG_BUFFER: Lazy<Mutex<VecDeque<String>>> =
|
||||
Lazy::new(|| Mutex::new(VecDeque::with_capacity(MAX_LOG_LINES)));
|
||||
|
||||
pub fn record(message: &str) {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let line = format!("[mangotune][{ts}] {message}");
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
eprintln!("{line}");
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
tracing::debug!("{message}");
|
||||
}
|
||||
|
||||
if let Ok(mut buffer) = LOG_BUFFER.lock() {
|
||||
buffer.push_back(line);
|
||||
while buffer.len() > MAX_LOG_LINES {
|
||||
buffer.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lines() -> Vec<String> {
|
||||
LOG_BUFFER
|
||||
.lock()
|
||||
.map(|buffer| buffer.iter().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn text() -> String {
|
||||
lines().join("\n")
|
||||
}
|
||||
|
||||
pub fn clear() {
|
||||
if let Ok(mut buffer) = LOG_BUFFER.lock() {
|
||||
buffer.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GameConfigHint {
|
||||
pub appid: Option<u32>,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub aliases: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub candidates: Vec<String>,
|
||||
pub preferred: String,
|
||||
pub verification: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GameConfigDb {
|
||||
game: Vec<GameConfigHint>,
|
||||
}
|
||||
|
||||
static GAME_HINTS: Lazy<Vec<GameConfigHint>> = Lazy::new(|| {
|
||||
toml::from_str::<GameConfigDb>(include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/data/game_config_db.toml"
|
||||
)))
|
||||
.expect("valid game config DB")
|
||||
.game
|
||||
});
|
||||
|
||||
fn normalize(text: &str) -> String {
|
||||
text.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.flat_map(|ch| ch.to_lowercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn score_hint(query: &str, hint: &GameConfigHint) -> Option<i32> {
|
||||
let query = normalize(query);
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut best = None;
|
||||
let candidates = hint
|
||||
.aliases
|
||||
.iter()
|
||||
.chain(hint.candidates.iter())
|
||||
.chain(std::iter::once(&hint.title))
|
||||
.chain(std::iter::once(&hint.preferred));
|
||||
for raw in candidates {
|
||||
let normalized = normalize(raw);
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let score = if normalized == query {
|
||||
1000
|
||||
} else if normalized.starts_with(&query) {
|
||||
850
|
||||
} else if normalized.contains(&query) {
|
||||
700
|
||||
} else if query.contains(&normalized) && normalized.len() >= 4 {
|
||||
450
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
best = Some(best.map_or(score, |old: i32| old.max(score)));
|
||||
}
|
||||
best.map(|score| {
|
||||
score
|
||||
+ i32::try_from(hint.title.len())
|
||||
.unwrap_or(0)
|
||||
.saturating_neg()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn search_game_config_hints(query: &str, limit: usize) -> Vec<GameConfigHint> {
|
||||
let mut matches = GAME_HINTS
|
||||
.iter()
|
||||
.filter_map(|hint| score_hint(query, hint).map(|score| (score, hint.clone())))
|
||||
.collect::<Vec<_>>();
|
||||
matches.sort_by(|a, b| {
|
||||
b.0.cmp(&a.0)
|
||||
.then_with(|| a.1.title.to_lowercase().cmp(&b.1.title.to_lowercase()))
|
||||
});
|
||||
if let Some((top_score, _)) = matches.first() {
|
||||
let threshold = if *top_score >= 1000 {
|
||||
900
|
||||
} else if *top_score >= 850 {
|
||||
760
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if threshold > 0 {
|
||||
matches.retain(|(score, _)| *score >= threshold);
|
||||
}
|
||||
}
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|(_, hint)| hint)
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_candidate_matches() {
|
||||
let results = search_game_config_hints("cs2", 5);
|
||||
assert!(results.iter().any(|hint| hint.preferred == "cs2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_search_matches() {
|
||||
let results = search_game_config_hints("valheim", 5);
|
||||
assert!(results.iter().any(|hint| hint.title == "Valheim"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use sysinfo::{ProcessesToUpdate, System};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameModeStatus {
|
||||
pub daemon_installed: bool,
|
||||
pub ctl_installed: bool,
|
||||
pub daemon_running: bool,
|
||||
pub current_clients: u32,
|
||||
}
|
||||
|
||||
pub async fn detect() -> GameModeStatus {
|
||||
let daemon_path = which("gamemoded");
|
||||
let ctl_path = which("gamemodectl");
|
||||
|
||||
let mut system = System::new_all();
|
||||
system.refresh_processes(ProcessesToUpdate::All);
|
||||
let daemon_running = system
|
||||
.processes()
|
||||
.values()
|
||||
.any(|process| process.name().to_string_lossy().contains("gamemoded"));
|
||||
|
||||
let current_clients = if ctl_path.is_some() {
|
||||
parse_clients(&run_cmd("gamemodectl", &["status"]).unwrap_or_default())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
GameModeStatus {
|
||||
daemon_installed: daemon_path.is_some(),
|
||||
ctl_installed: ctl_path.is_some(),
|
||||
daemon_running,
|
||||
current_clients,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_clients(status: &str) -> u32 {
|
||||
for line in status.lines() {
|
||||
if !line.to_ascii_lowercase().contains("client") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(number) = line
|
||||
.split(|ch: char| !ch.is_ascii_digit())
|
||||
.find(|part| !part.is_empty())
|
||||
.and_then(|part| part.parse::<u32>().ok())
|
||||
{
|
||||
return number;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn run_cmd(program: &str, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
fn which(tool: &str) -> Option<PathBuf> {
|
||||
let output = Command::new("which").arg(tool).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_client_count_variants() {
|
||||
assert_eq!(parse_clients("gamemode is active with 3 clients"), 3);
|
||||
assert_eq!(parse_clients("No clients"), 0);
|
||||
assert_eq!(parse_clients("Clients: 11"), 11);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use crate::system::paths::heroic_config_dirs;
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeroicStatus {
|
||||
pub installed: bool,
|
||||
pub flatpak: bool,
|
||||
pub config_dir: Option<PathBuf>,
|
||||
pub games: Vec<HeroicGame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeroicGame {
|
||||
pub title: String,
|
||||
pub app_name: String,
|
||||
pub store: HeroicStore,
|
||||
pub config_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HeroicStore {
|
||||
Epic,
|
||||
Gog,
|
||||
Amazon,
|
||||
}
|
||||
|
||||
pub async fn detect() -> HeroicStatus {
|
||||
let installed = which("heroic").is_some();
|
||||
let flatpak = flatpak_list_contains("com.heroicgameslauncher.hgl");
|
||||
let config_dir = heroic_config_dirs().into_iter().find(|path| path.exists());
|
||||
let games = config_dir
|
||||
.as_ref()
|
||||
.map(|path| read_games(path.as_path()))
|
||||
.unwrap_or_default();
|
||||
|
||||
HeroicStatus {
|
||||
installed,
|
||||
flatpak,
|
||||
config_dir,
|
||||
games,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_wrapper_enabled(config_path: &Path, enabled: bool) -> Result<()> {
|
||||
update_json(config_path, |doc| {
|
||||
let wrappers = ensure_array(doc, "wrapperOptions");
|
||||
if enabled {
|
||||
if !wrappers.iter().any(is_mangohud_wrapper) {
|
||||
wrappers.push(json!({"exe": "mangohud", "args": ""}));
|
||||
}
|
||||
} else {
|
||||
wrappers.retain(|entry| !is_mangohud_wrapper(entry));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_environment_enabled(config_path: &Path, enabled: bool) -> Result<()> {
|
||||
update_json(config_path, |doc| {
|
||||
let envs = ensure_array(doc, "enviromentOptions");
|
||||
if enabled {
|
||||
if !envs.iter().any(is_mangohud_env) {
|
||||
envs.push(json!({"key": "MANGOHUD", "value": "1"}));
|
||||
}
|
||||
} else {
|
||||
envs.retain(|entry| !is_mangohud_env(entry));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn update_json<F>(config_path: &Path, mut patch: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(&mut Value),
|
||||
{
|
||||
let content = fs::read_to_string(config_path)
|
||||
.with_context(|| format!("failed to read {}", config_path.display()))?;
|
||||
let mut value: Value = serde_json::from_str(&content)
|
||||
.with_context(|| format!("invalid json in {}", config_path.display()))?;
|
||||
|
||||
patch(&mut value);
|
||||
|
||||
let output = serde_json::to_string_pretty(&value)?;
|
||||
fs::write(config_path, format!("{output}\n"))
|
||||
.with_context(|| format!("failed to write {}", config_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_games(config_dir: &Path) -> Vec<HeroicGame> {
|
||||
let games_dir = config_dir.join("GamesConfig");
|
||||
let mut games = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(games_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(content) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(json): Result<Value, _> = serde_json::from_str(&content) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let app_name = json
|
||||
.get("appName")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| {
|
||||
path.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let title = json
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| app_name.clone());
|
||||
|
||||
let store = infer_store(&json, &path);
|
||||
|
||||
games.push(HeroicGame {
|
||||
title,
|
||||
app_name,
|
||||
store,
|
||||
config_path: path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
games.sort_by(|a, b| a.title.cmp(&b.title));
|
||||
games
|
||||
}
|
||||
|
||||
fn ensure_array<'a>(doc: &'a mut Value, key: &str) -> &'a mut Vec<Value> {
|
||||
let map = doc
|
||||
.as_object_mut()
|
||||
.expect("heroic config must be an object");
|
||||
let entry = map
|
||||
.entry(key.to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !entry.is_array() {
|
||||
*entry = Value::Array(Vec::new());
|
||||
}
|
||||
entry.as_array_mut().expect("array inserted")
|
||||
}
|
||||
|
||||
fn is_mangohud_wrapper(value: &Value) -> bool {
|
||||
value
|
||||
.get("exe")
|
||||
.and_then(Value::as_str)
|
||||
.map(|exe| exe == "mangohud")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_mangohud_env(value: &Value) -> bool {
|
||||
value
|
||||
.get("key")
|
||||
.and_then(Value::as_str)
|
||||
.map(|key| key == "MANGOHUD")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn infer_store(json: &Value, path: &Path) -> HeroicStore {
|
||||
if let Some(store) = json.get("store").and_then(Value::as_str) {
|
||||
return match store.to_ascii_lowercase().as_str() {
|
||||
"gog" => HeroicStore::Gog,
|
||||
"amazon" => HeroicStore::Amazon,
|
||||
_ => HeroicStore::Epic,
|
||||
};
|
||||
}
|
||||
|
||||
let lower = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if lower.contains("gog") {
|
||||
HeroicStore::Gog
|
||||
} else if lower.contains("amazon") {
|
||||
HeroicStore::Amazon
|
||||
} else {
|
||||
HeroicStore::Epic
|
||||
}
|
||||
}
|
||||
|
||||
fn flatpak_list_contains(app_id: &str) -> bool {
|
||||
let output = Command::new("flatpak").arg("list").output();
|
||||
let Ok(output) = output else {
|
||||
return false;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout).contains(app_id)
|
||||
}
|
||||
|
||||
fn which(tool: &str) -> Option<PathBuf> {
|
||||
let output = Command::new("which").arg(tool).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn wrapper_toggle_updates_json_without_data_loss() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let path = dir.path().join("game.json");
|
||||
let source = json!({
|
||||
"appName": "abc",
|
||||
"title": "My Game",
|
||||
"other": { "nested": true },
|
||||
"wrapperOptions": []
|
||||
});
|
||||
fs::write(&path, serde_json::to_string_pretty(&source).expect("json")).expect("write");
|
||||
|
||||
set_wrapper_enabled(&path, true).expect("enable wrapper");
|
||||
let after_enable: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&path).expect("read json")).expect("parse");
|
||||
assert!(after_enable["wrapperOptions"]
|
||||
.as_array()
|
||||
.expect("array")
|
||||
.iter()
|
||||
.any(is_mangohud_wrapper));
|
||||
assert_eq!(after_enable["other"]["nested"], Value::Bool(true));
|
||||
|
||||
set_wrapper_enabled(&path, false).expect("disable wrapper");
|
||||
let after_disable: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&path).expect("read json")).expect("parse");
|
||||
assert!(after_disable["wrapperOptions"]
|
||||
.as_array()
|
||||
.expect("array")
|
||||
.iter()
|
||||
.all(|item| !is_mangohud_wrapper(item)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_toggle_updates_json() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let path = dir.path().join("game.json");
|
||||
fs::write(
|
||||
&path,
|
||||
serde_json::to_string_pretty(&json!({ "enviromentOptions": [] })).expect("json"),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
set_environment_enabled(&path, true).expect("enable env");
|
||||
let after_enable: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&path).expect("read json")).expect("parse");
|
||||
assert!(after_enable["enviromentOptions"]
|
||||
.as_array()
|
||||
.expect("array")
|
||||
.iter()
|
||||
.any(is_mangohud_env));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use crate::system::paths::lutris_config_dirs;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LutrisStatus {
|
||||
pub installed: bool,
|
||||
pub flatpak: bool,
|
||||
pub config_dir: Option<PathBuf>,
|
||||
pub games: Vec<LutrisGame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LutrisGame {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub config_path: PathBuf,
|
||||
pub runner: String,
|
||||
}
|
||||
|
||||
pub async fn detect() -> LutrisStatus {
|
||||
let installed = which("lutris").is_some();
|
||||
let flatpak = flatpak_list_contains("net.lutris.Lutris");
|
||||
|
||||
let config_dir = lutris_config_dirs().into_iter().find(|path| path.exists());
|
||||
let games = config_dir
|
||||
.as_ref()
|
||||
.map(|path| read_games(path.as_path()))
|
||||
.unwrap_or_default();
|
||||
|
||||
LutrisStatus {
|
||||
installed,
|
||||
flatpak,
|
||||
config_dir,
|
||||
games,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_mangohud_enabled(game_config_path: &Path, enabled: bool) -> Result<()> {
|
||||
let content = fs::read_to_string(game_config_path)
|
||||
.with_context(|| format!("failed to read {}", game_config_path.display()))?;
|
||||
let rewritten = rewrite_mangohud_in_yaml(&content, enabled);
|
||||
fs::write(game_config_path, rewritten)
|
||||
.with_context(|| format!("failed to write {}", game_config_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_games(config_dir: &Path) -> Vec<LutrisGame> {
|
||||
let games_dir = config_dir.join("games");
|
||||
let mut games = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(games_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("yml") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
let slug = path
|
||||
.file_stem()
|
||||
.and_then(|item| item.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let name = parse_yaml_value(&content, "name")
|
||||
.or_else(|| parse_yaml_value(&content, "game"))
|
||||
.unwrap_or_else(|| slug.clone());
|
||||
let runner = parse_yaml_value(&content, "runner").unwrap_or_default();
|
||||
games.push(LutrisGame {
|
||||
name,
|
||||
slug,
|
||||
config_path: path,
|
||||
runner,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
games.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
games
|
||||
}
|
||||
|
||||
fn parse_yaml_value(content: &str, key: &str) -> Option<String> {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(value) = trimmed.strip_prefix(&format!("{key}:")) {
|
||||
return Some(value.trim().trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn rewrite_mangohud_in_yaml(content: &str, enabled: bool) -> String {
|
||||
let mut lines: Vec<String> = content.lines().map(ToString::to_string).collect();
|
||||
let desired_line = format!(" mangohud: {}", if enabled { "true" } else { "false" });
|
||||
|
||||
let mut system_idx = None;
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
if line.trim() == "system:" {
|
||||
system_idx = Some(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match system_idx {
|
||||
Some(start) => {
|
||||
let mut section_end = lines.len();
|
||||
for (idx, line) in lines.iter().enumerate().skip(start + 1) {
|
||||
if !line.starts_with(' ') && line.trim_end().ends_with(':') {
|
||||
section_end = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for line in lines.iter_mut().take(section_end).skip(start + 1) {
|
||||
if line.trim_start().starts_with("mangohud:") {
|
||||
*line = desired_line.clone();
|
||||
return format!("{}\n", lines.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
lines.insert(start + 1, desired_line);
|
||||
}
|
||||
None => {
|
||||
if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
|
||||
lines.push(String::new());
|
||||
}
|
||||
lines.push("system:".to_string());
|
||||
lines.push(desired_line);
|
||||
}
|
||||
}
|
||||
|
||||
format!("{}\n", lines.join("\n"))
|
||||
}
|
||||
|
||||
fn flatpak_list_contains(app_id: &str) -> bool {
|
||||
let output = Command::new("flatpak").arg("list").output();
|
||||
let Ok(output) = output else {
|
||||
return false;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout).contains(app_id)
|
||||
}
|
||||
|
||||
fn which(tool: &str) -> Option<PathBuf> {
|
||||
let output = Command::new("which").arg(tool).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rewrite_yaml_in_existing_system_section() {
|
||||
let input = "game:\n exe: foo\nsystem:\n mangohud: false\n foo: bar\n";
|
||||
let out = rewrite_mangohud_in_yaml(input, true);
|
||||
assert!(out.contains("mangohud: true"));
|
||||
assert!(out.contains("foo: bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_yaml_adds_section_if_missing() {
|
||||
let input = "game:\n exe: foo\n";
|
||||
let out = rewrite_mangohud_in_yaml(input, true);
|
||||
assert!(out.contains("system:"));
|
||||
assert!(out.contains("mangohud: true"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pub mod game_db;
|
||||
pub mod gamemode;
|
||||
pub mod heroic;
|
||||
pub mod lutris;
|
||||
pub mod steam;
|
||||
|
||||
pub use game_db::{search_game_config_hints, GameConfigHint};
|
||||
pub use gamemode::{detect as detect_gamemode, GameModeStatus};
|
||||
pub use heroic::{
|
||||
detect as detect_heroic, set_environment_enabled as set_heroic_env,
|
||||
set_wrapper_enabled as set_heroic_wrapper, HeroicGame, HeroicStatus, HeroicStore,
|
||||
};
|
||||
pub use lutris::{
|
||||
detect as detect_lutris, set_mangohud_enabled as set_lutris_mangohud, LutrisGame, LutrisStatus,
|
||||
};
|
||||
pub use steam::{detect as detect_steam, generate_launch_option, SteamInjectMethod, SteamStatus};
|
||||
@@ -0,0 +1,118 @@
|
||||
use crate::system::paths::steam_roots;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use sysinfo::{ProcessesToUpdate, System};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SteamStatus {
|
||||
pub installed: bool,
|
||||
pub flatpak: bool,
|
||||
pub running: bool,
|
||||
pub steam_root: Option<PathBuf>,
|
||||
pub localconfig_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SteamInjectMethod {
|
||||
MangohudPrefix,
|
||||
EnvVar,
|
||||
ExplicitConfig,
|
||||
GameMode,
|
||||
GameModeFlatpak,
|
||||
}
|
||||
|
||||
pub async fn detect() -> SteamStatus {
|
||||
let installed = which("steam").is_some();
|
||||
let flatpak = flatpak_list_contains("com.valvesoftware.Steam");
|
||||
|
||||
let mut system = System::new_all();
|
||||
system.refresh_processes(ProcessesToUpdate::All);
|
||||
let running = system
|
||||
.processes()
|
||||
.values()
|
||||
.any(|process| process.name().to_string_lossy().contains("steam"));
|
||||
|
||||
let steam_root = steam_roots().into_iter().find(|path| path.exists());
|
||||
let localconfig_path = steam_root
|
||||
.as_ref()
|
||||
.and_then(|path| find_localconfig(path.as_path()));
|
||||
|
||||
SteamStatus {
|
||||
installed,
|
||||
flatpak,
|
||||
running,
|
||||
steam_root,
|
||||
localconfig_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_launch_option(method: SteamInjectMethod, config_path: Option<&str>) -> String {
|
||||
match method {
|
||||
SteamInjectMethod::MangohudPrefix => "mangohud %command%".to_string(),
|
||||
SteamInjectMethod::EnvVar => "MANGOHUD=1 %command%".to_string(),
|
||||
SteamInjectMethod::ExplicitConfig => {
|
||||
let config = config_path.unwrap_or("~/.config/MangoHud/MangoHud.conf");
|
||||
format!("MANGOHUD_CONFIGFILE={} %command%", config)
|
||||
}
|
||||
SteamInjectMethod::GameMode => "gamemoderun mangohud %command%".to_string(),
|
||||
SteamInjectMethod::GameModeFlatpak => "gamemoderun mangohud %command%".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_localconfig(root: &Path) -> Option<PathBuf> {
|
||||
let userdata = root.join("userdata");
|
||||
let entries = fs::read_dir(userdata).ok()?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let candidate = entry.path().join("config/localconfig.vdf");
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn flatpak_list_contains(app_id: &str) -> bool {
|
||||
let output = Command::new("flatpak").arg("list").output();
|
||||
let Ok(output) = output else {
|
||||
return false;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout).contains(app_id)
|
||||
}
|
||||
|
||||
fn which(tool: &str) -> Option<PathBuf> {
|
||||
let output = Command::new("which").arg(tool).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launch_option_generation_covers_all_modes() {
|
||||
assert!(
|
||||
generate_launch_option(SteamInjectMethod::MangohudPrefix, None)
|
||||
.contains("mangohud %command%")
|
||||
);
|
||||
assert!(generate_launch_option(SteamInjectMethod::EnvVar, None).contains("MANGOHUD=1"));
|
||||
assert!(
|
||||
generate_launch_option(SteamInjectMethod::ExplicitConfig, Some("/tmp/mh.conf"))
|
||||
.contains("/tmp/mh.conf")
|
||||
);
|
||||
assert!(generate_launch_option(SteamInjectMethod::GameMode, None).contains("gamemoderun"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod runner;
|
||||
|
||||
pub use runner::{LaunchConfig, Runner, RunningProcess};
|
||||
@@ -0,0 +1,564 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
use nix::unistd::Pid;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct LaunchConfig {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub config_path: PathBuf,
|
||||
pub show_terminal: bool,
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub struct RunningProcess {
|
||||
pub child: Child,
|
||||
pub command: String,
|
||||
pub pid: u32,
|
||||
}
|
||||
|
||||
pub struct Runner;
|
||||
|
||||
impl Runner {
|
||||
/// Check if a tool is available on PATH.
|
||||
pub fn is_available(tool: &str) -> bool {
|
||||
which(tool).is_some()
|
||||
}
|
||||
|
||||
/// Launch a tool with MANGOHUD=1 and the specified config file.
|
||||
pub fn launch(config: LaunchConfig) -> Result<RunningProcess> {
|
||||
if config.command.trim().is_empty() {
|
||||
return Err(anyhow!("launch command cannot be empty"));
|
||||
}
|
||||
|
||||
let mut command = if config.show_terminal {
|
||||
build_terminal_command(&config).unwrap_or_else(|| build_direct_command(&config))
|
||||
} else {
|
||||
build_direct_command(&config)
|
||||
};
|
||||
|
||||
command
|
||||
.env("MANGOHUD", "1")
|
||||
.env("MANGOHUD_CONFIGFILE", &config.config_path)
|
||||
.stdin(Stdio::null());
|
||||
for (key, value) in &config.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
let child = command
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to launch command '{}':", config.command))?;
|
||||
|
||||
let pid = child.id();
|
||||
|
||||
Ok(RunningProcess {
|
||||
child,
|
||||
command: config.command,
|
||||
pid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop a running process (SIGTERM, then SIGKILL after 3s).
|
||||
pub fn stop(mut process: RunningProcess) -> Result<()> {
|
||||
let _ = kill(Pid::from_raw(process.child.id() as i32), Signal::SIGTERM);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
loop {
|
||||
match process.child.try_wait() {
|
||||
Ok(Some(_status)) => return Ok(()),
|
||||
Ok(None) => {
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(anyhow!("failed waiting for process exit: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = kill(Pid::from_raw(process.child.id() as i32), Signal::SIGKILL);
|
||||
let _ = process.child.wait();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send SIGUSR1 to reload config in a running MangoHud process.
|
||||
pub fn reload_config(pid: u32) -> Result<()> {
|
||||
kill(Pid::from_raw(pid as i32), Signal::SIGUSR1)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn active_window_geometry() -> Option<(i32, i32, i32, i32)> {
|
||||
active_window_geometry()
|
||||
}
|
||||
|
||||
pub fn window_geometry_for_pid(
|
||||
pid: u32,
|
||||
title_hint: Option<&str>,
|
||||
) -> Option<(i32, i32, i32, i32)> {
|
||||
window_geometry_for_pid(pid, title_hint)
|
||||
}
|
||||
|
||||
/// Best-effort docking: place a launched window to the right of the currently
|
||||
/// active window (typically MangoTune). Works on X11 when wmctrl is available.
|
||||
pub async fn dock_right_of_active_window(
|
||||
pid: u32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
command_hint: Option<&str>,
|
||||
anchor_geometry: Option<(i32, i32, i32, i32)>,
|
||||
) -> Result<()> {
|
||||
if std::env::var("WAYLAND_DISPLAY")
|
||||
.ok()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return Err(anyhow!("docking is not supported on Wayland"));
|
||||
}
|
||||
if which("wmctrl").is_none() {
|
||||
return Err(anyhow!("wmctrl is not installed"));
|
||||
}
|
||||
|
||||
let (base_x, base_y, base_width, _) = anchor_geometry
|
||||
.or_else(active_window_geometry)
|
||||
.unwrap_or((40, 40, 900, 600));
|
||||
let (target_x, target_y) = dock_target_position(
|
||||
(base_x, base_y, base_width),
|
||||
(width, height),
|
||||
display_geometry().unwrap_or((1920, 1080)),
|
||||
);
|
||||
|
||||
let window_id = wait_for_window_id(pid, command_hint).await?;
|
||||
run_wmctrl_move(&window_id, target_x, target_y, width, height)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_direct_command(config: &LaunchConfig) -> Command {
|
||||
let mut cmd = Command::new(&config.command);
|
||||
cmd.args(&config.args);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn build_terminal_command(config: &LaunchConfig) -> Option<Command> {
|
||||
let shell_line = shell_command_line(&config.command, &config.args);
|
||||
|
||||
if let Some(term) = std::env::var("MANGOTUNE_TERMINAL")
|
||||
.ok()
|
||||
.filter(|item| !item.trim().is_empty())
|
||||
{
|
||||
return Some(build_shell_terminal_command(term.into(), &shell_line));
|
||||
}
|
||||
|
||||
if let Some(term_program) = std::env::var("TERM_PROGRAM")
|
||||
.ok()
|
||||
.filter(|item| !item.trim().is_empty())
|
||||
{
|
||||
if let Some(path) = which(&term_program) {
|
||||
return Some(build_shell_terminal_command(path.into(), &shell_line));
|
||||
}
|
||||
}
|
||||
|
||||
for candidate in [
|
||||
"gnome-terminal",
|
||||
"kgx",
|
||||
"konsole",
|
||||
"xfce4-terminal",
|
||||
"mate-terminal",
|
||||
"lxterminal",
|
||||
"xterm",
|
||||
] {
|
||||
if let Some(path) = which(candidate) {
|
||||
return Some(build_shell_terminal_command(path.into(), &shell_line));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn build_shell_terminal_command(term: OsString, shell_line: &str) -> Command {
|
||||
let term_str = term.to_string_lossy();
|
||||
let mut cmd = Command::new(&term);
|
||||
|
||||
match term_str.as_ref() {
|
||||
name if name.contains("gnome-terminal") => {
|
||||
cmd.arg("--").arg("sh").arg("-lc").arg(shell_line);
|
||||
}
|
||||
name if name.contains("kgx") => {
|
||||
cmd.arg("-e").arg("sh").arg("-lc").arg(shell_line);
|
||||
}
|
||||
name if name.contains("konsole") => {
|
||||
cmd.arg("-e").arg("sh").arg("-lc").arg(shell_line);
|
||||
}
|
||||
name if name.contains("xterm")
|
||||
|| name.contains("xfce4-terminal")
|
||||
|| name.contains("mate-terminal")
|
||||
|| name.contains("lxterminal") =>
|
||||
{
|
||||
cmd.arg("-e").arg("sh").arg("-lc").arg(shell_line);
|
||||
}
|
||||
_ => {
|
||||
cmd.arg("-e").arg("sh").arg("-lc").arg(shell_line);
|
||||
}
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
fn shell_command_line(command: &str, args: &[String]) -> String {
|
||||
let mut parts = Vec::with_capacity(args.len() + 1);
|
||||
parts.push(shell_escape(command));
|
||||
parts.extend(args.iter().map(|item| shell_escape(item)));
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn shell_escape(text: &str) -> String {
|
||||
if text.is_empty() {
|
||||
return "''".to_string();
|
||||
}
|
||||
if text
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "-_=./:".contains(ch))
|
||||
{
|
||||
return text.to_string();
|
||||
}
|
||||
format!("'{}'", text.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
fn which(tool: &str) -> Option<PathBuf> {
|
||||
let output = std::process::Command::new("which")
|
||||
.arg(tool)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn active_window_geometry() -> Option<(i32, i32, i32, i32)> {
|
||||
let output = std::process::Command::new("xdotool")
|
||||
.args(["getactivewindow", "getwindowgeometry", "--shell"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_active_window_geometry(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
fn window_geometry_for_pid(pid: u32, title_hint: Option<&str>) -> Option<(i32, i32, i32, i32)> {
|
||||
let output = std::process::Command::new("wmctrl")
|
||||
.args(["-lpG"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_wmctrl_geometry_for_pid(&String::from_utf8_lossy(&output.stdout), pid, title_hint)
|
||||
}
|
||||
|
||||
fn display_geometry() -> Option<(i32, i32)> {
|
||||
let output = std::process::Command::new("xdotool")
|
||||
.args(["getdisplaygeometry"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_display_geometry(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
async fn wait_for_window_id(pid: u32, command_hint: Option<&str>) -> Result<String> {
|
||||
let hint = command_hint.map(|value| value.to_ascii_lowercase());
|
||||
for _ in 0..20 {
|
||||
if let Some(id) = window_id_for_pid(pid, hint.as_deref()) {
|
||||
return Ok(id);
|
||||
}
|
||||
glib::timeout_future(Duration::from_millis(150)).await;
|
||||
}
|
||||
Err(anyhow!("failed to find window id for pid {pid}"))
|
||||
}
|
||||
|
||||
fn window_id_for_pid(pid: u32, command_hint: Option<&str>) -> Option<String> {
|
||||
let output = std::process::Command::new("wmctrl")
|
||||
.arg("-lp")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pids = process_tree_pids(pid);
|
||||
let hint = command_hint.map(|value| value.to_ascii_lowercase());
|
||||
let mut title_match = None;
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
let parts = line.split_whitespace().collect::<Vec<_>>();
|
||||
if parts.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
if parts[2]
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.is_some_and(|item| pids.contains(&item))
|
||||
{
|
||||
return Some(parts[0].to_string());
|
||||
}
|
||||
|
||||
if let Some(hint) = hint.as_ref() {
|
||||
let title = parts[4..].join(" ").to_ascii_lowercase();
|
||||
if title.contains(hint) && title_match.is_none() {
|
||||
title_match = Some(parts[0].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
title_match
|
||||
}
|
||||
|
||||
fn process_tree_pids(root_pid: u32) -> HashSet<u32> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut queue = VecDeque::new();
|
||||
seen.insert(root_pid);
|
||||
queue.push_back(root_pid);
|
||||
|
||||
while let Some(pid) = queue.pop_front() {
|
||||
for child in child_pids(pid) {
|
||||
if seen.insert(child) {
|
||||
queue.push_back(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seen
|
||||
}
|
||||
|
||||
fn child_pids(parent_pid: u32) -> Vec<u32> {
|
||||
let output = std::process::Command::new("pgrep")
|
||||
.arg("-P")
|
||||
.arg(parent_pid.to_string())
|
||||
.output();
|
||||
let Ok(output) = output else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().parse::<u32>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_wmctrl_move(window_id: &str, x: i32, y: i32, width: i32, height: i32) -> Result<()> {
|
||||
let geometry = format!("0,{x},{y},{width},{height}");
|
||||
let status = std::process::Command::new("wmctrl")
|
||||
.args(["-ir", window_id, "-e", &geometry])
|
||||
.status()
|
||||
.context("failed to execute wmctrl move command")?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!("wmctrl failed to move window"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dock_target_position(
|
||||
anchor: (i32, i32, i32),
|
||||
size: (i32, i32),
|
||||
screen: (i32, i32),
|
||||
) -> (i32, i32) {
|
||||
let (base_x, base_y, base_width) = anchor;
|
||||
let (width, height) = size;
|
||||
let (screen_width, screen_height) = screen;
|
||||
let gap = 16;
|
||||
|
||||
let preferred_right = base_x + base_width + gap;
|
||||
let left_candidate = base_x - gap - width;
|
||||
let max_x = (screen_width - width).max(0);
|
||||
let max_y = (screen_height - height).max(0);
|
||||
|
||||
let x = if preferred_right + width <= screen_width {
|
||||
preferred_right
|
||||
} else if left_candidate >= 0 {
|
||||
left_candidate
|
||||
} else {
|
||||
preferred_right.clamp(0, max_x)
|
||||
};
|
||||
let y = base_y.clamp(0, max_y);
|
||||
(x, y)
|
||||
}
|
||||
|
||||
fn parse_active_window_geometry(output: &str) -> Option<(i32, i32, i32, i32)> {
|
||||
let mut x = None;
|
||||
let mut y = None;
|
||||
let mut width = None;
|
||||
let mut height = None;
|
||||
|
||||
for line in output.lines() {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
match key {
|
||||
"X" => x = value.parse::<i32>().ok(),
|
||||
"Y" => y = value.parse::<i32>().ok(),
|
||||
"WIDTH" => width = value.parse::<i32>().ok(),
|
||||
"HEIGHT" => height = value.parse::<i32>().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Some((x?, y?, width?, height?))
|
||||
}
|
||||
|
||||
fn parse_display_geometry(output: &str) -> Option<(i32, i32)> {
|
||||
let mut parts = output.split_whitespace();
|
||||
let width = parts.next()?.parse::<i32>().ok()?;
|
||||
let height = parts.next()?.parse::<i32>().ok()?;
|
||||
Some((width, height))
|
||||
}
|
||||
|
||||
fn parse_wmctrl_geometry_for_pid(
|
||||
output: &str,
|
||||
pid: u32,
|
||||
title_hint: Option<&str>,
|
||||
) -> Option<(i32, i32, i32, i32)> {
|
||||
let pids = process_tree_pids(pid);
|
||||
let hint = title_hint.map(|value| value.to_ascii_lowercase());
|
||||
let mut best_hint_match: Option<((i32, i32, i32, i32), i32)> = None;
|
||||
let mut best_pid_match: Option<((i32, i32, i32, i32), i32)> = None;
|
||||
|
||||
for line in output.lines() {
|
||||
let parts = line.split_whitespace().collect::<Vec<_>>();
|
||||
if parts.len() < 8 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(window_pid) = parts[2].parse::<u32>().ok() else {
|
||||
continue;
|
||||
};
|
||||
if !pids.contains(&window_pid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(x) = parts[3].parse::<i32>().ok() else {
|
||||
continue;
|
||||
};
|
||||
let Some(y) = parts[4].parse::<i32>().ok() else {
|
||||
continue;
|
||||
};
|
||||
let Some(width) = parts[5].parse::<i32>().ok() else {
|
||||
continue;
|
||||
};
|
||||
let Some(height) = parts[6].parse::<i32>().ok() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let geometry = (x, y, width, height);
|
||||
let area = width.saturating_mul(height);
|
||||
let title = parts
|
||||
.get(8..)
|
||||
.map(|rest| rest.join(" "))
|
||||
.unwrap_or_default();
|
||||
let title_matches = hint
|
||||
.as_ref()
|
||||
.is_some_and(|needle| title.to_ascii_lowercase().contains(needle));
|
||||
|
||||
if title_matches {
|
||||
match best_hint_match {
|
||||
Some((_, current_area)) if current_area >= area => {}
|
||||
_ => best_hint_match = Some((geometry, area)),
|
||||
}
|
||||
} else {
|
||||
match best_pid_match {
|
||||
Some((_, current_area)) if current_area >= area => {}
|
||||
_ => best_pid_match = Some((geometry, area)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best_hint_match
|
||||
.map(|(geometry, _)| geometry)
|
||||
.or_else(|| best_pid_match.map(|(geometry, _)| geometry))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn launch_and_stop_basic_process() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let cfg = LaunchConfig {
|
||||
command: "sh".to_string(),
|
||||
args: vec!["-lc".to_string(), "sleep 1".to_string()],
|
||||
config_path: dir.path().join("MangoHud.conf"),
|
||||
show_terminal: false,
|
||||
env: Vec::new(),
|
||||
};
|
||||
|
||||
let running = Runner::launch(cfg).expect("launch");
|
||||
assert!(running.pid > 0);
|
||||
Runner::stop(running).expect("stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_is_escaped() {
|
||||
let line = shell_command_line("/usr/bin/echo", &["hello world".into(), "x".into()]);
|
||||
assert!(line.contains("'hello world'"));
|
||||
assert!(line.contains("/usr/bin/echo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn availability_check_runs() {
|
||||
let _ = Runner::is_available("sh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_xdotool_geometry_output() {
|
||||
let sample = "WINDOW=123\nX=240\nY=120\nWIDTH=1024\nHEIGHT=640\nSCREEN=0\n";
|
||||
assert_eq!(
|
||||
parse_active_window_geometry(sample),
|
||||
Some((240, 120, 1024, 640))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_display_geometry_output() {
|
||||
assert_eq!(parse_display_geometry("2560 1440\n"), Some((2560, 1440)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_wmctrl_geometry_for_pid_preferring_title_match() {
|
||||
let sample = "\
|
||||
0x01200007 0 4242 40 50 420 220 host Settings\n\
|
||||
0x0140000a 0 4242 120 80 920 780 host MangoTune\n\
|
||||
0x0160000c 0 7777 10 20 600 400 host Other App\n";
|
||||
assert_eq!(
|
||||
parse_wmctrl_geometry_for_pid(sample, 4242, Some("MangoTune")),
|
||||
Some((120, 80, 920, 780))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docks_left_when_right_side_would_be_offscreen() {
|
||||
let pos = dock_target_position((1400, 100, 700), (900, 700), (1920, 1080));
|
||||
assert_eq!(pos, (484, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_docked_position_inside_screen() {
|
||||
let pos = dock_target_position((1700, 900, 500), (700, 400), (1920, 1080));
|
||||
assert_eq!(pos, (984, 680));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod config;
|
||||
pub mod debug_log;
|
||||
pub mod integrations;
|
||||
pub mod launcher;
|
||||
pub mod preview;
|
||||
pub mod profiles;
|
||||
pub mod system;
|
||||
@@ -0,0 +1,9 @@
|
||||
mod app;
|
||||
mod ui;
|
||||
mod window;
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
let app = app::MangoTuneApp::new();
|
||||
std::process::exit(app.run());
|
||||
}
|
||||
+1357
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
use crate::config::normalize::normalize_legacy_option_values;
|
||||
use crate::config::parser::Parser;
|
||||
use crate::config::types::AnnotatedConfig;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProfileInfo {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn profiles_dir() -> PathBuf {
|
||||
if let Ok(override_dir) = std::env::var("MANGOTUNE_PROFILES_DIR") {
|
||||
let path = PathBuf::from(override_dir);
|
||||
if !path.as_os_str().is_empty() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
|
||||
return PathBuf::from(config_home).join("mangotune/profiles");
|
||||
}
|
||||
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
|
||||
PathBuf::from(home).join(".config/mangotune/profiles")
|
||||
}
|
||||
|
||||
pub fn list_profiles() -> Result<Vec<ProfileInfo>> {
|
||||
let dir = profiles_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut profiles = Vec::new();
|
||||
for entry in fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("conf") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
profiles.push(ProfileInfo {
|
||||
name: stem.to_string(),
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
profiles.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
pub fn save_profile(name: &str, config: &AnnotatedConfig) -> Result<PathBuf> {
|
||||
let safe_name = sanitize_name(name)?;
|
||||
let dir = profiles_dir();
|
||||
fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
|
||||
|
||||
let profile_path = dir.join(format!("{safe_name}.conf"));
|
||||
fs::write(&profile_path, Parser::to_string(config))
|
||||
.with_context(|| format!("failed to write {}", profile_path.display()))?;
|
||||
Ok(profile_path)
|
||||
}
|
||||
|
||||
pub fn profile_exists(name: &str) -> Result<bool> {
|
||||
let safe_name = sanitize_name(name)?;
|
||||
let path = profiles_dir().join(format!("{safe_name}.conf"));
|
||||
Ok(path.exists())
|
||||
}
|
||||
|
||||
pub fn load_profile(name: &str, target_path: Option<PathBuf>) -> Result<AnnotatedConfig> {
|
||||
let safe_name = sanitize_name(name)?;
|
||||
let path = profiles_dir().join(format!("{safe_name}.conf"));
|
||||
load_profile_from_path(&path, target_path)
|
||||
}
|
||||
|
||||
pub fn load_profile_from_path(
|
||||
path: &Path,
|
||||
target_path: Option<PathBuf>,
|
||||
) -> Result<AnnotatedConfig> {
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read profile {}", path.display()))?;
|
||||
let mut parsed = Parser::parse_str(&content, target_path);
|
||||
normalize_legacy_option_values(&mut parsed);
|
||||
parsed.dirty = true;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn delete_profile(name: &str) -> Result<()> {
|
||||
let safe_name = sanitize_name(name)?;
|
||||
let path = profiles_dir().join(format!("{safe_name}.conf"));
|
||||
delete_profile_path(&path)
|
||||
}
|
||||
|
||||
pub fn delete_profile_path(path: &Path) -> Result<()> {
|
||||
if !path.exists() {
|
||||
return Err(anyhow!("profile does not exist"));
|
||||
}
|
||||
fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> Result<String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("profile name cannot be empty"));
|
||||
}
|
||||
|
||||
let safe: String = trimmed
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if safe.trim_matches('_').is_empty() {
|
||||
return Err(anyhow!("profile name must contain letters or numbers"));
|
||||
}
|
||||
|
||||
Ok(safe)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::{ConfigLine, ConfigValue};
|
||||
use indexmap::IndexMap;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::tempdir;
|
||||
|
||||
static PROFILE_TEST_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
|
||||
fn sample_config(path: Option<PathBuf>) -> AnnotatedConfig {
|
||||
let mut options = IndexMap::new();
|
||||
options.insert("fps".to_string(), (0, ConfigValue::Value("60".to_string())));
|
||||
|
||||
AnnotatedConfig {
|
||||
lines: vec![ConfigLine::Option {
|
||||
key: "fps".to_string(),
|
||||
value: Some("60".to_string()),
|
||||
raw: "fps=60".to_string(),
|
||||
}],
|
||||
options,
|
||||
path,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_list_and_load_profile_round_trip() {
|
||||
let _guard = PROFILE_TEST_LOCK.lock().expect("profile test lock");
|
||||
let temp = tempdir().expect("tempdir");
|
||||
std::env::set_var("MANGOTUNE_PROFILES_DIR", temp.path());
|
||||
|
||||
let cfg = sample_config(Some(PathBuf::from("/tmp/MangoHud.conf")));
|
||||
let saved = save_profile("my profile", &cfg).expect("save");
|
||||
assert!(saved.exists());
|
||||
|
||||
let listed = list_profiles().expect("list");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].name, "my_profile");
|
||||
|
||||
let loaded =
|
||||
load_profile("my profile", Some(PathBuf::from("/tmp/target.conf"))).expect("load");
|
||||
assert!(loaded.options.contains_key("fps"));
|
||||
assert_eq!(loaded.path, Some(PathBuf::from("/tmp/target.conf")));
|
||||
|
||||
std::env::remove_var("MANGOTUNE_PROFILES_DIR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_profile_removes_saved_file() {
|
||||
let _guard = PROFILE_TEST_LOCK.lock().expect("profile test lock");
|
||||
let temp = tempdir().expect("tempdir");
|
||||
std::env::set_var("MANGOTUNE_PROFILES_DIR", temp.path());
|
||||
|
||||
let cfg = sample_config(Some(PathBuf::from("/tmp/MangoHud.conf")));
|
||||
let saved = save_profile("delete me", &cfg).expect("save");
|
||||
assert!(saved.exists());
|
||||
|
||||
delete_profile_path(&saved).expect("delete");
|
||||
assert!(!saved.exists());
|
||||
|
||||
std::env::remove_var("MANGOTUNE_PROFILES_DIR");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
use crate::config::types::GpuVendor;
|
||||
use anyhow::Result;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
static VERSION_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^(?:MangoHud\s+)?v?(\d+\.\d+[\.\d]*)").expect("valid version regex"));
|
||||
|
||||
#[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>,
|
||||
pub lib_path: Option<PathBuf>,
|
||||
pub flatpak: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GpuInfo {
|
||||
pub vendor: GpuVendor,
|
||||
pub name: Option<String>,
|
||||
pub pci_id: Option<String>,
|
||||
pub total_vram_mb: Option<u32>,
|
||||
pub used_vram_mb: Option<u32>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
impl SystemInfo {
|
||||
pub fn unknown() -> Self {
|
||||
Self {
|
||||
mangohud: MangoHudInfo {
|
||||
installed: false,
|
||||
version: None,
|
||||
lib_path: None,
|
||||
flatpak: false,
|
||||
},
|
||||
gpu: GpuInfo {
|
||||
vendor: GpuVendor::Any,
|
||||
name: None,
|
||||
pci_id: None,
|
||||
total_vram_mb: None,
|
||||
used_vram_mb: None,
|
||||
},
|
||||
display_server: DisplayServer::Unknown,
|
||||
tools: AvailableTools {
|
||||
vkcube: None,
|
||||
glxgears: None,
|
||||
gamemodectl: None,
|
||||
gamemoded: None,
|
||||
},
|
||||
integrations: IntegrationAvailability {
|
||||
steam: false,
|
||||
steam_flatpak: false,
|
||||
lutris: false,
|
||||
lutris_flatpak: false,
|
||||
heroic: false,
|
||||
heroic_flatpak: false,
|
||||
gamemode: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn detect_system() -> Result<SystemInfo> {
|
||||
let mangohud_bin = which("mangohud");
|
||||
let version = mangohud_bin
|
||||
.as_ref()
|
||||
.and_then(|_| mangohud_version().ok())
|
||||
.flatten();
|
||||
let lib_path = detect_mangohud_library_path();
|
||||
let flatpak_list = flatpak_list();
|
||||
let flatpak_mangohud = flatpak_list
|
||||
.as_deref()
|
||||
.map(|s| s.to_lowercase().contains("mangohud"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let tools = AvailableTools {
|
||||
vkcube: which("vkcube"),
|
||||
glxgears: which("glxgears"),
|
||||
gamemodectl: which("gamemodectl"),
|
||||
gamemoded: which("gamemoded"),
|
||||
};
|
||||
let display_server = detect_display_server();
|
||||
let gpu = detect_gpu().unwrap_or(GpuInfo {
|
||||
vendor: GpuVendor::Any,
|
||||
name: None,
|
||||
pci_id: None,
|
||||
total_vram_mb: None,
|
||||
used_vram_mb: None,
|
||||
});
|
||||
|
||||
let integrations = IntegrationAvailability {
|
||||
steam: which("steam").is_some(),
|
||||
steam_flatpak: flatpak_list
|
||||
.as_deref()
|
||||
.map(|s| s.contains("com.valvesoftware.Steam"))
|
||||
.unwrap_or(false),
|
||||
lutris: which("lutris").is_some(),
|
||||
lutris_flatpak: flatpak_list
|
||||
.as_deref()
|
||||
.map(|s| s.contains("net.lutris.Lutris"))
|
||||
.unwrap_or(false),
|
||||
heroic: which("heroic").is_some(),
|
||||
heroic_flatpak: flatpak_list
|
||||
.as_deref()
|
||||
.map(|s| s.contains("com.heroicgameslauncher.hgl"))
|
||||
.unwrap_or(false),
|
||||
gamemode: tools.gamemoded.is_some() || tools.gamemodectl.is_some(),
|
||||
};
|
||||
|
||||
Ok(SystemInfo {
|
||||
mangohud: MangoHudInfo {
|
||||
installed: mangohud_bin.is_some(),
|
||||
version,
|
||||
lib_path,
|
||||
flatpak: flatpak_mangohud,
|
||||
},
|
||||
gpu,
|
||||
display_server,
|
||||
tools,
|
||||
integrations,
|
||||
})
|
||||
}
|
||||
|
||||
fn which(tool: &str) -> Option<PathBuf> {
|
||||
let output = Command::new("which").arg(tool).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn mangohud_version() -> Result<Option<String>> {
|
||||
let output = Command::new("mangohud").arg("--version").output()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let text = if stdout.is_empty() { stderr } else { stdout };
|
||||
Ok(parse_version_from_output(&text))
|
||||
}
|
||||
|
||||
fn parse_version_from_output(output: &str) -> Option<String> {
|
||||
VERSION_RE
|
||||
.captures(output.trim())
|
||||
.and_then(|cap| cap.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
fn detect_mangohud_library_path() -> Option<PathBuf> {
|
||||
let home = env::var("HOME").unwrap_or_else(|_| "/".to_string());
|
||||
let candidates = [
|
||||
"/usr/lib/x86_64-linux-gnu/mangohud/libMangoHud.so",
|
||||
"/usr/lib/mangohud/libMangoHud.so",
|
||||
"/usr/local/lib/mangohud/libMangoHud.so",
|
||||
];
|
||||
|
||||
for path in candidates {
|
||||
if Path::new(path).exists() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
|
||||
let local = PathBuf::from(home).join(".local/lib/mangohud/libMangoHud.so");
|
||||
if local.exists() {
|
||||
return Some(local);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn flatpak_list() -> Option<String> {
|
||||
let output = Command::new("flatpak").arg("list").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
fn detect_display_server() -> DisplayServer {
|
||||
let wayland = env::var("WAYLAND_DISPLAY").ok().filter(|v| !v.is_empty());
|
||||
let x11 = env::var("DISPLAY").ok().filter(|v| !v.is_empty());
|
||||
|
||||
match (wayland.is_some(), x11.is_some()) {
|
||||
(true, true) => DisplayServer::XwaylandUnderWayland,
|
||||
(true, false) => DisplayServer::Wayland,
|
||||
(false, true) => DisplayServer::X11,
|
||||
(false, false) => match env::var("XDG_SESSION_TYPE").ok().as_deref() {
|
||||
Some("wayland") => DisplayServer::Wayland,
|
||||
Some("x11") => DisplayServer::X11,
|
||||
_ => DisplayServer::Unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_gpu() -> Option<GpuInfo> {
|
||||
let from_sys = detect_gpu_from_sysfs();
|
||||
if from_sys.is_some() {
|
||||
return from_sys;
|
||||
}
|
||||
detect_gpu_from_lspci()
|
||||
}
|
||||
|
||||
fn detect_gpu_from_sysfs() -> Option<GpuInfo> {
|
||||
let drm_path = Path::new("/sys/class/drm");
|
||||
let mut detected = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(drm_path).ok()? {
|
||||
let entry = entry.ok()?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with("card") || name.contains('-') {
|
||||
continue;
|
||||
}
|
||||
let base = entry.path().join("device");
|
||||
let vendor = fs::read_to_string(base.join("vendor")).ok()?;
|
||||
let device = fs::read_to_string(base.join("device")).ok();
|
||||
let vendor = vendor.trim().to_lowercase();
|
||||
let mapped = match vendor.as_str() {
|
||||
"0x1002" => GpuVendor::AmdOnly,
|
||||
"0x10de" => GpuVendor::NvidiaOnly,
|
||||
"0x8086" => GpuVendor::IntelOnly,
|
||||
_ => GpuVendor::Any,
|
||||
};
|
||||
let total_vram_mb = detect_vram_mb_from_sysfs(&base, mapped.clone())
|
||||
.or_else(|| detect_vram_mb_from_nvidia_smi_total(mapped.clone()));
|
||||
let used_vram_mb = detect_used_vram_mb_from_sysfs(&base, mapped.clone())
|
||||
.or_else(|| detect_vram_mb_from_nvidia_smi_used(mapped.clone()));
|
||||
detected.push(GpuInfo {
|
||||
vendor: mapped,
|
||||
name: None,
|
||||
pci_id: device.map(|d| d.trim().to_string()),
|
||||
total_vram_mb,
|
||||
used_vram_mb,
|
||||
});
|
||||
}
|
||||
|
||||
if detected.is_empty() {
|
||||
return None;
|
||||
}
|
||||
detected.sort_by_key(|gpu| match gpu.vendor {
|
||||
GpuVendor::NvidiaOnly => 0,
|
||||
GpuVendor::AmdOnly => 1,
|
||||
GpuVendor::IntelOnly => 2,
|
||||
GpuVendor::Any => 3,
|
||||
});
|
||||
detected.into_iter().next()
|
||||
}
|
||||
|
||||
fn detect_gpu_from_lspci() -> Option<GpuInfo> {
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("lspci -nn 2>/dev/null")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
if !(line.contains("VGA compatible controller")
|
||||
|| line.contains("3D controller")
|
||||
|| line.contains("Display controller"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let lower = line.to_lowercase();
|
||||
let vendor = if lower.contains("[10de:") {
|
||||
GpuVendor::NvidiaOnly
|
||||
} else if lower.contains("[1002:") {
|
||||
GpuVendor::AmdOnly
|
||||
} else if lower.contains("[8086:") {
|
||||
GpuVendor::IntelOnly
|
||||
} else {
|
||||
GpuVendor::Any
|
||||
};
|
||||
return Some(GpuInfo {
|
||||
vendor: vendor.clone(),
|
||||
name: Some(line.to_string()),
|
||||
pci_id: None,
|
||||
total_vram_mb: detect_vram_mb_from_nvidia_smi_total(vendor.clone()),
|
||||
used_vram_mb: detect_vram_mb_from_nvidia_smi_used(vendor),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_vram_mb_from_sysfs(base: &Path, vendor: GpuVendor) -> Option<u32> {
|
||||
match vendor {
|
||||
GpuVendor::AmdOnly => {
|
||||
let bytes = fs::read_to_string(base.join("mem_info_vram_total")).ok()?;
|
||||
let bytes = bytes.trim().parse::<u64>().ok()?;
|
||||
Some((bytes / (1024 * 1024)).clamp(0, u32::MAX as u64) as u32)
|
||||
}
|
||||
GpuVendor::IntelOnly => {
|
||||
let bytes = fs::read_to_string(base.join("lmem_total_bytes")).ok()?;
|
||||
let bytes = bytes.trim().parse::<u64>().ok()?;
|
||||
Some((bytes / (1024 * 1024)).clamp(0, u32::MAX as u64) as u32)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_used_vram_mb_from_sysfs(base: &Path, vendor: GpuVendor) -> Option<u32> {
|
||||
match vendor {
|
||||
GpuVendor::AmdOnly => {
|
||||
let bytes = fs::read_to_string(base.join("mem_info_vram_used")).ok()?;
|
||||
let bytes = bytes.trim().parse::<u64>().ok()?;
|
||||
Some((bytes / (1024 * 1024)).clamp(0, u32::MAX as u64) as u32)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_vram_mb_from_nvidia_smi_total(vendor: GpuVendor) -> Option<u32> {
|
||||
if vendor != GpuVendor::NvidiaOnly {
|
||||
return None;
|
||||
}
|
||||
let output = Command::new("nvidia-smi")
|
||||
.args(["--query-gpu=memory.total", "--format=csv,noheader,nounits"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.find_map(|line| line.trim().parse::<u32>().ok())
|
||||
}
|
||||
|
||||
fn detect_vram_mb_from_nvidia_smi_used(vendor: GpuVendor) -> Option<u32> {
|
||||
if vendor != GpuVendor::NvidiaOnly {
|
||||
return None;
|
||||
}
|
||||
let output = Command::new("nvidia-smi")
|
||||
.args(["--query-gpu=memory.used", "--format=csv,noheader,nounits"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.find_map(|line| line.trim().parse::<u32>().ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_mangohud_version_output() {
|
||||
assert_eq!(
|
||||
parse_version_from_output("MangoHud 0.7.2"),
|
||||
Some("0.7.2".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_version_from_output("v0.7.1-3-gabcdef"),
|
||||
Some("0.7.1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_constructor_has_safe_defaults() {
|
||||
let info = SystemInfo::unknown();
|
||||
assert!(!info.mangohud.installed);
|
||||
assert_eq!(info.display_server, DisplayServer::Unknown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod detect;
|
||||
pub mod paths;
|
||||
|
||||
pub use detect::{AvailableTools, DisplayServer, GpuInfo, MangoHudInfo, SystemInfo};
|
||||
pub use paths::XdgPaths;
|
||||
@@ -0,0 +1,78 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use xdg::BaseDirectories;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct XdgPaths {
|
||||
pub config_home: PathBuf,
|
||||
pub mangohud_dir: PathBuf,
|
||||
pub global_config: PathBuf,
|
||||
pub data_home: PathBuf,
|
||||
}
|
||||
|
||||
impl XdgPaths {
|
||||
pub fn resolve() -> Result<Self> {
|
||||
let xdg = BaseDirectories::new().context("failed to resolve XDG base directories")?;
|
||||
let config_home = if let Ok(custom) = env::var("XDG_CONFIG_HOME") {
|
||||
if !custom.trim().is_empty() {
|
||||
expand_tilde(&custom)
|
||||
} else {
|
||||
xdg.get_config_home()
|
||||
}
|
||||
} else {
|
||||
xdg.get_config_home()
|
||||
};
|
||||
let data_home = xdg.get_data_home();
|
||||
let mangohud_dir = config_home.join("MangoHud");
|
||||
let global_config = mangohud_dir.join("MangoHud.conf");
|
||||
|
||||
Ok(Self {
|
||||
config_home,
|
||||
mangohud_dir,
|
||||
global_config,
|
||||
data_home,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn steam_roots() -> Vec<PathBuf> {
|
||||
let home = home_dir();
|
||||
vec![
|
||||
home.join(".steam/steam"),
|
||||
home.join(".local/share/Steam"),
|
||||
home.join(".var/app/com.valvesoftware.Steam/data/Steam"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn heroic_config_dirs() -> Vec<PathBuf> {
|
||||
let home = home_dir();
|
||||
vec![
|
||||
home.join(".config/heroic"),
|
||||
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn lutris_config_dirs() -> Vec<PathBuf> {
|
||||
let home = home_dir();
|
||||
vec![
|
||||
home.join(".config/lutris"),
|
||||
home.join(".var/app/net.lutris.Lutris/config/lutris"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
return home_dir();
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
return home_dir().join(rest);
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/"))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod pages;
|
||||
pub mod toast;
|
||||
pub mod widgets;
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use mangotune::config::types::Category;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
tool_page::build_single_category_page(
|
||||
ctx,
|
||||
"Layout and Position",
|
||||
"Appearance",
|
||||
"Shape where MangoHud sits on screen, how wide it grows, and how tightly it hugs the game viewport.",
|
||||
&["anchor", "spacing", "footprint"],
|
||||
"Placement and spacing",
|
||||
"Fine-tune anchor, margins, offsets, and compactness without digging through raw MangoHud keys.",
|
||||
Some("Most used"),
|
||||
Category::AppearanceLayout,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use mangotune::config::types::Category;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
tool_page::build_single_category_page(
|
||||
ctx,
|
||||
"Battery",
|
||||
"Display",
|
||||
"Add battery telemetry for laptops and wireless devices only when it adds signal instead of noise.",
|
||||
&["laptops", "controllers", "power"],
|
||||
"Battery indicators",
|
||||
"Choose the battery metrics that actually help during gaming sessions without cluttering the overlay.",
|
||||
Some("Optional"),
|
||||
Category::DisplayBattery,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"Blacklist",
|
||||
"Behavior",
|
||||
"Tell MangoHud where not to inject, and control the extra behavior flags that keep problematic apps from getting in the way.",
|
||||
&["exceptions", "socket", "safety"],
|
||||
);
|
||||
|
||||
tool_page::append_schema_key_section(
|
||||
&body,
|
||||
ctx,
|
||||
"Injection and config behavior",
|
||||
"These settings are mostly defensive. Change them when a launcher, desktop app, or special-case game needs different behavior.",
|
||||
Some("Advanced"),
|
||||
&["blacklist", "control", "read_cfg"],
|
||||
);
|
||||
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Use sparingly",
|
||||
"Special MangoHud directives",
|
||||
"These options exist upstream, but they are niche. `help` makes MangoHud print supported parameters and exit instead of drawing the HUD, and `inherit` is mainly meant for preset definitions.",
|
||||
Some("tool-callout-warning"),
|
||||
);
|
||||
|
||||
tool_page::append_schema_key_section(
|
||||
&body,
|
||||
ctx,
|
||||
"Special directives",
|
||||
"Available here for completeness, but usually not something you want in a normal gaming config.",
|
||||
Some("Edge cases"),
|
||||
&["help", "inherit"],
|
||||
);
|
||||
|
||||
page
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::{color_row, toggle_row, tool_page};
|
||||
use libadwaita::prelude::*;
|
||||
use mangotune::config::help::{display_title_for_key, option_help_for_key};
|
||||
use mangotune::config::schema::get_schema_entry;
|
||||
use mangotune::config::types::OptionType;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"Colors and Theme",
|
||||
"Appearance",
|
||||
"Build a palette that reads clearly over real game scenes instead of settling for MangoHud’s stock look.",
|
||||
&["palette", "contrast", "readability"],
|
||||
);
|
||||
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Input format",
|
||||
"Color fields use six-digit hex",
|
||||
"Enter colors as RRGGBB without a leading #. The swatches update live so you can tune against the preview without guessing.",
|
||||
None,
|
||||
);
|
||||
|
||||
append_color_key_section(
|
||||
&body,
|
||||
ctx,
|
||||
"Core palette",
|
||||
"Set the main panel, text, and outline colors that define the overall look of the HUD.",
|
||||
Some("Visual identity"),
|
||||
&[
|
||||
"text_color",
|
||||
"background_color",
|
||||
"horizontal_separator_color",
|
||||
"text_outline",
|
||||
"text_outline_color",
|
||||
"text_outline_thickness",
|
||||
],
|
||||
);
|
||||
|
||||
append_color_key_section(
|
||||
&body,
|
||||
ctx,
|
||||
"Hardware labels",
|
||||
"Choose the accent colors for the main hardware labels so GPU, CPU, RAM, and VRAM stay easy to scan.",
|
||||
Some("Hardware"),
|
||||
&["gpu_color", "cpu_color", "vram_color", "ram_color", "engine_color"],
|
||||
);
|
||||
|
||||
append_color_key_section(
|
||||
&body,
|
||||
ctx,
|
||||
"Live readout accents",
|
||||
"Adjust the colors used by FPS, GPU load, and CPU load readouts.",
|
||||
Some("Live metrics"),
|
||||
&["fps_color", "gpu_load_color", "cpu_load_color"],
|
||||
);
|
||||
|
||||
append_color_key_section(
|
||||
&body,
|
||||
ctx,
|
||||
"Status and integrations",
|
||||
"Tune the accent colors for extra HUD modules like I/O, network, media players, Wine, and battery status.",
|
||||
Some("Extras"),
|
||||
&[
|
||||
"io_color",
|
||||
"network_color",
|
||||
"media_player_color",
|
||||
"wine_color",
|
||||
"battery_color",
|
||||
"frametime_color",
|
||||
],
|
||||
);
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
fn append_color_key_section(
|
||||
body: >k4::Box,
|
||||
ctx: &PageBuildContext,
|
||||
title: &str,
|
||||
description: &str,
|
||||
badge: Option<&str>,
|
||||
keys: &[&str],
|
||||
) {
|
||||
let group = tool_page::append_custom_section(body, title, description, badge);
|
||||
for key in keys {
|
||||
if let Some(entry) = get_schema_entry(key) {
|
||||
if matches!(entry.option_type, OptionType::Color) {
|
||||
let row = color_row::build_color_row(
|
||||
&friendly_color_title(entry.key),
|
||||
&friendly_color_subtitle(entry.key),
|
||||
entry.key,
|
||||
ctx,
|
||||
);
|
||||
group.add(&row);
|
||||
} else {
|
||||
toggle_row::add_schema_row(&group, entry, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn friendly_color_title(key: &str) -> String {
|
||||
match key {
|
||||
"engine_color" => "Engine / Vulkan".to_string(),
|
||||
"gpu_color" => "GPU labels".to_string(),
|
||||
"cpu_color" => "CPU labels".to_string(),
|
||||
"vram_color" => "VRAM labels".to_string(),
|
||||
"ram_color" => "RAM labels".to_string(),
|
||||
"fps_color" => "FPS thresholds".to_string(),
|
||||
"gpu_load_color" => "GPU load thresholds".to_string(),
|
||||
"cpu_load_color" => "CPU load thresholds".to_string(),
|
||||
"frametime_color" => "Frametime line".to_string(),
|
||||
"horizontal_separator_color" => "Separator line".to_string(),
|
||||
_ => display_title_for_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn friendly_color_subtitle(key: &str) -> String {
|
||||
match key {
|
||||
"fps_color" => "Colors paired with the FPS threshold values.".to_string(),
|
||||
"gpu_load_color" => "Colors paired with the GPU load threshold values.".to_string(),
|
||||
"cpu_load_color" => "Colors paired with the CPU load threshold values.".to_string(),
|
||||
"text_color" => "Main text color used across the overlay.".to_string(),
|
||||
"background_color" => "Panel fill color behind the HUD text.".to_string(),
|
||||
"text_outline_color" => "Outline color used when text outlining is enabled.".to_string(),
|
||||
"horizontal_separator_color" => "Divider line color between grouped readouts.".to_string(),
|
||||
"gpu_color" => "Accent color for GPU labels and related readouts.".to_string(),
|
||||
"cpu_color" => "Accent color for CPU labels and related readouts.".to_string(),
|
||||
"vram_color" => "Accent color for VRAM labels and related readouts.".to_string(),
|
||||
"ram_color" => "Accent color for RAM labels and related readouts.".to_string(),
|
||||
"engine_color" => "Accent color for engine and API labels.".to_string(),
|
||||
"io_color" => "Accent color for I/O readouts.".to_string(),
|
||||
"network_color" => "Accent color for network readouts.".to_string(),
|
||||
"media_player_color" => "Accent color for media-player readouts.".to_string(),
|
||||
"wine_color" => "Accent color for Wine and Proton readouts.".to_string(),
|
||||
"battery_color" => "Accent color for battery readouts.".to_string(),
|
||||
"frametime_color" => "Color used by the frametime graph line.".to_string(),
|
||||
_ => option_help_for_key(key)
|
||||
.map(|help| help.summary)
|
||||
.filter(|summary| !summary.is_empty())
|
||||
.unwrap_or_else(|| "Adjust this overlay color.".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
use crate::ui::pages::{block_on_optional, PageBuildContext};
|
||||
use crate::ui::widgets::cascade_view::{
|
||||
build_cascade_view, has_visible_options, CascadeFilter, CascadeViewModel, LayerViewModel,
|
||||
OptionState, OptionViewModel,
|
||||
};
|
||||
use crate::ui::widgets::tool_page;
|
||||
use gtk4::prelude::*;
|
||||
use mangotune::config::resolver::{ConfigConflict, ConfigLayer, Resolver};
|
||||
use mangotune::config::types::ConfigValue;
|
||||
use mangotune::system::paths::XdgPaths;
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub fn build_page(_ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"Layer Conflicts",
|
||||
"Config",
|
||||
"See which config layer wins, which values are shadowed, and where your edits will actually land in MangoHud’s stack.",
|
||||
&["cascade", "winning values", "shadowed options"],
|
||||
);
|
||||
|
||||
let Some(xdg) = XdgPaths::resolve().ok() else {
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Unavailable",
|
||||
"Could not resolve XDG paths",
|
||||
"MangoTune could not determine your MangoHud config locations, so the layer view is unavailable right now.",
|
||||
Some("tool-callout-warning"),
|
||||
);
|
||||
return page;
|
||||
};
|
||||
|
||||
let Some(layers_result) = block_on_optional(Resolver::discover(&xdg)) else {
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Unavailable",
|
||||
"Could not inspect config layers",
|
||||
"The layer discovery task could not run in this session, so the cascade view could not be built.",
|
||||
Some("tool-callout-warning"),
|
||||
);
|
||||
return page;
|
||||
};
|
||||
|
||||
let Ok(mut layers) = layers_result else {
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Unavailable",
|
||||
"Could not read discovered config files",
|
||||
"MangoTune found config locations but could not parse the current layer stack safely.",
|
||||
Some("tool-callout-warning"),
|
||||
);
|
||||
return page;
|
||||
};
|
||||
|
||||
if layers.is_empty() {
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Empty stack",
|
||||
"No MangoHud config files were found",
|
||||
"MangoHud will currently fall back to compiled defaults. Save a config to start building a visible layer stack here.",
|
||||
None,
|
||||
);
|
||||
return page;
|
||||
}
|
||||
|
||||
let mut conflicts: HashMap<String, ConfigConflict> = HashMap::new();
|
||||
for item in Resolver::find_conflicts(&layers) {
|
||||
conflicts.insert(item.key.clone(), item);
|
||||
}
|
||||
layers.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
|
||||
let shadowed_count = layers
|
||||
.iter()
|
||||
.filter_map(|layer| layer.config.as_ref())
|
||||
.flat_map(|config| config.options.keys())
|
||||
.filter(|key| {
|
||||
conflicts
|
||||
.get(*key)
|
||||
.is_some_and(|conflict| conflict.winning_layer_priority > 0)
|
||||
})
|
||||
.count();
|
||||
|
||||
body.append(&build_summary_strip(
|
||||
layers.len(),
|
||||
conflicts.len(),
|
||||
shadowed_count,
|
||||
));
|
||||
|
||||
let (recipe_title, recipe_body) = build_conflict_recipe();
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"What this page means",
|
||||
&recipe_title,
|
||||
&recipe_body,
|
||||
None,
|
||||
);
|
||||
|
||||
if conflicts_rc_is_empty(&conflicts) {
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"No active layer conflicts",
|
||||
"Nothing is currently being overridden by a higher-priority layer.",
|
||||
"Try a simple repro: set `fps=60` in the saved global config and `fps=120` in a per-app config, then come back here. The per-app value should win and the global one should show as shadowed.",
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
body.append(&build_conflict_list_section(&layers, &conflicts));
|
||||
}
|
||||
|
||||
let filter_shell = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
filter_shell.add_css_class("tool-section-shell");
|
||||
|
||||
let filter_header = gtk4::Label::new(Some("Full layer cascade"));
|
||||
filter_header.add_css_class("tool-section-title");
|
||||
filter_header.set_xalign(0.0);
|
||||
filter_shell.append(&filter_header);
|
||||
|
||||
let filter_subtitle = gtk4::Label::new(Some(
|
||||
"Use this detailed view when you need to inspect every visible key in each layer. The simpler conflict list above is the quickest way to see what is actually being overridden.",
|
||||
));
|
||||
filter_subtitle.add_css_class("tool-section-subtitle");
|
||||
filter_subtitle.set_wrap(true);
|
||||
filter_subtitle.set_xalign(0.0);
|
||||
filter_shell.append(&filter_subtitle);
|
||||
|
||||
let filter_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
content.set_vexpand(true);
|
||||
|
||||
let layers_rc = Rc::new(layers);
|
||||
let conflicts_rc = Rc::new(conflicts);
|
||||
let updating_filter = Rc::new(Cell::new(false));
|
||||
|
||||
let all_button = gtk4::ToggleButton::with_label("All");
|
||||
let conflicts_button = gtk4::ToggleButton::with_label("Conflicts");
|
||||
let shadowed_button = gtk4::ToggleButton::with_label("Shadowed");
|
||||
all_button.set_active(true);
|
||||
for button in [&all_button, &conflicts_button, &shadowed_button] {
|
||||
button.add_css_class("dashboard-toggle");
|
||||
filter_row.append(button);
|
||||
}
|
||||
|
||||
let apply_filter = {
|
||||
let content = content.clone();
|
||||
let layers = layers_rc.clone();
|
||||
let conflicts = conflicts_rc.clone();
|
||||
let all_button = all_button.clone();
|
||||
let conflicts_button = conflicts_button.clone();
|
||||
let shadowed_button = shadowed_button.clone();
|
||||
let updating_filter = updating_filter.clone();
|
||||
Rc::new(move |filter: CascadeFilter| {
|
||||
if updating_filter.get() {
|
||||
return;
|
||||
}
|
||||
updating_filter.set(true);
|
||||
all_button.set_active(matches!(filter, CascadeFilter::All));
|
||||
conflicts_button.set_active(matches!(filter, CascadeFilter::ConflictsOnly));
|
||||
shadowed_button.set_active(matches!(filter, CascadeFilter::ShadowedOnly));
|
||||
updating_filter.set(false);
|
||||
rebuild_cascade_content(&content, &layers, &conflicts, filter);
|
||||
})
|
||||
};
|
||||
|
||||
apply_filter(CascadeFilter::All);
|
||||
|
||||
{
|
||||
let apply_filter = apply_filter.clone();
|
||||
all_button.connect_clicked(move |_| {
|
||||
apply_filter(CascadeFilter::All);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let apply_filter = apply_filter.clone();
|
||||
conflicts_button.connect_clicked(move |_| {
|
||||
apply_filter(CascadeFilter::ConflictsOnly);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let apply_filter = apply_filter.clone();
|
||||
shadowed_button.connect_clicked(move |_| {
|
||||
apply_filter(CascadeFilter::ShadowedOnly);
|
||||
});
|
||||
}
|
||||
|
||||
filter_shell.append(&filter_row);
|
||||
filter_shell.append(&content);
|
||||
body.append(&filter_shell);
|
||||
page
|
||||
}
|
||||
|
||||
fn rebuild_cascade_content(
|
||||
content: >k4::Box,
|
||||
layers: &[ConfigLayer],
|
||||
conflicts: &HashMap<String, ConfigConflict>,
|
||||
filter: CascadeFilter,
|
||||
) {
|
||||
while let Some(child) = content.first_child() {
|
||||
content.remove(&child);
|
||||
}
|
||||
|
||||
let model = build_cascade_model(layers, conflicts, filter);
|
||||
if !has_visible_options(&model) {
|
||||
content.append(&build_empty_state(filter));
|
||||
return;
|
||||
}
|
||||
|
||||
let widget = build_cascade_view(model);
|
||||
content.append(&widget);
|
||||
}
|
||||
|
||||
fn conflicts_rc_is_empty(conflicts: &HashMap<String, ConfigConflict>) -> bool {
|
||||
conflicts.is_empty()
|
||||
}
|
||||
|
||||
fn build_summary_strip(
|
||||
layer_count: usize,
|
||||
conflict_count: usize,
|
||||
shadowed_count: usize,
|
||||
) -> gtk4::Box {
|
||||
let strip = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
strip.add_css_class("dashboard-row");
|
||||
|
||||
for (label, value) in [
|
||||
("Layers", layer_count.to_string()),
|
||||
("Conflicting keys", conflict_count.to_string()),
|
||||
("Shadowed values", shadowed_count.to_string()),
|
||||
] {
|
||||
let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
tile.add_css_class("dashboard-status-panel");
|
||||
tile.set_hexpand(true);
|
||||
|
||||
let title = gtk4::Label::new(Some(label));
|
||||
title.add_css_class("dashboard-field-label");
|
||||
title.set_xalign(0.0);
|
||||
|
||||
let number = gtk4::Label::new(Some(&value));
|
||||
number.add_css_class("dashboard-card-title");
|
||||
number.set_xalign(0.0);
|
||||
|
||||
tile.append(&title);
|
||||
tile.append(&number);
|
||||
strip.append(&tile);
|
||||
}
|
||||
|
||||
strip
|
||||
}
|
||||
|
||||
fn build_conflict_list_section(
|
||||
layers: &[ConfigLayer],
|
||||
conflicts: &HashMap<String, ConfigConflict>,
|
||||
) -> gtk4::Box {
|
||||
let shell = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
shell.add_css_class("tool-section-shell");
|
||||
|
||||
let title = gtk4::Label::new(Some("Conflicting keys"));
|
||||
title.add_css_class("tool-section-title");
|
||||
title.set_xalign(0.0);
|
||||
shell.append(&title);
|
||||
|
||||
let subtitle = gtk4::Label::new(Some(
|
||||
"Each row shows one key that is set differently in more than one active layer, which value currently wins, and which lower-priority values are being ignored.",
|
||||
));
|
||||
subtitle.add_css_class("tool-section-subtitle");
|
||||
subtitle.set_wrap(true);
|
||||
subtitle.set_xalign(0.0);
|
||||
shell.append(&subtitle);
|
||||
|
||||
let list = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||
let mut ordered = conflicts.values().collect::<Vec<_>>();
|
||||
ordered.sort_by(|a, b| a.key.cmp(&b.key));
|
||||
for conflict in ordered {
|
||||
list.append(&build_conflict_row(conflict, layers));
|
||||
}
|
||||
shell.append(&list);
|
||||
shell
|
||||
}
|
||||
|
||||
fn build_conflict_row(conflict: &ConfigConflict, layers: &[ConfigLayer]) -> gtk4::Box {
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
row.add_css_class("dashboard-status-panel");
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let key = gtk4::Label::new(Some(&conflict.key));
|
||||
key.add_css_class("dashboard-field-label");
|
||||
key.set_xalign(0.0);
|
||||
key.set_hexpand(true);
|
||||
|
||||
let winner_badge = gtk4::Label::new(Some("Winning value"));
|
||||
winner_badge.add_css_class("tool-page-chip");
|
||||
header.append(&key);
|
||||
header.append(&winner_badge);
|
||||
row.append(&header);
|
||||
|
||||
let winner = gtk4::Label::new(Some(&format!(
|
||||
"{} from {}",
|
||||
stringify_value(&conflict.winning_value),
|
||||
layer_label_for_priority(layers, conflict.winning_layer_priority)
|
||||
)));
|
||||
winner.add_css_class("tool-section-subtitle");
|
||||
winner.set_wrap(true);
|
||||
winner.set_xalign(0.0);
|
||||
row.append(&winner);
|
||||
|
||||
for (priority, value) in &conflict.shadowed {
|
||||
let shadowed = gtk4::Label::new(Some(&format!(
|
||||
"Shadowed: {} from {}",
|
||||
stringify_value(value),
|
||||
layer_label_for_priority(layers, *priority)
|
||||
)));
|
||||
shadowed.add_css_class("dim-label");
|
||||
shadowed.set_wrap(true);
|
||||
shadowed.set_xalign(0.0);
|
||||
row.append(&shadowed);
|
||||
}
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
fn layer_label_for_priority(layers: &[ConfigLayer], priority: u8) -> String {
|
||||
layers
|
||||
.iter()
|
||||
.find(|layer| layer.priority == priority)
|
||||
.map(|layer| Resolver::layer_label(&layer.source_type))
|
||||
.unwrap_or_else(|| format!("priority {priority}"))
|
||||
}
|
||||
|
||||
fn build_empty_state(filter: CascadeFilter) -> gtk4::Widget {
|
||||
let message = match filter {
|
||||
CascadeFilter::All => "No visible layer data was available for the current MangoHud stack.",
|
||||
CascadeFilter::ConflictsOnly => {
|
||||
"No layer conflicts are active right now. That usually means the same key is not being set differently in two active layers."
|
||||
}
|
||||
CascadeFilter::ShadowedOnly => "Nothing is currently shadowed by a higher-priority layer.",
|
||||
};
|
||||
|
||||
let subtitle = match filter {
|
||||
CascadeFilter::All => "Save or load a MangoHud config to populate the cascade view.",
|
||||
CascadeFilter::ConflictsOnly => {
|
||||
"Try the All filter to inspect the full stack, or create a test conflict like fps=60 in the saved global config and fps=120 in a per-app config."
|
||||
}
|
||||
CascadeFilter::ShadowedOnly => {
|
||||
"Try the Conflicts or All filters if you want to inspect effective values too."
|
||||
}
|
||||
};
|
||||
|
||||
let box_ = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
box_.add_css_class("tool-callout");
|
||||
|
||||
let title = gtk4::Label::new(Some(message));
|
||||
title.add_css_class("tool-callout-title");
|
||||
title.set_wrap(true);
|
||||
title.set_xalign(0.0);
|
||||
|
||||
let body = gtk4::Label::new(Some(subtitle));
|
||||
body.add_css_class("tool-callout-subtitle");
|
||||
body.set_wrap(true);
|
||||
body.set_xalign(0.0);
|
||||
|
||||
box_.append(&title);
|
||||
box_.append(&body);
|
||||
box_.upcast()
|
||||
}
|
||||
|
||||
fn build_conflict_recipe() -> (String, String) {
|
||||
(
|
||||
"Layer conflicts only appear when two active config layers set the same key differently."
|
||||
.to_string(),
|
||||
"Quick check: set `fps=60` in the saved global config, set `fps=120` in a higher-priority per-app or app-local config, then reopen this page. The higher-priority layer should win and the lower one should show the value as shadowed."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_cascade_model(
|
||||
layers: &[ConfigLayer],
|
||||
conflicts: &HashMap<String, ConfigConflict>,
|
||||
filter: CascadeFilter,
|
||||
) -> CascadeViewModel {
|
||||
let mut layer_models = Vec::new();
|
||||
|
||||
for layer in layers {
|
||||
let mut options = Vec::new();
|
||||
if let Some(config) = &layer.config {
|
||||
for (key, (_, value)) in &config.options {
|
||||
let (state, overridden_by) = if let Some(conflict) = conflicts.get(key) {
|
||||
if layer.priority < conflict.winning_layer_priority {
|
||||
(
|
||||
OptionState::Shadowed,
|
||||
Some(layer_label_for_priority(
|
||||
layers,
|
||||
conflict.winning_layer_priority,
|
||||
)),
|
||||
)
|
||||
} else if layer.priority == conflict.winning_layer_priority {
|
||||
(OptionState::Winning, None)
|
||||
} else {
|
||||
(OptionState::Effective, None)
|
||||
}
|
||||
} else {
|
||||
(OptionState::Effective, None)
|
||||
};
|
||||
|
||||
options.push(OptionViewModel {
|
||||
key: key.clone(),
|
||||
value: stringify_value(value).to_string(),
|
||||
state,
|
||||
overridden_by,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
layer_models.push(LayerViewModel {
|
||||
source: layer.source_type.clone(),
|
||||
label: Resolver::layer_label(&layer.source_type),
|
||||
is_editable: layer.is_editable,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
CascadeViewModel {
|
||||
layers: layer_models,
|
||||
filter,
|
||||
}
|
||||
}
|
||||
|
||||
fn stringify_value(value: &ConfigValue) -> &str {
|
||||
match value {
|
||||
ConfigValue::Flag => "enabled",
|
||||
ConfigValue::Value(value) => value,
|
||||
ConfigValue::Disabled => "disabled",
|
||||
ConfigValue::Absent => "absent",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use mangotune::config::types::Category;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"CPU",
|
||||
"Display",
|
||||
"Surface CPU load, clocks, temperatures, and per-core data without turning the overlay into a wall of numbers.",
|
||||
&["utilization", "thermals", "thread load"],
|
||||
);
|
||||
|
||||
tool_page::append_schema_category_section_filtered(
|
||||
&body,
|
||||
ctx,
|
||||
"CPU telemetry",
|
||||
"Choose the CPU metrics that help you diagnose stutter, heat, or bottlenecks during real play. CPU usage is part of the main stats block on MangoHud builds that expose it there. Visual styling lives in Colors and Theme.",
|
||||
Some("Diagnostics"),
|
||||
Category::DisplayCpu,
|
||||
|entry| entry.key != "cpu_load_color",
|
||||
);
|
||||
|
||||
page
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use crate::ui::pages::{current_config_snapshot, PageBuildContext};
|
||||
use crate::ui::toast::show_toast;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use gtk4::prelude::*;
|
||||
use libadwaita::prelude::*;
|
||||
use mangotune::config::parser::Parser;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"Debug",
|
||||
"Tools",
|
||||
"Inspect MangoTune’s current workspace state, copy the effective config, and review recent internal logs without using the terminal.",
|
||||
&["diagnostics", "clipboard", "support"],
|
||||
);
|
||||
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Support",
|
||||
"Use this page when something feels off",
|
||||
"Copy the current config or recent MangoTune log before filing an issue or sending screenshots. This page reflects the in-memory workspace, not just what is already saved on disk.",
|
||||
None,
|
||||
);
|
||||
|
||||
let snapshot_group = tool_page::append_custom_section(
|
||||
&body,
|
||||
"Current workspace config",
|
||||
"This is the exact config MangoTune is currently editing in memory.",
|
||||
Some("Effective state"),
|
||||
);
|
||||
|
||||
let snapshot_header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let snapshot_status = gtk4::Label::new(None);
|
||||
snapshot_status.add_css_class("dim-label");
|
||||
snapshot_status.set_xalign(0.0);
|
||||
snapshot_status.set_hexpand(true);
|
||||
let snapshot_refresh = gtk4::Button::with_label("Refresh");
|
||||
let snapshot_copy = gtk4::Button::with_label("Copy Config");
|
||||
snapshot_copy.add_css_class("suggested-action");
|
||||
snapshot_header.append(&snapshot_status);
|
||||
snapshot_header.append(&snapshot_refresh);
|
||||
snapshot_header.append(&snapshot_copy);
|
||||
|
||||
let snapshot_buffer = gtk4::TextBuffer::new(None::<>k4::TextTagTable>);
|
||||
let snapshot_view = gtk4::TextView::builder()
|
||||
.buffer(&snapshot_buffer)
|
||||
.editable(false)
|
||||
.cursor_visible(false)
|
||||
.monospace(true)
|
||||
.wrap_mode(gtk4::WrapMode::Char)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.top_margin(12)
|
||||
.bottom_margin(12)
|
||||
.left_margin(12)
|
||||
.right_margin(12)
|
||||
.build();
|
||||
let snapshot_scrolled = gtk4::ScrolledWindow::builder()
|
||||
.child(&snapshot_view)
|
||||
.min_content_height(240)
|
||||
.min_content_width(0)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
snapshot_scrolled.add_css_class("tool-section-shell");
|
||||
|
||||
let snapshot_box = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||
snapshot_box.append(&snapshot_header);
|
||||
snapshot_box.append(&snapshot_scrolled);
|
||||
snapshot_group.add(&snapshot_box);
|
||||
|
||||
let log_group = tool_page::append_custom_section(
|
||||
&body,
|
||||
"Recent MangoTune log",
|
||||
"This captures MangoTune’s internal debug trail in-app, so you do not need the terminal open to share useful diagnostics.",
|
||||
Some("Recent activity"),
|
||||
);
|
||||
|
||||
let log_header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let log_status = gtk4::Label::new(None);
|
||||
log_status.add_css_class("dim-label");
|
||||
log_status.set_xalign(0.0);
|
||||
log_status.set_hexpand(true);
|
||||
let log_refresh = gtk4::Button::with_label("Refresh");
|
||||
let log_clear = gtk4::Button::with_label("Clear");
|
||||
let log_copy = gtk4::Button::with_label("Copy Log");
|
||||
log_header.append(&log_status);
|
||||
log_header.append(&log_refresh);
|
||||
log_header.append(&log_clear);
|
||||
log_header.append(&log_copy);
|
||||
|
||||
let log_buffer = gtk4::TextBuffer::new(None::<>k4::TextTagTable>);
|
||||
let log_view = gtk4::TextView::builder()
|
||||
.buffer(&log_buffer)
|
||||
.editable(false)
|
||||
.cursor_visible(false)
|
||||
.monospace(true)
|
||||
.wrap_mode(gtk4::WrapMode::Char)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.top_margin(12)
|
||||
.bottom_margin(12)
|
||||
.left_margin(12)
|
||||
.right_margin(12)
|
||||
.build();
|
||||
let log_scrolled = gtk4::ScrolledWindow::builder()
|
||||
.child(&log_view)
|
||||
.min_content_height(240)
|
||||
.min_content_width(0)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
log_scrolled.add_css_class("tool-section-shell");
|
||||
|
||||
let log_box = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||
log_box.append(&log_header);
|
||||
log_box.append(&log_scrolled);
|
||||
log_group.add(&log_box);
|
||||
|
||||
refresh_snapshot_view(ctx, &snapshot_buffer, &snapshot_status);
|
||||
refresh_log_view(&log_buffer, &log_status);
|
||||
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
let snapshot_buffer = snapshot_buffer.clone();
|
||||
let snapshot_status = snapshot_status.clone();
|
||||
snapshot_refresh.connect_clicked(move |_| {
|
||||
refresh_snapshot_view(&ctx, &snapshot_buffer, &snapshot_status);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
snapshot_copy.connect_clicked(move |_| {
|
||||
let text = Parser::to_string(¤t_config_snapshot(&ctx));
|
||||
copy_to_clipboard(&ctx, &text, "Copied current workspace config");
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let log_buffer = log_buffer.clone();
|
||||
let log_status = log_status.clone();
|
||||
log_refresh.connect_clicked(move |_| {
|
||||
refresh_log_view(&log_buffer, &log_status);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
log_copy.connect_clicked(move |_| {
|
||||
let text = mangotune::debug_log::text();
|
||||
copy_to_clipboard(&ctx, &text, "Copied recent MangoTune log");
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
let log_buffer = log_buffer.clone();
|
||||
let log_status = log_status.clone();
|
||||
log_clear.connect_clicked(move |_| {
|
||||
mangotune::debug_log::clear();
|
||||
refresh_log_view(&log_buffer, &log_status);
|
||||
show_toast(&ctx.toast_overlay, "Cleared recent MangoTune log");
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
let snapshot_buffer = snapshot_buffer.downgrade();
|
||||
let snapshot_status = snapshot_status.downgrade();
|
||||
let log_buffer = log_buffer.downgrade();
|
||||
let log_status = log_status.downgrade();
|
||||
glib::timeout_add_seconds_local(1, move || {
|
||||
let (Some(snapshot_buffer), Some(snapshot_status), Some(log_buffer), Some(log_status)) = (
|
||||
snapshot_buffer.upgrade(),
|
||||
snapshot_status.upgrade(),
|
||||
log_buffer.upgrade(),
|
||||
log_status.upgrade(),
|
||||
) else {
|
||||
return glib::ControlFlow::Break;
|
||||
};
|
||||
refresh_snapshot_view(&ctx, &snapshot_buffer, &snapshot_status);
|
||||
refresh_log_view(&log_buffer, &log_status);
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
fn refresh_snapshot_view(ctx: &PageBuildContext, buffer: >k4::TextBuffer, status: >k4::Label) {
|
||||
let config = current_config_snapshot(ctx);
|
||||
let text = Parser::to_string(&config);
|
||||
let line_count = text.lines().count();
|
||||
let option_count = config.options.len();
|
||||
buffer.set_text(&text);
|
||||
status.set_text(&format!(
|
||||
"{} active options across {} lines | {}",
|
||||
option_count,
|
||||
line_count,
|
||||
config
|
||||
.path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "unsaved workspace".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
fn refresh_log_view(buffer: >k4::TextBuffer, status: >k4::Label) {
|
||||
let lines = mangotune::debug_log::lines();
|
||||
let text = if lines.is_empty() {
|
||||
"No recent MangoTune log lines yet.".to_string()
|
||||
} else {
|
||||
lines.join("\n")
|
||||
};
|
||||
buffer.set_text(&text);
|
||||
status.set_text(&format!("{} recent line(s)", lines.len()));
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(ctx: &PageBuildContext, text: &str, success_message: &str) {
|
||||
if let Some(display) = gtk4::gdk::Display::default() {
|
||||
display.clipboard().set_text(text);
|
||||
show_toast(&ctx.toast_overlay, success_message);
|
||||
} else {
|
||||
show_toast(&ctx.toast_overlay, "Clipboard unavailable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"FPS Limits",
|
||||
"Behavior",
|
||||
"Control MangoHud-side frame caps and sync hooks when you want the overlay to participate in how a game is paced.",
|
||||
&["caps", "vsync", "frame pacing"],
|
||||
);
|
||||
|
||||
tool_page::append_schema_key_section(
|
||||
&body,
|
||||
ctx,
|
||||
"Frame caps and sync",
|
||||
"These settings affect how MangoHud applies FPS limits and sync behavior for Vulkan and OpenGL titles.",
|
||||
Some("Advanced"),
|
||||
&["fps_limit", "fps_limit_method", "vsync", "gl_vsync"],
|
||||
);
|
||||
|
||||
page
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use mangotune::config::types::{Category, GpuVendor};
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"GPU",
|
||||
"Display",
|
||||
"Surface the GPU metrics you actually care about when tuning thermals, clocks, load, and frame stability.",
|
||||
&["utilization", "thermals", "vendor aware"],
|
||||
);
|
||||
|
||||
if ctx.system_info.gpu.vendor != GpuVendor::AmdOnly {
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Vendor note",
|
||||
"Some GPU metrics are AMD-only",
|
||||
"If you are not on AMD hardware, options like gpu_voltage may stay inert even though MangoHud exposes them.",
|
||||
Some("tool-callout-warning"),
|
||||
);
|
||||
}
|
||||
|
||||
tool_page::append_schema_category_section_filtered(
|
||||
&body,
|
||||
ctx,
|
||||
"GPU telemetry",
|
||||
"Choose the clocks, temperatures, memory, and GPU stats readout that matter for your setup. GPU usage is part of the main stats block on MangoHud builds that expose it there. Visual styling lives in Colors and Theme.",
|
||||
Some("Core signals"),
|
||||
Category::DisplayGpu,
|
||||
|entry| entry.key != "gpu_load_color",
|
||||
);
|
||||
page
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
use crate::ui::pages::{refresh_live_preview_for_key, PageBuildContext};
|
||||
use crate::ui::toast::show_toast;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use crate::window::{recompute_validation, refresh_save_button};
|
||||
use gtk4::prelude::*;
|
||||
use mangotune::config::parser::Parser;
|
||||
use mangotune::config::types::ConfigValue;
|
||||
use std::rc::Rc;
|
||||
|
||||
struct HudOrderGroupDef {
|
||||
id: &'static str,
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
members: &'static [&'static str],
|
||||
}
|
||||
|
||||
const HUD_ORDER_GROUPS: &[HudOrderGroupDef] = &[
|
||||
HudOrderGroupDef {
|
||||
id: "fps",
|
||||
title: "FPS and pacing",
|
||||
description: "FPS, frametime, graphs, limits, and pacing readouts.",
|
||||
members: &[
|
||||
"fps_only",
|
||||
"fps",
|
||||
"frametime",
|
||||
"frame_count",
|
||||
"frame_timing",
|
||||
"frame_timing_detailed",
|
||||
"dynamic_frame_timing",
|
||||
"histogram",
|
||||
"show_fps_limit",
|
||||
"throttling_status",
|
||||
"throttling_status_graph",
|
||||
],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "gpu",
|
||||
title: "GPU",
|
||||
description: "GPU usage, temperatures, clocks, power, and labels.",
|
||||
members: &[
|
||||
"gpu_stats",
|
||||
"gpu_temp",
|
||||
"gpu_junction_temp",
|
||||
"gpu_core_clock",
|
||||
"gpu_mem_temp",
|
||||
"gpu_mem_clock",
|
||||
"gpu_power",
|
||||
"gpu_power_limit",
|
||||
"gpu_text",
|
||||
"gpu_fan",
|
||||
"gpu_voltage",
|
||||
"gpu_list",
|
||||
"gpu_efficiency",
|
||||
"gpu_name",
|
||||
"vulkan_driver",
|
||||
"engine_version",
|
||||
"engine_short_names",
|
||||
"hide_engine_names",
|
||||
"present_mode",
|
||||
"pci_dev",
|
||||
],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "vram",
|
||||
title: "VRAM",
|
||||
description: "VRAM usage and memory-related GPU readouts.",
|
||||
members: &["vram", "gpu_mem_clock", "gpu_mem_temp"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "cpu",
|
||||
title: "CPU",
|
||||
description: "CPU load, temperature, power, clock, and core details.",
|
||||
members: &[
|
||||
"cpu_stats",
|
||||
"cpu_temp",
|
||||
"cpu_power",
|
||||
"cpu_custom_temp_sensor",
|
||||
"cpu_text",
|
||||
"cpu_mhz",
|
||||
"cpu_efficiency",
|
||||
"core_load",
|
||||
"core_load_change",
|
||||
"core_bars",
|
||||
"core_type",
|
||||
],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "ram",
|
||||
title: "RAM",
|
||||
description: "System RAM usage and RAM temperature.",
|
||||
members: &["ram", "ram_temp"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "swap",
|
||||
title: "Swap usage",
|
||||
description: "Swap usage when memory pressure rises.",
|
||||
members: &["swap"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "process-memory",
|
||||
title: "Process memory",
|
||||
description: "Per-process resident, shared, and virtual memory.",
|
||||
members: &["procmem", "procmem_shared", "procmem_virt"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "process-vram",
|
||||
title: "Process VRAM",
|
||||
description: "Per-process VRAM usage for the current game.",
|
||||
members: &["proc_vram"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "io-read",
|
||||
title: "Disk read throughput",
|
||||
description: "Read activity from the running process.",
|
||||
members: &["io_read"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "io-write",
|
||||
title: "Disk write throughput",
|
||||
description: "Write activity from the running process.",
|
||||
members: &["io_write"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "network",
|
||||
title: "Network interfaces",
|
||||
description: "Upload and download activity for selected interfaces.",
|
||||
members: &["network"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "battery",
|
||||
title: "Battery status",
|
||||
description: "Battery level, optional icon, wattage, and remaining time.",
|
||||
members: &["battery", "battery_icon", "battery_watt", "battery_time"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "device-battery",
|
||||
title: "Device batteries",
|
||||
description: "Controller, mouse, headset, and other device battery info.",
|
||||
members: &["device_battery", "device_battery_icon"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "media-player",
|
||||
title: "Media player",
|
||||
description: "Now-playing text from the selected media player source.",
|
||||
members: &["media_player", "media_player_name", "media_player_format"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "gamescope-fsr",
|
||||
title: "Gamescope FSR",
|
||||
description: "FSR status and sharpness information under Gamescope.",
|
||||
members: &["fsr", "hide_fsr_sharpness", "fsr_steam_sharpness"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "gamescope-hdr",
|
||||
title: "HDR status",
|
||||
description: "HDR state reported by Gamescope.",
|
||||
members: &["hdr"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "gamescope-refresh",
|
||||
title: "Refresh rate",
|
||||
description: "Display refresh rate when Gamescope exposes it.",
|
||||
members: &["refresh_rate"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "gamescope-debug",
|
||||
title: "Gamescope debug",
|
||||
description: "Gamescope debug and frametime details.",
|
||||
members: &["debug"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "steam-deck-fan",
|
||||
title: "Fan speed",
|
||||
description: "Steam Deck fan speed readout.",
|
||||
members: &["fan"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "steam-deck-app",
|
||||
title: "MangoApp Steam mode",
|
||||
description: "Steam Deck specific MangoApp integration state.",
|
||||
members: &["mangoapp_steam"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "wine",
|
||||
title: "Wine version",
|
||||
description: "Wine version information for the running game.",
|
||||
members: &["wine"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "winesync",
|
||||
title: "Wine sync",
|
||||
description: "Wine synchronization mode details.",
|
||||
members: &["winesync"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "exec-name",
|
||||
title: "Executable name",
|
||||
description: "The current game or process executable name.",
|
||||
members: &["exec_name"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "arch",
|
||||
title: "System architecture",
|
||||
description: "CPU architecture and host platform details.",
|
||||
members: &["arch"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "gamemode",
|
||||
title: "GameMode",
|
||||
description: "Whether GameMode is active.",
|
||||
members: &["gamemode"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "vkbasalt",
|
||||
title: "vkBasalt status",
|
||||
description: "Whether vkBasalt is active.",
|
||||
members: &["vkbasalt"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "version",
|
||||
title: "MangoHud version",
|
||||
description: "MangoHud version information.",
|
||||
members: &["version"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "resolution",
|
||||
title: "Resolution",
|
||||
description: "Current output resolution.",
|
||||
members: &["resolution"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "display-server",
|
||||
title: "Display server",
|
||||
description: "Display server and session information.",
|
||||
members: &["display_server"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "dx-api",
|
||||
title: "DirectX API",
|
||||
description: "Detected DirectX API in use.",
|
||||
members: &["dx_api"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "flip-efficiency",
|
||||
title: "Flip efficiency",
|
||||
description: "Joules-per-frame efficiency information.",
|
||||
members: &["flip_efficiency"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "fex-stats",
|
||||
title: "FEX stats",
|
||||
description: "FEX emulator statistics.",
|
||||
members: &["fex_stats"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "graphs",
|
||||
title: "Graphs",
|
||||
description: "Graph strips for selected performance metrics.",
|
||||
members: &["graphs"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "time",
|
||||
title: "Current time",
|
||||
description: "Clock output with optional time formatting changes.",
|
||||
members: &["time", "time_no_label", "time_format"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "custom-text-center",
|
||||
title: "Centered custom text",
|
||||
description: "Centered text line inserted into the HUD.",
|
||||
members: &["custom_text_center"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "custom-text",
|
||||
title: "Custom text",
|
||||
description: "Custom text line inserted into the HUD.",
|
||||
members: &["custom_text"],
|
||||
},
|
||||
HudOrderGroupDef {
|
||||
id: "exec",
|
||||
title: "Command output",
|
||||
description: "Shell command output rendered as a HUD line.",
|
||||
members: &["exec"],
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HudOrderItem {
|
||||
id: &'static str,
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
active_keys: Vec<String>,
|
||||
primary_key: String,
|
||||
first_line_idx: usize,
|
||||
}
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"HUD Order",
|
||||
"Tools",
|
||||
"Reorder the visible HUD groups MangoHud renders in sequence. This changes the real option line order in your current config target.",
|
||||
&["sequence", "render order", "live preview"],
|
||||
);
|
||||
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"How it works",
|
||||
"Move whole visible HUD groups, not every child setting",
|
||||
"GPU, CPU, FPS, memory, battery, media, and text blocks often combine multiple config keys into one visible unit. This page moves those units together so the preview matches what you see.",
|
||||
None,
|
||||
);
|
||||
|
||||
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
section.add_css_class("tool-section-shell");
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
let title = gtk4::Label::new(Some("Current visible HUD order"));
|
||||
title.add_css_class("tool-section-title");
|
||||
title.set_xalign(0.0);
|
||||
let subtitle = gtk4::Label::new(Some(
|
||||
"Drag whole HUD groups above or below each other. MangoTune applies the same order to the live preview when it is running.",
|
||||
));
|
||||
subtitle.add_css_class("tool-section-subtitle");
|
||||
subtitle.set_wrap(true);
|
||||
subtitle.set_xalign(0.0);
|
||||
header.append(&title);
|
||||
header.append(&subtitle);
|
||||
section.append(&header);
|
||||
|
||||
let list = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
list.add_css_class("hud-order-list");
|
||||
section.append(&list);
|
||||
body.append(§ion);
|
||||
|
||||
let list_rc = Rc::new(list);
|
||||
rebuild_order_list(&list_rc, ctx);
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
fn rebuild_order_list(list: &Rc<gtk4::Box>, ctx: &PageBuildContext) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
|
||||
let items = ordered_hud_items(ctx);
|
||||
if items.is_empty() {
|
||||
let empty = gtk4::Label::new(Some(
|
||||
"No enabled HUD groups are available to reorder yet. Turn on some metrics first, then come back here.",
|
||||
));
|
||||
empty.add_css_class("dim-label");
|
||||
empty.set_wrap(true);
|
||||
empty.set_xalign(0.0);
|
||||
list.append(&empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
list.append(&build_order_row(ctx, list.clone(), item, index));
|
||||
}
|
||||
}
|
||||
|
||||
fn build_order_row(
|
||||
ctx: &PageBuildContext,
|
||||
list: Rc<gtk4::Box>,
|
||||
item: &HudOrderItem,
|
||||
index: usize,
|
||||
) -> gtk4::Box {
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
row.add_css_class("hud-order-row");
|
||||
row.set_hexpand(true);
|
||||
row.set_widget_name(item.id);
|
||||
|
||||
let ordinal = gtk4::Label::new(Some(&(index + 1).to_string()));
|
||||
ordinal.add_css_class("tool-chip");
|
||||
ordinal.add_css_class("hud-order-index");
|
||||
ordinal.set_valign(gtk4::Align::Center);
|
||||
row.append(&ordinal);
|
||||
|
||||
let copy = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
copy.set_hexpand(true);
|
||||
|
||||
let title = gtk4::Label::new(Some(item.title));
|
||||
title.add_css_class("dashboard-field-label");
|
||||
title.set_xalign(0.0);
|
||||
|
||||
let subtitle = gtk4::Label::new(Some(item.description));
|
||||
subtitle.add_css_class("dim-label");
|
||||
subtitle.set_xalign(0.0);
|
||||
|
||||
copy.append(&title);
|
||||
copy.append(&subtitle);
|
||||
row.append(©);
|
||||
|
||||
let key_label = gtk4::Label::new(Some(item.id));
|
||||
key_label.add_css_class("hud-order-key");
|
||||
key_label.set_valign(gtk4::Align::Center);
|
||||
row.append(&key_label);
|
||||
|
||||
let drag_hint = gtk4::Label::new(Some("::"));
|
||||
drag_hint.add_css_class("hud-order-handle");
|
||||
drag_hint.set_tooltip_text(Some("Drag to reorder"));
|
||||
drag_hint.set_valign(gtk4::Align::Center);
|
||||
row.append(&drag_hint);
|
||||
|
||||
let drag_source = gtk4::DragSource::builder()
|
||||
.actions(gtk4::gdk::DragAction::MOVE)
|
||||
.build();
|
||||
{
|
||||
let row = row.clone();
|
||||
let item_id = item.id.to_string();
|
||||
drag_source.connect_prepare(move |_, _, _| {
|
||||
row.add_css_class("hud-order-row-dragging");
|
||||
Some(gtk4::gdk::ContentProvider::for_value(&item_id.to_value()))
|
||||
});
|
||||
}
|
||||
{
|
||||
let row = row.clone();
|
||||
drag_source.connect_drag_end(move |_, _, _| {
|
||||
row.remove_css_class("hud-order-row-dragging");
|
||||
});
|
||||
}
|
||||
row.add_controller(drag_source);
|
||||
|
||||
let drop_target = gtk4::DropTarget::new(String::static_type(), gtk4::gdk::DragAction::MOVE);
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
let list = list.clone();
|
||||
let row = row.clone();
|
||||
drop_target.connect_motion(move |_, _, y| {
|
||||
apply_drop_marker(&ctx, &list, &row, y);
|
||||
gtk4::gdk::DragAction::MOVE
|
||||
});
|
||||
}
|
||||
{
|
||||
let list = list.clone();
|
||||
let row = row.clone();
|
||||
drop_target.connect_leave(move |_| {
|
||||
clear_drop_markers(&list);
|
||||
row.remove_css_class("hud-order-row-drop-before");
|
||||
row.remove_css_class("hud-order-row-drop-after");
|
||||
});
|
||||
}
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
let list = list.clone();
|
||||
let row = row.clone();
|
||||
let target_id = item.id.to_string();
|
||||
drop_target.connect_drop(move |_, value, _x, y| {
|
||||
clear_drop_markers(&list);
|
||||
row.remove_css_class("hud-order-row-drop-before");
|
||||
row.remove_css_class("hud-order-row-drop-after");
|
||||
let Ok(dragged_id) = value.get::<String>() else {
|
||||
return false;
|
||||
};
|
||||
let (effective_target_id, before) =
|
||||
effective_drop_target(&ctx, &target_id, y < (row.height() as f64 / 2.0));
|
||||
move_item(&ctx, &list, &dragged_id, &effective_target_id, before);
|
||||
true
|
||||
});
|
||||
}
|
||||
row.add_controller(drop_target);
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
fn move_item(
|
||||
ctx: &PageBuildContext,
|
||||
list: &Rc<gtk4::Box>,
|
||||
item_id: &str,
|
||||
target_id: &str,
|
||||
before: bool,
|
||||
) {
|
||||
let items = ordered_hud_items(ctx);
|
||||
let Some(current_idx) = items.iter().position(|item| item.id == item_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(target_idx) = items.iter().position(|item| item.id == target_id) else {
|
||||
return;
|
||||
};
|
||||
if current_idx == target_idx {
|
||||
return;
|
||||
}
|
||||
|
||||
let moving = &items[current_idx];
|
||||
let anchor = &items[target_idx];
|
||||
let mut disabled_legacy_layout = false;
|
||||
let moved = if let Ok(mut state) = ctx.state.lock() {
|
||||
if before {
|
||||
let moved = Parser::move_option_group_before(
|
||||
&mut state.config,
|
||||
&moving.active_keys,
|
||||
&anchor.active_keys,
|
||||
);
|
||||
if moved && is_flag_effectively_enabled(state.config.options.get("legacy_layout")) {
|
||||
Parser::set_value(&mut state.config, "legacy_layout", ConfigValue::Disabled);
|
||||
disabled_legacy_layout = true;
|
||||
}
|
||||
moved
|
||||
} else {
|
||||
let moved = Parser::move_option_group_after(
|
||||
&mut state.config,
|
||||
&moving.active_keys,
|
||||
&anchor.active_keys,
|
||||
);
|
||||
if moved && is_flag_effectively_enabled(state.config.options.get("legacy_layout")) {
|
||||
Parser::set_value(&mut state.config, "legacy_layout", ConfigValue::Disabled);
|
||||
disabled_legacy_layout = true;
|
||||
}
|
||||
moved
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !moved {
|
||||
return;
|
||||
}
|
||||
|
||||
recompute_validation(&ctx.state);
|
||||
refresh_save_button(&ctx.state, &ctx.save_button);
|
||||
if ctx.preview.running_scene().is_some() {
|
||||
let config = crate::ui::pages::current_config_snapshot(ctx);
|
||||
let _ = ctx
|
||||
.preview
|
||||
.apply_live_config(&config)
|
||||
.or_else(|_| ctx.preview.restart(&config));
|
||||
} else {
|
||||
refresh_live_preview_for_key(ctx, Some(&moving.primary_key));
|
||||
}
|
||||
rebuild_order_list(list, ctx);
|
||||
show_toast(
|
||||
&ctx.toast_overlay,
|
||||
&if disabled_legacy_layout {
|
||||
format!(
|
||||
"Moved {} {} {} and turned off legacy layout",
|
||||
moving.title,
|
||||
if before { "before" } else { "after" },
|
||||
anchor.title
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Moved {} {} {}",
|
||||
moving.title,
|
||||
if before { "before" } else { "after" },
|
||||
anchor.title
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn ordered_hud_items(ctx: &PageBuildContext) -> Vec<HudOrderItem> {
|
||||
let Ok(state) = ctx.state.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut items = HUD_ORDER_GROUPS
|
||||
.iter()
|
||||
.filter_map(|group| {
|
||||
let mut active_members = group
|
||||
.members
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
let (line_idx, value) = state.config.options.get(*key)?;
|
||||
is_active_display_value(value).then_some((*line_idx, (*key).to_string()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if active_members.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
active_members.sort_by_key(|(line_idx, _)| *line_idx);
|
||||
let first_line_idx = active_members.first().map(|(line_idx, _)| *line_idx)?;
|
||||
let primary_key = active_members
|
||||
.first()
|
||||
.map(|(_, key)| key.clone())
|
||||
.unwrap_or_else(|| group.members[0].to_string());
|
||||
let active_keys = active_members
|
||||
.into_iter()
|
||||
.map(|(_, key)| key)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(HudOrderItem {
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
description: group.description,
|
||||
active_keys,
|
||||
primary_key,
|
||||
first_line_idx,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
items.sort_by_key(|item| item.first_line_idx);
|
||||
items
|
||||
}
|
||||
|
||||
fn clear_drop_markers(list: >k4::Box) {
|
||||
let mut child = list.first_child();
|
||||
while let Some(widget) = child {
|
||||
widget.remove_css_class("hud-order-row-drop-before");
|
||||
widget.remove_css_class("hud-order-row-drop-after");
|
||||
child = widget.next_sibling();
|
||||
}
|
||||
}
|
||||
|
||||
fn row_at(list: >k4::Box, idx: usize) -> Option<gtk4::Widget> {
|
||||
let mut child = list.first_child();
|
||||
let mut current_idx = 0usize;
|
||||
while let Some(widget) = child {
|
||||
if current_idx == idx {
|
||||
return Some(widget);
|
||||
}
|
||||
current_idx += 1;
|
||||
child = widget.next_sibling();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn effective_drop_target(
|
||||
ctx: &PageBuildContext,
|
||||
target_id: &str,
|
||||
upper_half: bool,
|
||||
) -> (String, bool) {
|
||||
let items = ordered_hud_items(ctx);
|
||||
let Some(target_idx) = items.iter().position(|item| item.id == target_id) else {
|
||||
return (target_id.to_string(), true);
|
||||
};
|
||||
|
||||
if upper_half {
|
||||
return (target_id.to_string(), true);
|
||||
}
|
||||
|
||||
if let Some(next_item) = items.get(target_idx + 1) {
|
||||
(next_item.id.to_string(), true)
|
||||
} else {
|
||||
(target_id.to_string(), false)
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_drop_marker(ctx: &PageBuildContext, list: >k4::Box, row: >k4::Box, y: f64) {
|
||||
clear_drop_markers(list);
|
||||
|
||||
let target_id = row.widget_name();
|
||||
let items = ordered_hud_items(ctx);
|
||||
let Some(target_idx) = items.iter().position(|item| item.id == target_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let upper_half = y < (row.height() as f64 / 2.0);
|
||||
if upper_half {
|
||||
row.add_css_class("hud-order-row-drop-before");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(next_row) = row_at(list, target_idx + 1) {
|
||||
next_row.add_css_class("hud-order-row-drop-before");
|
||||
} else {
|
||||
row.add_css_class("hud-order-row-drop-after");
|
||||
}
|
||||
}
|
||||
|
||||
fn is_flag_effectively_enabled(value: Option<&(usize, ConfigValue)>) -> bool {
|
||||
let Some((_, value)) = value else {
|
||||
return true;
|
||||
};
|
||||
|
||||
match value {
|
||||
ConfigValue::Flag => true,
|
||||
ConfigValue::Disabled | ConfigValue::Absent => false,
|
||||
ConfigValue::Value(text) => {
|
||||
let normalized = text.trim().to_ascii_lowercase();
|
||||
!matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active_display_value(value: &ConfigValue) -> bool {
|
||||
match value {
|
||||
ConfigValue::Flag => true,
|
||||
ConfigValue::Value(raw) => !matches!(
|
||||
raw.trim().to_ascii_lowercase().as_str(),
|
||||
"" | "0" | "false" | "no" | "off"
|
||||
),
|
||||
ConfigValue::Disabled | ConfigValue::Absent => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
use crate::ui::pages::{block_on_optional, PageBuildContext};
|
||||
use crate::ui::toast::show_toast;
|
||||
use crate::ui::widgets::{toggle_row, tool_page};
|
||||
use gtk4::prelude::*;
|
||||
use libadwaita::prelude::*;
|
||||
use mangotune::config::schema::get_schema_entry;
|
||||
use mangotune::integrations;
|
||||
use mangotune::integrations::{
|
||||
GameModeStatus, HeroicStatus, LutrisStatus, SteamInjectMethod, SteamStatus,
|
||||
};
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"Integrations",
|
||||
"Tools",
|
||||
"Connect MangoHud to the launchers and helpers Linux gamers actually use, without digging through multiple config formats by hand.",
|
||||
&["steam", "lutris", "heroic", "gamemode"],
|
||||
);
|
||||
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Quick start",
|
||||
"Use this page when you want MangoHud to show up before the game even launches",
|
||||
"Steam gets a generated launch option, while Lutris and Heroic can patch detected game configs directly. GameMode can also be enabled from the same surface when supported.",
|
||||
None,
|
||||
);
|
||||
|
||||
append_gamemode_section(&body, ctx);
|
||||
append_steam_section(&body, ctx);
|
||||
append_lutris_section(&body, ctx);
|
||||
append_heroic_section(&body, ctx);
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
fn append_gamemode_section(body: >k4::Box, ctx: &PageBuildContext) {
|
||||
let group = tool_page::append_custom_section(
|
||||
body,
|
||||
"GameMode",
|
||||
"Check daemon status and decide whether MangoHud should request GameMode when supported by your system.",
|
||||
Some("Performance helper"),
|
||||
);
|
||||
|
||||
let status = block_on_optional(integrations::detect_gamemode());
|
||||
let status_row = libadwaita::ActionRow::builder()
|
||||
.title("Current status")
|
||||
.subtitle(gamemode_status_text(status.as_ref()))
|
||||
.build();
|
||||
status_row.add_css_class("control-row");
|
||||
group.add(&status_row);
|
||||
|
||||
if let Some(entry) = get_schema_entry("gamemode") {
|
||||
toggle_row::add_schema_row(&group, entry, ctx);
|
||||
}
|
||||
|
||||
if let Some(status) = status {
|
||||
let detail_row = libadwaita::ActionRow::builder()
|
||||
.title("Detection details")
|
||||
.subtitle(gamemode_detail_text(&status))
|
||||
.build();
|
||||
detail_row.add_css_class("control-row");
|
||||
group.add(&detail_row);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_steam_section(body: >k4::Box, ctx: &PageBuildContext) {
|
||||
let group = tool_page::append_custom_section(
|
||||
body,
|
||||
"Steam",
|
||||
"Generate a launch option you can paste into a game’s Steam launch settings. MangoTune updates the command live as you switch strategies.",
|
||||
Some("Launch option"),
|
||||
);
|
||||
|
||||
let status = block_on_optional(integrations::detect_steam());
|
||||
let status_row = libadwaita::ActionRow::builder()
|
||||
.title("Steam detection")
|
||||
.subtitle(steam_status_text(status.as_ref()))
|
||||
.build();
|
||||
status_row.add_css_class("control-row");
|
||||
group.add(&status_row);
|
||||
|
||||
if let Some(status) = status.as_ref() {
|
||||
let detail_row = libadwaita::ActionRow::builder()
|
||||
.title("Library/config path")
|
||||
.subtitle(steam_detail_text(status))
|
||||
.build();
|
||||
detail_row.add_css_class("control-row");
|
||||
group.add(&detail_row);
|
||||
}
|
||||
|
||||
let methods = steam_methods();
|
||||
let labels: Vec<&str> = methods.iter().map(|(label, _)| *label).collect();
|
||||
let model = gtk4::StringList::new(&labels);
|
||||
|
||||
let method_row = libadwaita::ComboRow::builder()
|
||||
.title("Injection method")
|
||||
.subtitle(
|
||||
"Switch between prefix, env-var, explicit config, and GameMode-style launch strings",
|
||||
)
|
||||
.build();
|
||||
method_row.add_css_class("control-row");
|
||||
method_row.set_model(Some(&model));
|
||||
group.add(&method_row);
|
||||
|
||||
let command_row = libadwaita::ActionRow::builder()
|
||||
.title("Generated launch option")
|
||||
.subtitle("Copy this into Steam game properties")
|
||||
.build();
|
||||
command_row.add_css_class("control-row");
|
||||
|
||||
let entry = gtk4::Entry::new();
|
||||
entry.add_css_class("control-field");
|
||||
entry.set_hexpand(true);
|
||||
entry.set_editable(false);
|
||||
entry.set_text(&launch_option_for_method(
|
||||
methods
|
||||
.first()
|
||||
.map(|(_, method)| *method)
|
||||
.unwrap_or(SteamInjectMethod::MangohudPrefix),
|
||||
ctx,
|
||||
));
|
||||
|
||||
let copy = gtk4::Button::with_label("Copy");
|
||||
copy.add_css_class("control-button");
|
||||
let entry_copy = entry.clone();
|
||||
let overlay = ctx.toast_overlay.clone();
|
||||
copy.connect_clicked(move |_| {
|
||||
if let Some(display) = gtk4::gdk::Display::default() {
|
||||
display.clipboard().set_text(&entry_copy.text());
|
||||
show_toast(&overlay, "Copied Steam launch option");
|
||||
}
|
||||
});
|
||||
|
||||
let entry_update = entry.clone();
|
||||
let ctx_update = ctx.clone();
|
||||
let methods_update = methods.clone();
|
||||
method_row.connect_selected_notify(move |combo| {
|
||||
let idx = combo.selected() as usize;
|
||||
let method = methods_update
|
||||
.get(idx)
|
||||
.map(|(_, method)| *method)
|
||||
.unwrap_or(SteamInjectMethod::MangohudPrefix);
|
||||
entry_update.set_text(&launch_option_for_method(method, &ctx_update));
|
||||
});
|
||||
|
||||
command_row.add_suffix(&entry);
|
||||
command_row.add_suffix(©);
|
||||
group.add(&command_row);
|
||||
}
|
||||
|
||||
fn append_lutris_section(body: >k4::Box, ctx: &PageBuildContext) {
|
||||
let group = tool_page::append_custom_section(
|
||||
body,
|
||||
"Lutris",
|
||||
"Detect installed Lutris YAML configs and patch the first discovered game directly when you want a quick enable path.",
|
||||
Some("Config patching"),
|
||||
);
|
||||
|
||||
let status = block_on_optional(integrations::detect_lutris());
|
||||
let status_row = libadwaita::ActionRow::builder()
|
||||
.title("Lutris detection")
|
||||
.subtitle(lutris_status_text(status.as_ref()))
|
||||
.build();
|
||||
status_row.add_css_class("control-row");
|
||||
group.add(&status_row);
|
||||
|
||||
if let Some(status) = status.as_ref() {
|
||||
let detail_row = libadwaita::ActionRow::builder()
|
||||
.title("Config root")
|
||||
.subtitle(
|
||||
status
|
||||
.config_dir
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "No config directory found".to_string()),
|
||||
)
|
||||
.build();
|
||||
detail_row.add_css_class("control-row");
|
||||
group.add(&detail_row);
|
||||
}
|
||||
|
||||
append_lutris_quick_action(&group, status.as_ref(), ctx);
|
||||
append_launcher_empty_state(
|
||||
&group,
|
||||
status.as_ref().map(|status| status.games.is_empty()).unwrap_or(true),
|
||||
"No Lutris game configs detected yet",
|
||||
"MangoTune can patch the first detected Lutris game once Lutris has written game YAML files on this machine.",
|
||||
);
|
||||
}
|
||||
|
||||
fn append_heroic_section(body: >k4::Box, ctx: &PageBuildContext) {
|
||||
let group = tool_page::append_custom_section(
|
||||
body,
|
||||
"Heroic",
|
||||
"Patch Heroic JSON configs so MangoHud launches through wrapper and environment entries without hand-editing per-game files.",
|
||||
Some("Launcher patching"),
|
||||
);
|
||||
|
||||
let status = block_on_optional(integrations::detect_heroic());
|
||||
let status_row = libadwaita::ActionRow::builder()
|
||||
.title("Heroic detection")
|
||||
.subtitle(heroic_status_text(status.as_ref()))
|
||||
.build();
|
||||
status_row.add_css_class("control-row");
|
||||
group.add(&status_row);
|
||||
|
||||
if let Some(status) = status.as_ref() {
|
||||
let detail_row = libadwaita::ActionRow::builder()
|
||||
.title("Config root")
|
||||
.subtitle(
|
||||
status
|
||||
.config_dir
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "No config directory found".to_string()),
|
||||
)
|
||||
.build();
|
||||
detail_row.add_css_class("control-row");
|
||||
group.add(&detail_row);
|
||||
}
|
||||
|
||||
append_heroic_quick_action(&group, status.as_ref(), ctx);
|
||||
append_launcher_empty_state(
|
||||
&group,
|
||||
status.as_ref().map(|status| status.games.is_empty()).unwrap_or(true),
|
||||
"No Heroic game configs detected yet",
|
||||
"MangoTune can patch the first detected Heroic game once Heroic has written per-game JSON configs on this machine.",
|
||||
);
|
||||
}
|
||||
|
||||
fn append_lutris_quick_action(
|
||||
group: &libadwaita::PreferencesGroup,
|
||||
status: Option<&LutrisStatus>,
|
||||
ctx: &PageBuildContext,
|
||||
) {
|
||||
let Some(status) = status else {
|
||||
return;
|
||||
};
|
||||
let Some(game) = status.games.first() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let row = libadwaita::ActionRow::builder()
|
||||
.title(format!("Enable for {}", game.name))
|
||||
.subtitle(format!("{} • {}", game.runner, game.config_path.display()))
|
||||
.build();
|
||||
row.add_css_class("control-row");
|
||||
|
||||
let button = gtk4::Button::with_label("Patch");
|
||||
button.add_css_class("suggested-action");
|
||||
let path = game.config_path.clone();
|
||||
let overlay = ctx.toast_overlay.clone();
|
||||
button.connect_clicked(
|
||||
move |_| match integrations::set_lutris_mangohud(&path, true) {
|
||||
Ok(_) => show_toast(&overlay, "Enabled MangoHud for Lutris game"),
|
||||
Err(error) => show_toast(&overlay, &format!("Failed to patch Lutris config: {error}")),
|
||||
},
|
||||
);
|
||||
row.add_suffix(&button);
|
||||
group.add(&row);
|
||||
}
|
||||
|
||||
fn append_heroic_quick_action(
|
||||
group: &libadwaita::PreferencesGroup,
|
||||
status: Option<&HeroicStatus>,
|
||||
ctx: &PageBuildContext,
|
||||
) {
|
||||
let Some(status) = status else {
|
||||
return;
|
||||
};
|
||||
let Some(game) = status.games.first() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let row = libadwaita::ActionRow::builder()
|
||||
.title(format!("Enable for {}", game.title))
|
||||
.subtitle(format!("{:?} • {}", game.store, game.config_path.display()))
|
||||
.build();
|
||||
row.add_css_class("control-row");
|
||||
|
||||
let button = gtk4::Button::with_label("Patch");
|
||||
button.add_css_class("suggested-action");
|
||||
let path = game.config_path.clone();
|
||||
let overlay = ctx.toast_overlay.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
let wrapper_result = integrations::set_heroic_wrapper(&path, true);
|
||||
let env_result = integrations::set_heroic_env(&path, true);
|
||||
|
||||
match (wrapper_result, env_result) {
|
||||
(Ok(_), Ok(_)) => {
|
||||
show_toast(&overlay, "Enabled MangoHud for Heroic game");
|
||||
}
|
||||
(wrapper, env) => {
|
||||
let mut failures = Vec::new();
|
||||
if let Err(error) = wrapper {
|
||||
failures.push(format!("wrapper: {error}"));
|
||||
}
|
||||
if let Err(error) = env {
|
||||
failures.push(format!("env: {error}"));
|
||||
}
|
||||
show_toast(
|
||||
&overlay,
|
||||
&format!("Failed to patch Heroic config: {}", failures.join(", ")),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
row.add_suffix(&button);
|
||||
group.add(&row);
|
||||
}
|
||||
|
||||
fn append_launcher_empty_state(
|
||||
group: &libadwaita::PreferencesGroup,
|
||||
show: bool,
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
) {
|
||||
if !show {
|
||||
return;
|
||||
}
|
||||
|
||||
let row = libadwaita::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.build();
|
||||
row.add_css_class("control-row");
|
||||
group.add(&row);
|
||||
}
|
||||
|
||||
fn steam_methods() -> Vec<(&'static str, SteamInjectMethod)> {
|
||||
vec![
|
||||
("mangohud %command%", SteamInjectMethod::MangohudPrefix),
|
||||
("MANGOHUD=1 %command%", SteamInjectMethod::EnvVar),
|
||||
(
|
||||
"MANGOHUD_CONFIGFILE=... %command%",
|
||||
SteamInjectMethod::ExplicitConfig,
|
||||
),
|
||||
(
|
||||
"gamemoderun mangohud %command%",
|
||||
SteamInjectMethod::GameMode,
|
||||
),
|
||||
(
|
||||
"gamemoderun mangohud %command% (flatpak)",
|
||||
SteamInjectMethod::GameModeFlatpak,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn launch_option_for_method(method: SteamInjectMethod, ctx: &PageBuildContext) -> String {
|
||||
let config_path = ctx.state.lock().ok().and_then(|state| {
|
||||
state
|
||||
.config
|
||||
.path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
});
|
||||
integrations::generate_launch_option(method, config_path.as_deref())
|
||||
}
|
||||
|
||||
fn gamemode_status_text(status: Option<&GameModeStatus>) -> String {
|
||||
match status {
|
||||
Some(s) if !s.daemon_installed => "Not installed".to_string(),
|
||||
Some(s) if s.daemon_running => format!("Running with {} clients", s.current_clients),
|
||||
Some(_) => "Installed but not running".to_string(),
|
||||
None => "Detection unavailable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn gamemode_detail_text(status: &GameModeStatus) -> String {
|
||||
let ctl = if status.ctl_installed {
|
||||
"gamemodectl available"
|
||||
} else {
|
||||
"gamemodectl missing"
|
||||
};
|
||||
format!("{ctl} • {} active clients", status.current_clients)
|
||||
}
|
||||
|
||||
fn steam_status_text(status: Option<&SteamStatus>) -> String {
|
||||
match status {
|
||||
Some(s) if !s.installed && !s.flatpak => "Not installed".to_string(),
|
||||
Some(s) if s.flatpak && s.running => "Installed via Flatpak • running".to_string(),
|
||||
Some(s) if s.flatpak => "Installed via Flatpak".to_string(),
|
||||
Some(s) if s.running => "Installed • running".to_string(),
|
||||
Some(_) => "Installed".to_string(),
|
||||
None => "Detection unavailable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn steam_detail_text(status: &SteamStatus) -> String {
|
||||
status
|
||||
.localconfig_path
|
||||
.as_ref()
|
||||
.or(status.steam_root.as_ref())
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "No Steam config path detected".to_string())
|
||||
}
|
||||
|
||||
fn lutris_status_text(status: Option<&LutrisStatus>) -> String {
|
||||
match status {
|
||||
Some(s) if !s.installed && !s.flatpak => "Not installed".to_string(),
|
||||
Some(s) if s.flatpak => format!("Installed via Flatpak • {} detected games", s.games.len()),
|
||||
Some(s) => format!("Installed • {} detected games", s.games.len()),
|
||||
None => "Detection unavailable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn heroic_status_text(status: Option<&HeroicStatus>) -> String {
|
||||
match status {
|
||||
Some(s) if !s.installed && !s.flatpak => "Not installed".to_string(),
|
||||
Some(s) if s.flatpak => format!("Installed via Flatpak • {} detected games", s.games.len()),
|
||||
Some(s) => format!("Installed • {} detected games", s.games.len()),
|
||||
None => "Detection unavailable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn steam_status_text_covers_running_flatpak() {
|
||||
let status = SteamStatus {
|
||||
installed: false,
|
||||
flatpak: true,
|
||||
running: true,
|
||||
steam_root: None,
|
||||
localconfig_path: None,
|
||||
};
|
||||
assert_eq!(
|
||||
steam_status_text(Some(&status)),
|
||||
"Installed via Flatpak • running"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamemode_detail_mentions_ctl_and_clients() {
|
||||
let status = GameModeStatus {
|
||||
daemon_installed: true,
|
||||
ctl_installed: true,
|
||||
daemon_running: true,
|
||||
current_clients: 2,
|
||||
};
|
||||
assert_eq!(
|
||||
gamemode_detail_text(&status),
|
||||
"gamemodectl available • 2 active clients"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use mangotune::config::types::Category;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
tool_page::build_single_category_page(
|
||||
ctx,
|
||||
"I/O and Network",
|
||||
"Display",
|
||||
"Track bandwidth, throughput, and storage activity when you need to prove a bottleneck instead of guessing at it.",
|
||||
&["network", "disk", "throughput"],
|
||||
"Streaming and storage signals",
|
||||
"Useful when downloads, shader compiles, or disk stalls are part of the problem you are investigating.",
|
||||
Some("Situational"),
|
||||
Category::DisplayIoNetwork,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::{hotkey_row, tool_page};
|
||||
use libadwaita::prelude::*;
|
||||
use mangotune::config::help::display_title_for_key;
|
||||
use mangotune::config::schema::entries_for_category;
|
||||
use mangotune::config::types::Category;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"Keybindings",
|
||||
"Behavior",
|
||||
"Set the shortcuts that let you toggle, cycle, or inspect MangoHud while you are in a real game.",
|
||||
&["hotkeys", "toggle HUD", "in-game control"],
|
||||
);
|
||||
|
||||
let group = tool_page::append_custom_section(
|
||||
&body,
|
||||
"Overlay shortcuts",
|
||||
"These actions change how MangoHud behaves live in game. Keep them memorable and avoid conflicts with your usual binds.",
|
||||
Some("In-game control"),
|
||||
);
|
||||
|
||||
for entry in entries_for_category(&Category::BehaviorKeybindings) {
|
||||
let row = hotkey_row::build_hotkey_row(&display_title_for_key(entry.key), entry.key, ctx);
|
||||
group.add(&row);
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use crate::ui::pages::{overview, PageBuildContext};
|
||||
use crate::ui::widgets::tool_page;
|
||||
use gtk4::prelude::*;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_start_page(
|
||||
"Live Preview",
|
||||
"Start",
|
||||
"Launch MangoTune's built-in Studio preview and tune its runtime controls without touching your saved MangoHud config.",
|
||||
&["preview", "studio", "runtime tuning"],
|
||||
);
|
||||
|
||||
body.append(&overview::build_preview_panel(ctx));
|
||||
page
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use mangotune::config::types::Category;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
tool_page::build_single_category_page(
|
||||
ctx,
|
||||
"Logging",
|
||||
"Behavior",
|
||||
"Enable logs and debug exports only when you actually need to troubleshoot MangoHud or share a reproducible issue.",
|
||||
&["debugging", "uploads", "troubleshooting"],
|
||||
"Logs and exports",
|
||||
"These settings are mainly for investigation. Leave them quiet by default unless you are tracking down a problem.",
|
||||
Some("Troubleshooting"),
|
||||
Category::BehaviorLogging,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use mangotune::config::types::Category;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
tool_page::build_single_category_page(
|
||||
ctx,
|
||||
"Media Player",
|
||||
"Display",
|
||||
"Expose now-playing info when it actually helps your setup, like couch gaming or a second-screen music flow.",
|
||||
&["now playing", "optional", "media metadata"],
|
||||
"Media overlays",
|
||||
"These settings are niche by design. Turn them on when background media context belongs in your HUD.",
|
||||
Some("Optional"),
|
||||
Category::DisplayMediaPlayer,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use mangotune::config::types::Category;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
tool_page::build_single_category_page(
|
||||
ctx,
|
||||
"Memory",
|
||||
"Display",
|
||||
"Monitor VRAM, RAM, swap, and process-level memory pressure without flooding the overlay with low-value counters.",
|
||||
&["VRAM", "RAM", "pressure"],
|
||||
"Memory telemetry",
|
||||
"Pick the memory signals that help explain stutter, asset streaming issues, or background pressure on your system.",
|
||||
Some("Diagnostics"),
|
||||
Category::DisplayMemory,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
use crate::ui::widgets::validation_label;
|
||||
use crate::window::AppState;
|
||||
use gtk4::prelude::*;
|
||||
use mangotune::config::help::{display_summary_for_key, display_title_for_key};
|
||||
use mangotune::config::schema::{entries_for_category, MANGOHUD_SCHEMA};
|
||||
use mangotune::config::types::AnnotatedConfig;
|
||||
use mangotune::config::types::Category;
|
||||
use mangotune::config::types::ValidationResult;
|
||||
use mangotune::config::{schema::get_schema_entry, validator};
|
||||
use mangotune::preview::PreviewController;
|
||||
use mangotune::system::detect::SystemInfo;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::runtime::Builder;
|
||||
|
||||
pub mod appearance;
|
||||
pub mod battery;
|
||||
pub mod blacklist;
|
||||
pub mod colors;
|
||||
pub mod conflicts;
|
||||
pub mod cpu;
|
||||
pub mod debug;
|
||||
pub mod fps_limits;
|
||||
pub mod gpu;
|
||||
pub mod hud_order;
|
||||
pub mod integrations;
|
||||
pub mod io_network;
|
||||
pub mod keybindings;
|
||||
pub mod live_preview;
|
||||
pub mod logging;
|
||||
pub mod media_player;
|
||||
pub mod memory;
|
||||
pub mod opengl_quirks;
|
||||
pub mod overview;
|
||||
pub mod performance;
|
||||
pub mod presets_page;
|
||||
pub mod raw_editor;
|
||||
pub mod search_results;
|
||||
pub mod typography;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PageBuildContext {
|
||||
pub state: Arc<Mutex<AppState>>,
|
||||
pub preview: PreviewController,
|
||||
pub preview_reload_source: Rc<RefCell<Option<glib::SourceId>>>,
|
||||
pub validation_rows: Rc<RefCell<HashMap<String, Vec<ValidationRowBinding>>>>,
|
||||
pub option_rows: Rc<RefCell<HashMap<String, Vec<glib::WeakRef<gtk4::Widget>>>>>,
|
||||
pub pending_search_target: Rc<RefCell<Option<String>>>,
|
||||
pub current_search_query: Rc<RefCell<String>>,
|
||||
pub save_button: libadwaita::SplitButton,
|
||||
pub toast_overlay: libadwaita::ToastOverlay,
|
||||
pub parent_window: libadwaita::ApplicationWindow,
|
||||
pub system_info: SystemInfo,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ValidationRowBinding {
|
||||
pub row: glib::WeakRef<libadwaita::ActionRow>,
|
||||
pub base_subtitle: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SearchResultItem {
|
||||
pub key: &'static str,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SearchResultGroup {
|
||||
pub page_id: &'static str,
|
||||
pub page_title: &'static str,
|
||||
pub page_section: &'static str,
|
||||
pub results: Vec<SearchResultItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SidebarItem {
|
||||
pub id: &'static str,
|
||||
pub title: &'static str,
|
||||
pub section: &'static str,
|
||||
pub icon_name: &'static str,
|
||||
}
|
||||
|
||||
pub const SIDEBAR_ITEMS: &[SidebarItem] = &[
|
||||
SidebarItem {
|
||||
id: "overview",
|
||||
title: "Dashboard",
|
||||
section: "Start",
|
||||
icon_name: "go-home-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "live_preview",
|
||||
title: "Live Preview",
|
||||
section: "Start",
|
||||
icon_name: "media-playback-start-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "presets_page",
|
||||
title: "Presets",
|
||||
section: "Start",
|
||||
icon_name: "view-grid-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "performance",
|
||||
title: "Performance",
|
||||
section: "Display",
|
||||
icon_name: "speedometer-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "gpu",
|
||||
title: "GPU",
|
||||
section: "Display",
|
||||
icon_name: "video-display-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "cpu",
|
||||
title: "CPU",
|
||||
section: "Display",
|
||||
icon_name: "computer-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "memory",
|
||||
title: "Memory",
|
||||
section: "Display",
|
||||
icon_name: "drive-harddisk-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "io_network",
|
||||
title: "I/O and Network",
|
||||
section: "Display",
|
||||
icon_name: "network-workgroup-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "media_player",
|
||||
title: "Media Player",
|
||||
section: "Display",
|
||||
icon_name: "multimedia-player-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "battery",
|
||||
title: "Battery",
|
||||
section: "Display",
|
||||
icon_name: "battery-good-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "appearance",
|
||||
title: "Layout and Position",
|
||||
section: "Appearance",
|
||||
icon_name: "view-grid-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "colors",
|
||||
title: "Colors and Theme",
|
||||
section: "Appearance",
|
||||
icon_name: "applications-graphics-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "typography",
|
||||
title: "Typography",
|
||||
section: "Appearance",
|
||||
icon_name: "format-text-bold-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "keybindings",
|
||||
title: "Keybindings",
|
||||
section: "Behavior",
|
||||
icon_name: "input-keyboard-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "fps_limits",
|
||||
title: "FPS Limits",
|
||||
section: "Behavior",
|
||||
icon_name: "view-refresh-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "logging",
|
||||
title: "Logging",
|
||||
section: "Behavior",
|
||||
icon_name: "text-x-log-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "blacklist",
|
||||
title: "Blacklist",
|
||||
section: "Behavior",
|
||||
icon_name: "process-stop-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "opengl_quirks",
|
||||
title: "OpenGL Quirks",
|
||||
section: "Advanced",
|
||||
icon_name: "applications-system-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "raw_editor",
|
||||
title: "Raw Editor",
|
||||
section: "Advanced",
|
||||
icon_name: "text-x-script-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "hud_order",
|
||||
title: "HUD Order",
|
||||
section: "Tools",
|
||||
icon_name: "view-sort-ascending-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "integrations",
|
||||
title: "Integrations",
|
||||
section: "Tools",
|
||||
icon_name: "insert-object-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "debug",
|
||||
title: "Debug",
|
||||
section: "Tools",
|
||||
icon_name: "utilities-terminal-symbolic",
|
||||
},
|
||||
SidebarItem {
|
||||
id: "conflicts",
|
||||
title: "Layer Conflicts",
|
||||
section: "Tools",
|
||||
icon_name: "dialog-warning-symbolic",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn sidebar_search_text(item: &SidebarItem) -> String {
|
||||
let mut parts = vec![
|
||||
item.title.to_string(),
|
||||
item.section.to_string(),
|
||||
item.id.replace('_', " "),
|
||||
];
|
||||
|
||||
match item.id {
|
||||
"overview" => parts.extend(
|
||||
[
|
||||
"layout position anchor offset hud width table columns readable typography metrics dashboard profiles quick tune colors preview",
|
||||
"font size font scale background opacity overall alpha corner radius compact no margin horizontal outline gpu cpu ram vram fps frametime frame timing battery media",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
),
|
||||
"live_preview" => parts.extend(
|
||||
[
|
||||
"preview studio start apply restart stop scene vsync reset defaults balanced threshold test gpu stress cpu stress vram test",
|
||||
"fps cap particle count particle size gpu passes cpu interaction vram pressure pause motion window size safe max",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
),
|
||||
"presets_page" => parts.extend(
|
||||
[
|
||||
"presets benchmark competitive performance streaming starter profiles starter overlays",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
),
|
||||
"appearance" => add_category_terms(&mut parts, &[Category::AppearanceLayout]),
|
||||
"colors" => add_category_terms(&mut parts, &[Category::AppearanceColors]),
|
||||
"typography" => add_category_terms(&mut parts, &[Category::AppearanceTypography]),
|
||||
"keybindings" => add_category_terms(&mut parts, &[Category::BehaviorKeybindings]),
|
||||
"fps_limits" => add_category_terms(&mut parts, &[Category::BehaviorFpsLimits]),
|
||||
"logging" => add_category_terms(&mut parts, &[Category::BehaviorLogging]),
|
||||
"blacklist" => parts.extend(
|
||||
[
|
||||
"blacklist exclude ignore process app applications executable blacklist list",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
),
|
||||
"performance" => add_category_terms(
|
||||
&mut parts,
|
||||
&[
|
||||
Category::Performance,
|
||||
Category::DisplayFps,
|
||||
Category::DisplayMisc,
|
||||
Category::DisplayGamescope,
|
||||
],
|
||||
),
|
||||
"gpu" => add_category_terms(&mut parts, &[Category::DisplayGpu]),
|
||||
"cpu" => add_category_terms(&mut parts, &[Category::DisplayCpu]),
|
||||
"memory" => add_category_terms(&mut parts, &[Category::DisplayMemory]),
|
||||
"io_network" => add_category_terms(&mut parts, &[Category::DisplayIoNetwork]),
|
||||
"media_player" => add_category_terms(&mut parts, &[Category::DisplayMediaPlayer]),
|
||||
"battery" => add_category_terms(&mut parts, &[Category::DisplayBattery]),
|
||||
"opengl_quirks" => add_category_terms(&mut parts, &[Category::WorkaroundsOpengl]),
|
||||
"raw_editor" => parts.extend(
|
||||
[
|
||||
"raw editor config text lines comments source parse write edit manually",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
),
|
||||
"hud_order" => parts.extend(
|
||||
[
|
||||
"hud order render order sequence drag drop reorder gpu cpu ram vram fps pacing overlay groups",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
),
|
||||
"integrations" => parts.extend(
|
||||
[
|
||||
"integrations steam gamescope goverlay mangohud control socket external tools",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
),
|
||||
"debug" => parts.extend(
|
||||
[
|
||||
"debug diagnostics state preview logs layer stack validation config info system runtime",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
),
|
||||
"conflicts" => parts.extend(
|
||||
[
|
||||
"layer conflicts cascade shadowed overridden winning values priority stack config layers",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
parts.join(" ").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
pub fn build_page_widget(id: &str, ctx: &PageBuildContext) -> Option<gtk4::Widget> {
|
||||
Some(match id {
|
||||
"overview" => overview::build_page(ctx).upcast(),
|
||||
"live_preview" => live_preview::build_page(ctx).upcast(),
|
||||
"presets_page" => presets_page::build_page(ctx).upcast(),
|
||||
"search_results" => search_results::build_page(ctx).upcast(),
|
||||
"conflicts" => conflicts::build_page(ctx).upcast(),
|
||||
"performance" => performance::build_page(ctx).upcast(),
|
||||
"gpu" => gpu::build_page(ctx).upcast(),
|
||||
"cpu" => cpu::build_page(ctx).upcast(),
|
||||
"memory" => memory::build_page(ctx).upcast(),
|
||||
"io_network" => io_network::build_page(ctx).upcast(),
|
||||
"media_player" => media_player::build_page(ctx).upcast(),
|
||||
"battery" => battery::build_page(ctx).upcast(),
|
||||
"appearance" => appearance::build_page(ctx).upcast(),
|
||||
"colors" => colors::build_page(ctx).upcast(),
|
||||
"typography" => typography::build_page(ctx).upcast(),
|
||||
"keybindings" => keybindings::build_page(ctx).upcast(),
|
||||
"fps_limits" => fps_limits::build_page(ctx).upcast(),
|
||||
"logging" => logging::build_page(ctx).upcast(),
|
||||
"blacklist" => blacklist::build_page(ctx).upcast(),
|
||||
"opengl_quirks" => opengl_quirks::build_page(ctx).upcast(),
|
||||
"raw_editor" => raw_editor::build_page(ctx).upcast(),
|
||||
"hud_order" => hud_order::build_page(ctx).upcast(),
|
||||
"integrations" => integrations::build_page(ctx).upcast(),
|
||||
"debug" => debug::build_page(ctx).upcast(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_navigation_page(
|
||||
id: &str,
|
||||
ctx: &PageBuildContext,
|
||||
) -> Option<libadwaita::NavigationPage> {
|
||||
let page_widget = build_page_widget(id, ctx)?;
|
||||
if id == "search_results" {
|
||||
return Some(libadwaita::NavigationPage::with_tag(
|
||||
&page_widget,
|
||||
"Search Results",
|
||||
"search_results",
|
||||
));
|
||||
}
|
||||
|
||||
let item = SIDEBAR_ITEMS.iter().find(|it| it.id == id)?;
|
||||
Some(libadwaita::NavigationPage::with_tag(
|
||||
&page_widget,
|
||||
item.title,
|
||||
item.id,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn current_config_snapshot(ctx: &PageBuildContext) -> AnnotatedConfig {
|
||||
ctx.state
|
||||
.lock()
|
||||
.map(|state| state.config.clone())
|
||||
.unwrap_or_else(|_| AnnotatedConfig {
|
||||
lines: Vec::new(),
|
||||
options: indexmap::IndexMap::new(),
|
||||
path: None,
|
||||
dirty: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn register_validation_row<R>(ctx: &PageBuildContext, key: &str, row: &R, base_subtitle: &str)
|
||||
where
|
||||
R: IsA<libadwaita::ActionRow> + Clone + 'static,
|
||||
{
|
||||
let action_row: libadwaita::ActionRow = row.clone().upcast();
|
||||
let weak = glib::WeakRef::new();
|
||||
weak.set(Some(&action_row));
|
||||
|
||||
let mut registry = ctx.validation_rows.borrow_mut();
|
||||
registry
|
||||
.entry(key.to_string())
|
||||
.or_default()
|
||||
.push(ValidationRowBinding {
|
||||
row: weak,
|
||||
base_subtitle: base_subtitle.to_string(),
|
||||
});
|
||||
drop(registry);
|
||||
|
||||
refresh_registered_validation_rows(ctx);
|
||||
}
|
||||
|
||||
pub fn register_option_row<W>(ctx: &PageBuildContext, key: &str, widget: &W)
|
||||
where
|
||||
W: IsA<gtk4::Widget> + Clone + 'static,
|
||||
{
|
||||
let widget: gtk4::Widget = widget.clone().upcast();
|
||||
let weak = glib::WeakRef::new();
|
||||
weak.set(Some(&widget));
|
||||
|
||||
ctx.option_rows
|
||||
.borrow_mut()
|
||||
.entry(key.to_string())
|
||||
.or_default()
|
||||
.push(weak);
|
||||
|
||||
if ctx
|
||||
.pending_search_target
|
||||
.borrow()
|
||||
.as_deref()
|
||||
.is_some_and(|pending| pending == key)
|
||||
{
|
||||
*ctx.pending_search_target.borrow_mut() = None;
|
||||
schedule_reveal_and_flash(&widget);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_registered_validation_rows(ctx: &PageBuildContext) {
|
||||
let validations = {
|
||||
let Ok(state) = ctx.state.lock() else {
|
||||
return;
|
||||
};
|
||||
ctx.validation_rows
|
||||
.borrow()
|
||||
.keys()
|
||||
.map(|key| (key.clone(), validation_for_key_with_state(&state, key)))
|
||||
.collect::<HashMap<_, _>>()
|
||||
};
|
||||
|
||||
let mut registry = ctx.validation_rows.borrow_mut();
|
||||
registry.retain(|key, bindings| {
|
||||
let validation = validations
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or(ValidationResult::Ok);
|
||||
let error = validation_message(&validation);
|
||||
bindings.retain(|binding| {
|
||||
if let Some(row) = binding.row.upgrade() {
|
||||
validation_label::set_action_row_error(&row, &binding.base_subtitle, error);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
!bindings.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
pub fn refresh_live_preview_for_key(ctx: &PageBuildContext, key: Option<&str>) {
|
||||
if ctx.preview.running_scene().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(source) = ctx.preview_reload_source.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
|
||||
let ctx_clone = ctx.clone();
|
||||
let _ = key;
|
||||
let source = glib::timeout_add_local(Duration::from_millis(180), move || {
|
||||
if ctx_clone.preview.running_scene().is_none() {
|
||||
*ctx_clone.preview_reload_source.borrow_mut() = None;
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
|
||||
let config = current_config_snapshot(&ctx_clone);
|
||||
if !validator::is_saveable(&config) {
|
||||
*ctx_clone.preview_reload_source.borrow_mut() = None;
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
|
||||
let result = ctx_clone.preview.apply_live_config(&config);
|
||||
if result.is_err() && ctx_clone.preview.running_scene().is_some() {
|
||||
let _ = ctx_clone.preview.restart(&config);
|
||||
}
|
||||
|
||||
*ctx_clone.preview_reload_source.borrow_mut() = None;
|
||||
glib::ControlFlow::Break
|
||||
});
|
||||
*ctx.preview_reload_source.borrow_mut() = Some(source);
|
||||
}
|
||||
|
||||
pub fn search_results_for_query(query: &str) -> Vec<SearchResultGroup> {
|
||||
let normalized_query = normalize_search_text(query);
|
||||
if normalized_query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
SIDEBAR_ITEMS
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let results = MANGOHUD_SCHEMA
|
||||
.iter()
|
||||
.filter(|entry| page_id_for_category(&entry.category) == Some(item.id))
|
||||
.filter_map(|entry| {
|
||||
let title = display_title_for_key(entry.key);
|
||||
let summary = display_summary_for_key(entry.key);
|
||||
let haystack = [
|
||||
entry.key.to_ascii_lowercase(),
|
||||
entry.key.replace('_', " ").to_ascii_lowercase(),
|
||||
title.to_ascii_lowercase(),
|
||||
normalize_search_text(&title),
|
||||
summary.to_ascii_lowercase(),
|
||||
normalize_search_text(&summary),
|
||||
]
|
||||
.join(" ");
|
||||
if haystack.contains(&normalized_query) {
|
||||
Some(SearchResultItem {
|
||||
key: entry.key,
|
||||
title,
|
||||
summary,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if results.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(SearchResultGroup {
|
||||
page_id: item.id,
|
||||
page_title: item.title,
|
||||
page_section: item.section,
|
||||
results,
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn focus_pending_search_target(ctx: &PageBuildContext) {
|
||||
let Some(key) = ctx.pending_search_target.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
let target = ctx
|
||||
.option_rows
|
||||
.borrow()
|
||||
.get(&key)
|
||||
.and_then(|widgets| widgets.iter().find_map(glib::WeakRef::upgrade));
|
||||
let Some(target) = target else {
|
||||
return;
|
||||
};
|
||||
*ctx.pending_search_target.borrow_mut() = None;
|
||||
schedule_reveal_and_flash(&target);
|
||||
}
|
||||
|
||||
fn validation_for_key_with_state(state: &AppState, key: &str) -> ValidationResult {
|
||||
if let Some(result) = state.validation.get(key) {
|
||||
return result.clone();
|
||||
}
|
||||
|
||||
if let Some((_, value)) = state.config.options.get(key) {
|
||||
if let Some(schema) = get_schema_entry(key) {
|
||||
return validator::validate_value(key, value, schema);
|
||||
}
|
||||
}
|
||||
|
||||
ValidationResult::Ok
|
||||
}
|
||||
|
||||
fn validation_message(validation: &ValidationResult) -> Option<&str> {
|
||||
match validation {
|
||||
ValidationResult::Error(message) | ValidationResult::Warning(message) => {
|
||||
Some(message.as_str())
|
||||
}
|
||||
ValidationResult::Ok => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_category_terms(parts: &mut Vec<String>, categories: &[Category]) {
|
||||
for category in categories {
|
||||
for entry in entries_for_category(category) {
|
||||
parts.push(display_title_for_key(entry.key));
|
||||
parts.push(display_summary_for_key(entry.key));
|
||||
parts.push(entry.key.to_string());
|
||||
parts.push(entry.key.replace('_', " "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn page_id_for_category(category: &Category) -> Option<&'static str> {
|
||||
Some(match category {
|
||||
Category::Performance
|
||||
| Category::DisplayFps
|
||||
| Category::DisplayMisc
|
||||
| Category::DisplayGamescope => "performance",
|
||||
Category::DisplayGpu => "gpu",
|
||||
Category::DisplayCpu => "cpu",
|
||||
Category::DisplayMemory => "memory",
|
||||
Category::DisplayIoNetwork => "io_network",
|
||||
Category::DisplayBattery => "battery",
|
||||
Category::DisplayMediaPlayer => "media_player",
|
||||
Category::AppearanceLayout => "appearance",
|
||||
Category::AppearanceColors => "colors",
|
||||
Category::AppearanceTypography => "typography",
|
||||
Category::BehaviorKeybindings => "keybindings",
|
||||
Category::BehaviorFpsLimits => "fps_limits",
|
||||
Category::BehaviorLogging => "logging",
|
||||
Category::BehaviorMisc => "blacklist",
|
||||
Category::WorkaroundsOpengl => "opengl_quirks",
|
||||
Category::DisplayGraphs | Category::DisplaySteamDeck | Category::DisplayTimeText => {
|
||||
"performance"
|
||||
}
|
||||
Category::AdvancedFcat | Category::AdvancedFtrace => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_search_text(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'_' | '-' | '/' => ' ',
|
||||
_ => ch.to_ascii_lowercase(),
|
||||
})
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn reveal_and_flash_widget(widget: >k4::Widget) {
|
||||
let list_row = widget
|
||||
.ancestor(gtk4::ListBoxRow::static_type())
|
||||
.and_then(|ancestor| ancestor.downcast::<gtk4::ListBoxRow>().ok());
|
||||
let anchor: gtk4::Widget = list_row
|
||||
.as_ref()
|
||||
.map(|row| row.clone().upcast())
|
||||
.unwrap_or_else(|| widget.clone());
|
||||
|
||||
if let Some(scrolled) = widget
|
||||
.ancestor(gtk4::ScrolledWindow::static_type())
|
||||
.and_then(|ancestor| ancestor.downcast::<gtk4::ScrolledWindow>().ok())
|
||||
{
|
||||
let adjustment = scrolled.vadjustment();
|
||||
let Some(content) = scrolled.child() else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = anchor.compute_bounds(&content) else {
|
||||
return;
|
||||
};
|
||||
let top = bounds.y() as f64;
|
||||
let height = bounds.height() as f64;
|
||||
let page_size = adjustment.page_size();
|
||||
let row_center = top + (height / 2.0);
|
||||
let max_value = (adjustment.upper() - page_size).max(adjustment.lower());
|
||||
let centered_value = (row_center - (page_size / 2.0)).clamp(adjustment.lower(), max_value);
|
||||
if (adjustment.value() - centered_value).abs() > 12.0 {
|
||||
adjustment.set_value(centered_value);
|
||||
}
|
||||
}
|
||||
|
||||
let widget_clone = widget.clone();
|
||||
let list_row_for_add = list_row.clone();
|
||||
glib::timeout_add_local_once(Duration::from_millis(70), move || {
|
||||
widget_clone.add_css_class("search-target-flash");
|
||||
if let Some(row) = list_row_for_add.as_ref() {
|
||||
row.add_css_class("search-target-flash");
|
||||
}
|
||||
});
|
||||
|
||||
let widget_clone = widget.clone();
|
||||
glib::timeout_add_local_once(Duration::from_millis(2200), move || {
|
||||
widget_clone.remove_css_class("search-target-flash");
|
||||
if let Some(row) = list_row {
|
||||
row.remove_css_class("search-target-flash");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn schedule_reveal_and_flash(widget: >k4::Widget) {
|
||||
let widget_clone = widget.clone();
|
||||
glib::timeout_add_local_once(Duration::from_millis(260), move || {
|
||||
reveal_and_flash_widget(&widget_clone);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn block_on_optional<F, T>(future: F) -> Option<T>
|
||||
where
|
||||
F: std::future::Future<Output = T>,
|
||||
{
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
return Some(handle.block_on(future));
|
||||
}
|
||||
let runtime = Builder::new_current_thread().enable_all().build().ok()?;
|
||||
Some(runtime.block_on(future))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use mangotune::config::types::Category;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
tool_page::build_single_category_page(
|
||||
ctx,
|
||||
"OpenGL Quirks",
|
||||
"Advanced",
|
||||
"Reach for these workarounds when an OpenGL title behaves differently from Vulkan and needs compatibility help from MangoHud.",
|
||||
&["OpenGL", "compatibility", "fallbacks"],
|
||||
"OpenGL workarounds",
|
||||
"These settings are mostly for edge cases. Use them when a game or launcher needs extra help rather than as a default baseline.",
|
||||
Some("Compatibility"),
|
||||
Category::WorkaroundsOpengl,
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use mangotune::config::types::Category;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"Performance",
|
||||
"Display",
|
||||
"Control frame pacing, sync behavior, and how aggressively MangoHud surfaces live FPS and frametime information.",
|
||||
&["pacing", "frametime", "latency feel"],
|
||||
);
|
||||
|
||||
tool_page::append_schema_category_section(
|
||||
&body,
|
||||
ctx,
|
||||
"Renderer controls",
|
||||
"Filtering, sync, and frame pacing knobs that can materially change how your game feels.",
|
||||
Some("Performance"),
|
||||
Category::Performance,
|
||||
);
|
||||
tool_page::append_schema_category_section_filtered(
|
||||
&body,
|
||||
ctx,
|
||||
"FPS and frametime readouts",
|
||||
"Decide whether the overlay should stay minimal or surface more granular pacing data while you tune. Visual styling lives in Colors and Theme.",
|
||||
Some("Overlay signal"),
|
||||
Category::DisplayFps,
|
||||
|entry| entry.key != "fps_color",
|
||||
);
|
||||
tool_page::append_schema_category_section(
|
||||
&body,
|
||||
ctx,
|
||||
"Runtime and renderer details",
|
||||
"Show API, refresh, display-server, and other session context when you need to verify how a game is actually running.",
|
||||
Some("Advanced"),
|
||||
Category::DisplayMisc,
|
||||
);
|
||||
tool_page::append_schema_category_section(
|
||||
&body,
|
||||
ctx,
|
||||
"Gamescope and upscaling",
|
||||
"Use these only when you are tuning Gamescope, FSR, HDR, or related presentation behavior. Leave them alone for a normal game-side HUD setup.",
|
||||
Some("Optional"),
|
||||
Category::DisplayGamescope,
|
||||
);
|
||||
|
||||
page
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use crate::ui::pages::{overview, PageBuildContext};
|
||||
use crate::ui::widgets::tool_page;
|
||||
use gtk4::prelude::*;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_start_page(
|
||||
"Presets",
|
||||
"Start",
|
||||
"Load one of your practical starting shapes, then fine-tune from the dashboard and deeper pages.",
|
||||
&["starter shapes", "quick launch", "overlay setup"],
|
||||
);
|
||||
|
||||
body.append(&overview::build_presets_panel(ctx));
|
||||
page
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::toast::show_toast;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use crate::window::{recompute_validation, refresh_save_button};
|
||||
use gtk4::prelude::*;
|
||||
use libadwaita::prelude::*;
|
||||
use mangotune::config::parser::Parser;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"Raw Editor",
|
||||
"Advanced",
|
||||
"Edit the full MangoHud config as plain text when you need absolute control, while still keeping MangoTune validation and save protection in the loop.",
|
||||
&["full config", "advanced", "validation-aware"],
|
||||
);
|
||||
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Power tool",
|
||||
"Raw edits bypass the guided controls",
|
||||
"Use this when you know exactly what you want to change or when MangoHud adds an option before MangoTune has a polished control for it. Validation still runs before save.",
|
||||
Some("tool-callout-warning"),
|
||||
);
|
||||
|
||||
let session_group = tool_page::append_custom_section(
|
||||
&body,
|
||||
"Editor session",
|
||||
"Open the dedicated raw text window, apply edits back into the in-memory config, then save normally once validation is clean.",
|
||||
Some("Manual mode"),
|
||||
);
|
||||
|
||||
let source_row = libadwaita::ActionRow::builder()
|
||||
.title("Current config source")
|
||||
.subtitle(current_config_path(ctx))
|
||||
.build();
|
||||
source_row.add_css_class("control-row");
|
||||
session_group.add(&source_row);
|
||||
|
||||
let stats_row = libadwaita::ActionRow::builder()
|
||||
.title("Current config snapshot")
|
||||
.subtitle(current_stats_label(ctx))
|
||||
.build();
|
||||
stats_row.add_css_class("control-row");
|
||||
session_group.add(&stats_row);
|
||||
|
||||
let launch_row = libadwaita::ActionRow::builder()
|
||||
.title("Open raw text editor")
|
||||
.subtitle("Launch a dedicated editor window with apply and reload actions")
|
||||
.build();
|
||||
launch_row.add_css_class("control-row");
|
||||
|
||||
let button_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let open_button = gtk4::Button::with_label("Open Editor");
|
||||
open_button.add_css_class("suggested-action");
|
||||
let reload_button = gtk4::Button::with_label("Refresh Snapshot");
|
||||
button_box.append(&reload_button);
|
||||
button_box.append(&open_button);
|
||||
|
||||
let ctx_reload = ctx.clone();
|
||||
let source_row_reload = source_row.clone();
|
||||
let stats_row_reload = stats_row.clone();
|
||||
reload_button.connect_clicked(move |_| {
|
||||
source_row_reload.set_subtitle(¤t_config_path(&ctx_reload));
|
||||
stats_row_reload.set_subtitle(¤t_stats_label(&ctx_reload));
|
||||
});
|
||||
|
||||
let ctx_open = ctx.clone();
|
||||
let source_row_open = source_row.clone();
|
||||
let stats_row_open = stats_row.clone();
|
||||
open_button.connect_clicked(move |_| {
|
||||
source_row_open.set_subtitle(¤t_config_path(&ctx_open));
|
||||
stats_row_open.set_subtitle(¤t_stats_label(&ctx_open));
|
||||
open_editor_window(&ctx_open);
|
||||
});
|
||||
|
||||
launch_row.add_suffix(&button_box);
|
||||
session_group.add(&launch_row);
|
||||
|
||||
let workflow_group = tool_page::append_custom_section(
|
||||
&body,
|
||||
"When raw editing helps",
|
||||
"Keep this lightweight: the guided controls should still be your default for most changes.",
|
||||
Some("Quick guidance"),
|
||||
);
|
||||
|
||||
let workflow_note = gtk4::Label::new(Some(
|
||||
"Use the raw editor when you want to paste a known-good MangoHud snippet, touch an uncommon option before MangoTune has a dedicated control for it, or make several text edits at once without jumping across pages.",
|
||||
));
|
||||
workflow_note.set_wrap(true);
|
||||
workflow_note.set_xalign(0.0);
|
||||
workflow_note.add_css_class("dim-label");
|
||||
workflow_group.add(&workflow_note);
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
fn open_editor_window(ctx: &PageBuildContext) {
|
||||
let window = gtk4::Window::builder()
|
||||
.title("MangoTune Raw Editor")
|
||||
.default_width(940)
|
||||
.default_height(680)
|
||||
.transient_for(&ctx.parent_window)
|
||||
.modal(false)
|
||||
.build();
|
||||
window.add_css_class("preferences-shell");
|
||||
|
||||
let (outer, body, footer_row) = tool_page::build_utility_window_shell(
|
||||
"Manual editing",
|
||||
"Raw Config Editor",
|
||||
"Apply updates back into MangoTune when you want the dashboard and page controls to reflect them. Reload discards unsaved raw edits and restores the current in-memory config.",
|
||||
);
|
||||
|
||||
let buffer = gtk4::TextBuffer::new(None::<>k4::TextTagTable>);
|
||||
if let Ok(state) = ctx.state.lock() {
|
||||
buffer.set_text(&Parser::to_string(&state.config));
|
||||
}
|
||||
|
||||
let text_view = gtk4::TextView::builder()
|
||||
.monospace(true)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.buffer(&buffer)
|
||||
.top_margin(12)
|
||||
.bottom_margin(12)
|
||||
.left_margin(12)
|
||||
.right_margin(12)
|
||||
.build();
|
||||
|
||||
let scrolled = gtk4::ScrolledWindow::builder()
|
||||
.child(&text_view)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
scrolled.add_css_class("utility-window-scroller");
|
||||
|
||||
let footer = gtk4::Label::new(Some("Line count: 0 | Option count: 0"));
|
||||
footer.add_css_class("dim-label");
|
||||
footer.set_xalign(0.0);
|
||||
|
||||
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
spacer.set_hexpand(true);
|
||||
let reload = gtk4::Button::with_label("Reload");
|
||||
let apply = gtk4::Button::with_label("Apply to Workspace");
|
||||
apply.add_css_class("suggested-action");
|
||||
footer_row.append(&spacer);
|
||||
footer_row.append(&reload);
|
||||
footer_row.append(&apply);
|
||||
|
||||
let ctx_apply = ctx.clone();
|
||||
let buffer_apply = buffer.clone();
|
||||
let footer_apply = footer.clone();
|
||||
apply.connect_clicked(move |_| {
|
||||
let start = buffer_apply.start_iter();
|
||||
let end = buffer_apply.end_iter();
|
||||
let text = buffer_apply.text(&start, &end, false).to_string();
|
||||
|
||||
if let Ok(mut state) = ctx_apply.state.lock() {
|
||||
let parsed = Parser::parse_str(&text, state.config.path.clone());
|
||||
state.config = parsed;
|
||||
state.config.dirty = true;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
update_footer(&footer_apply, &text);
|
||||
recompute_validation(&ctx_apply.state);
|
||||
refresh_save_button(&ctx_apply.state, &ctx_apply.save_button);
|
||||
show_toast(
|
||||
&ctx_apply.toast_overlay,
|
||||
"Applied raw text changes to the workspace",
|
||||
);
|
||||
});
|
||||
|
||||
let ctx_reload = ctx.clone();
|
||||
let buffer_reload = buffer.clone();
|
||||
let footer_reload = footer.clone();
|
||||
reload.connect_clicked(move |_| {
|
||||
if let Ok(state) = ctx_reload.state.lock() {
|
||||
let text = Parser::to_string(&state.config);
|
||||
buffer_reload.set_text(&text);
|
||||
update_footer(&footer_reload, &text);
|
||||
}
|
||||
});
|
||||
|
||||
let initial = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false);
|
||||
update_footer(&footer, &initial);
|
||||
|
||||
body.append(&scrolled);
|
||||
body.append(&footer);
|
||||
window.set_child(Some(&outer));
|
||||
window.present();
|
||||
}
|
||||
|
||||
fn current_config_path(ctx: &PageBuildContext) -> String {
|
||||
ctx.state
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|state| {
|
||||
state
|
||||
.config
|
||||
.path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "Unsaved session".to_string())
|
||||
}
|
||||
|
||||
fn current_stats_label(ctx: &PageBuildContext) -> String {
|
||||
ctx.state
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|state| {
|
||||
let text = Parser::to_string(&state.config);
|
||||
let (line_count, option_count) = count_text_stats(&text);
|
||||
format!("{line_count} lines | {option_count} active option rows")
|
||||
})
|
||||
.unwrap_or_else(|| "Unavailable".to_string())
|
||||
}
|
||||
|
||||
fn update_footer(footer: >k4::Label, text: &str) {
|
||||
let (line_count, option_count) = count_text_stats(text);
|
||||
footer.set_text(&format!(
|
||||
"Line count: {line_count} | Option count: {option_count}"
|
||||
));
|
||||
}
|
||||
|
||||
fn count_text_stats(text: &str) -> (usize, usize) {
|
||||
let line_count = text.lines().count();
|
||||
let option_count = text
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty() && !trimmed.starts_with('#')
|
||||
})
|
||||
.count();
|
||||
(line_count, option_count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::count_text_stats;
|
||||
|
||||
#[test]
|
||||
fn counts_only_active_option_lines() {
|
||||
let text = "# comment\nfps\n\nposition=top-left\n";
|
||||
assert_eq!(count_text_stats(text), (4, 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use crate::ui::pages::{search_results_for_query, PageBuildContext, SearchResultGroup};
|
||||
use crate::ui::widgets::tool_page;
|
||||
use gio::prelude::*;
|
||||
use gtk4::prelude::*;
|
||||
use libadwaita::prelude::*;
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let query = ctx.current_search_query.borrow().clone();
|
||||
let groups = search_results_for_query(&query);
|
||||
let chips = if groups.is_empty() {
|
||||
vec!["no exact page yet"]
|
||||
} else {
|
||||
vec!["live results", "grouped by page", "click to jump"]
|
||||
};
|
||||
let (page, body) = tool_page::build_start_page(
|
||||
"Search Results",
|
||||
"Search",
|
||||
"Find exact MangoHud controls by title, description, or raw key. Click a result to open its page and jump to the row.",
|
||||
&chips,
|
||||
);
|
||||
|
||||
if query.trim().is_empty() {
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"Search",
|
||||
"Start typing in the sidebar",
|
||||
"Results appear here as you type. Click any result to open its page and jump directly to that control.",
|
||||
None,
|
||||
);
|
||||
return page;
|
||||
}
|
||||
|
||||
if groups.is_empty() {
|
||||
tool_page::append_callout(
|
||||
&body,
|
||||
"No matches",
|
||||
"No controls matched that search",
|
||||
"Try a raw MangoHud key like fps_color_change, or broader terms like font, opacity, battery, or frametime.",
|
||||
Some("tool-callout-warning"),
|
||||
);
|
||||
return page;
|
||||
}
|
||||
|
||||
for group in groups {
|
||||
append_group(&body, ctx, group);
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
fn append_group(body: >k4::Box, ctx: &PageBuildContext, group: SearchResultGroup) {
|
||||
let title = format!("{} ({})", group.page_title, group.page_section);
|
||||
let description = format!(
|
||||
"{} matching control{} on this page.",
|
||||
group.results.len(),
|
||||
if group.results.len() == 1 { "" } else { "s" }
|
||||
);
|
||||
let section =
|
||||
tool_page::append_custom_section(body, &title, &description, Some("Search results"));
|
||||
|
||||
for result in group.results {
|
||||
let subtitle = if result.summary.trim().is_empty() {
|
||||
format!("MangoHud key: {}", result.key)
|
||||
} else {
|
||||
format!("{} MangoHud key: {}.", result.summary, result.key)
|
||||
};
|
||||
let row = libadwaita::ActionRow::builder()
|
||||
.title(&result.title)
|
||||
.subtitle(&subtitle)
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_css_class("control-row");
|
||||
|
||||
let key_label = gtk4::Label::new(Some(result.key));
|
||||
key_label.add_css_class("tool-chip");
|
||||
key_label.set_valign(gtk4::Align::Center);
|
||||
row.add_suffix(&key_label);
|
||||
|
||||
let page_id = group.page_id.to_string();
|
||||
let key = result.key.to_string();
|
||||
let window = ctx.parent_window.clone();
|
||||
let pending = ctx.pending_search_target.clone();
|
||||
row.connect_activated(move |_| {
|
||||
*pending.borrow_mut() = Some(key.clone());
|
||||
let _ = gtk4::prelude::WidgetExt::activate_action(
|
||||
&window,
|
||||
"win.navigate-page",
|
||||
Some(&page_id.to_variant()),
|
||||
);
|
||||
});
|
||||
|
||||
section.add(&row);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use crate::ui::pages::{refresh_live_preview_for_key, register_option_row, PageBuildContext};
|
||||
use crate::ui::widgets::{toggle_row, tool_page};
|
||||
use crate::window::{recompute_validation, refresh_save_button};
|
||||
use gtk4::prelude::*;
|
||||
use libadwaita::prelude::*;
|
||||
use mangotune::config::parser::Parser;
|
||||
use mangotune::config::schema::get_schema_entry;
|
||||
use mangotune::config::types::ConfigValue;
|
||||
use std::collections::BTreeMap;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FontChoice {
|
||||
family: String,
|
||||
label: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow {
|
||||
let fonts = discover_fonts();
|
||||
let (page, body) = tool_page::build_tool_page(
|
||||
"Typography",
|
||||
"Appearance",
|
||||
"Make the overlay readable at a glance, whether you want a subtle competitive HUD or a chunkier streaming layout.",
|
||||
&["font scale", "custom font files", "glyph support"],
|
||||
);
|
||||
|
||||
let scaling_group = tool_page::append_custom_section(
|
||||
&body,
|
||||
"Scale and density",
|
||||
"Control the global size, text scale, and compactness of MangoHud typography before you dig into font files.",
|
||||
Some("Fastest win"),
|
||||
);
|
||||
|
||||
for key in [
|
||||
"font_size",
|
||||
"font_size_secondary",
|
||||
"font_scale",
|
||||
"font_size_text",
|
||||
"font_scale_media_player",
|
||||
"no_small_font",
|
||||
] {
|
||||
if let Some(schema) = get_schema_entry(key) {
|
||||
toggle_row::add_schema_row(&scaling_group, schema, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
let font_file_group = tool_page::append_custom_section(
|
||||
&body,
|
||||
"Font files",
|
||||
"Pick installed system fonts for the main HUD and free-text widgets. Leaving this on system default avoids hard-coding a font path.",
|
||||
Some("Optional"),
|
||||
);
|
||||
|
||||
font_file_group.add(&build_font_file_row(
|
||||
"Primary Font",
|
||||
"font_file",
|
||||
"Font used for the main HUD labels and values",
|
||||
&fonts,
|
||||
ctx,
|
||||
));
|
||||
font_file_group.add(&build_font_file_row(
|
||||
"Text Font",
|
||||
"font_file_text",
|
||||
"Font used for free-text widgets and custom text",
|
||||
&fonts,
|
||||
ctx,
|
||||
));
|
||||
|
||||
let glyph_group = tool_page::append_custom_section(
|
||||
&body,
|
||||
"Glyph ranges",
|
||||
"Turn on extra language/script packs only when you need them so the HUD stays light and predictable.",
|
||||
Some("Localization"),
|
||||
);
|
||||
if let Some(schema) = get_schema_entry("font_glyph_ranges") {
|
||||
toggle_row::add_schema_row(&glyph_group, schema, ctx);
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
fn build_font_file_row(
|
||||
title: &str,
|
||||
key: &str,
|
||||
subtitle: &str,
|
||||
fonts: &[FontChoice],
|
||||
ctx: &PageBuildContext,
|
||||
) -> libadwaita::ActionRow {
|
||||
let row = libadwaita::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.build();
|
||||
row.add_css_class("control-row");
|
||||
register_option_row(ctx, key, &row);
|
||||
row.set_title_lines(1);
|
||||
row.set_subtitle_lines(2);
|
||||
row.set_tooltip_text(Some(&format!(
|
||||
"{subtitle}\nMangoHud key: {key}\nUse 'System default' to disable the explicit path"
|
||||
)));
|
||||
|
||||
let mut labels = vec!["System default".to_string()];
|
||||
let mut values: Vec<Option<String>> = vec![None];
|
||||
for font in fonts {
|
||||
labels.push(font.label.clone());
|
||||
values.push(Some(font.path.clone()));
|
||||
}
|
||||
|
||||
let label_refs = labels.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let dropdown = gtk4::DropDown::from_strings(&label_refs);
|
||||
dropdown.add_css_class("control-field");
|
||||
dropdown.set_size_request(320, -1);
|
||||
dropdown.set_valign(gtk4::Align::Center);
|
||||
|
||||
let current = current_string_value(ctx, key);
|
||||
if let Some(current_path) = current.as_ref() {
|
||||
if let Some(idx) = values
|
||||
.iter()
|
||||
.position(|candidate| candidate.as_deref() == Some(current_path.as_str()))
|
||||
{
|
||||
dropdown.set_selected(idx as u32);
|
||||
} else {
|
||||
let fallback = std::path::Path::new(current_path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(current_path);
|
||||
labels.push(format!("Current: {fallback}"));
|
||||
values.push(Some(current_path.clone()));
|
||||
let updated =
|
||||
gtk4::StringList::new(&labels.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
dropdown.set_model(Some(&updated));
|
||||
dropdown.set_selected((values.len() - 1) as u32);
|
||||
}
|
||||
}
|
||||
|
||||
let browse_button = gtk4::Button::with_label("Browse");
|
||||
browse_button.add_css_class("control-button");
|
||||
|
||||
let key_owned = key.to_string();
|
||||
let ctx_clone = ctx.clone();
|
||||
let values_for_change = values.clone();
|
||||
dropdown.connect_selected_notify(move |combo| {
|
||||
let idx = combo.selected() as usize;
|
||||
let selected = values_for_change.get(idx).cloned().flatten();
|
||||
apply_font_value(&ctx_clone, &key_owned, selected);
|
||||
});
|
||||
|
||||
let key_owned = key.to_string();
|
||||
let ctx_clone = ctx.clone();
|
||||
browse_button.connect_clicked(move |_| {
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Select font file")
|
||||
.modal(true)
|
||||
.build();
|
||||
|
||||
let filters = gio::ListStore::new::<gtk4::FileFilter>();
|
||||
let filter = gtk4::FileFilter::new();
|
||||
filter.add_pattern("*.ttf");
|
||||
filter.add_pattern("*.otf");
|
||||
filter.set_name(Some("Font files (*.ttf, *.otf)"));
|
||||
filters.append(&filter);
|
||||
dialog.set_filters(Some(&filters));
|
||||
dialog.set_default_filter(Some(&filter));
|
||||
|
||||
let key_for_resp = key_owned.clone();
|
||||
let ctx_for_resp = ctx_clone.clone();
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
if let Ok(file) = dialog.open_future(Some(&ctx_for_resp.parent_window)).await {
|
||||
if let Some(path) = file.path() {
|
||||
apply_font_value(
|
||||
&ctx_for_resp,
|
||||
&key_for_resp,
|
||||
Some(path.display().to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
row.add_suffix(&dropdown);
|
||||
row.add_suffix(&browse_button);
|
||||
row
|
||||
}
|
||||
|
||||
fn apply_font_value(ctx: &PageBuildContext, key: &str, selected_path: Option<String>) {
|
||||
if let Ok(mut state) = ctx.state.lock() {
|
||||
match selected_path {
|
||||
Some(path) if !path.trim().is_empty() => {
|
||||
Parser::set_value(&mut state.config, key, ConfigValue::Value(path));
|
||||
}
|
||||
_ => {
|
||||
Parser::set_value(&mut state.config, key, ConfigValue::Disabled);
|
||||
}
|
||||
}
|
||||
state.dirty = state.config.dirty;
|
||||
}
|
||||
|
||||
recompute_validation(&ctx.state);
|
||||
refresh_save_button(&ctx.state, &ctx.save_button);
|
||||
refresh_live_preview_for_key(ctx, Some(key));
|
||||
}
|
||||
|
||||
fn current_string_value(ctx: &PageBuildContext, key: &str) -> Option<String> {
|
||||
let Ok(state) = ctx.state.lock() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
state
|
||||
.config
|
||||
.options
|
||||
.get(key)
|
||||
.and_then(|(_, value)| match value {
|
||||
ConfigValue::Value(value) => Some(value.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn discover_fonts() -> Vec<FontChoice> {
|
||||
let output = Command::new("fc-list")
|
||||
.arg("--format")
|
||||
.arg("%{family}\t%{file}\n")
|
||||
.output();
|
||||
|
||||
let Ok(output) = output else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut by_path: BTreeMap<String, String> = BTreeMap::new();
|
||||
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
||||
let Some((family, path)) = line.split_once('\t') else {
|
||||
continue;
|
||||
};
|
||||
let family = family.split(',').next().unwrap_or(family).trim();
|
||||
let path = path.trim();
|
||||
if family.is_empty() || path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
by_path
|
||||
.entry(path.to_string())
|
||||
.or_insert_with(|| family.to_string());
|
||||
}
|
||||
|
||||
let mut family_counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for family in by_path.values() {
|
||||
*family_counts.entry(family.clone()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut fonts = by_path
|
||||
.into_iter()
|
||||
.map(|(path, family)| FontChoice {
|
||||
label: if family_counts.get(&family).copied().unwrap_or(0) > 1 {
|
||||
format!(
|
||||
"{family} ({})",
|
||||
std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("font")
|
||||
)
|
||||
} else {
|
||||
family.clone()
|
||||
},
|
||||
family,
|
||||
path,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
fonts.sort_by(|a, b| a.family.cmp(&b.family).then_with(|| a.label.cmp(&b.label)));
|
||||
fonts
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
thread_local! {
|
||||
static LAST_TOAST: RefCell<Option<libadwaita::Toast>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn show_toast(overlay: &libadwaita::ToastOverlay, message: &str) {
|
||||
LAST_TOAST.with(|last| {
|
||||
if let Some(previous) = last.borrow_mut().take() {
|
||||
previous.dismiss();
|
||||
}
|
||||
});
|
||||
|
||||
let toast = libadwaita::Toast::new(message);
|
||||
let lower = message.to_ascii_lowercase();
|
||||
let is_problem = lower.contains("failed")
|
||||
|| lower.contains("error")
|
||||
|| lower.contains("warning")
|
||||
|| lower.contains("could not")
|
||||
|| lower.contains("invalid");
|
||||
|
||||
toast.set_timeout(if is_problem { 4 } else { 2 });
|
||||
if is_problem {
|
||||
toast.set_priority(libadwaita::ToastPriority::High);
|
||||
}
|
||||
|
||||
overlay.add_toast(toast.clone());
|
||||
LAST_TOAST.with(|last| {
|
||||
last.replace(Some(toast));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use gtk4::prelude::*;
|
||||
use libadwaita::prelude::*;
|
||||
use mangotune::config::resolver::LayerSource;
|
||||
|
||||
pub struct CascadeViewModel {
|
||||
pub layers: Vec<LayerViewModel>,
|
||||
pub filter: CascadeFilter,
|
||||
}
|
||||
|
||||
pub struct LayerViewModel {
|
||||
pub source: LayerSource,
|
||||
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>,
|
||||
}
|
||||
|
||||
pub enum OptionState {
|
||||
Effective,
|
||||
Shadowed,
|
||||
Winning,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CascadeFilter {
|
||||
All,
|
||||
ConflictsOnly,
|
||||
ShadowedOnly,
|
||||
}
|
||||
|
||||
pub fn build_cascade_view(model: CascadeViewModel) -> gtk4::Widget {
|
||||
let scrolled = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.min_content_height(420)
|
||||
.build();
|
||||
scrolled.add_css_class("cascade-scroller");
|
||||
|
||||
let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
outer.set_margin_top(12);
|
||||
outer.set_margin_bottom(12);
|
||||
outer.set_margin_start(12);
|
||||
outer.set_margin_end(12);
|
||||
|
||||
for layer in model.layers {
|
||||
let filtered_options = filter_options(layer.options, model.filter);
|
||||
if filtered_options.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let group = libadwaita::PreferencesGroup::new();
|
||||
group.set_title(&layer.label);
|
||||
let (badge, css_class) = source_badge(&layer.source);
|
||||
let mode = if layer.is_editable {
|
||||
"Editable layer"
|
||||
} else {
|
||||
"Read-only layer"
|
||||
};
|
||||
group.set_description(Some(&format!("{mode} • {badge}")));
|
||||
|
||||
for item in filtered_options {
|
||||
let row = libadwaita::ActionRow::builder()
|
||||
.title(&item.key)
|
||||
.subtitle(&item.value)
|
||||
.build();
|
||||
|
||||
match item.state {
|
||||
OptionState::Shadowed => {
|
||||
row.add_css_class("option-shadowed");
|
||||
if let Some(overridden_by) = item.overridden_by {
|
||||
let label =
|
||||
gtk4::Label::new(Some(&format!("overridden by {overridden_by}")));
|
||||
label.add_css_class("dim-label");
|
||||
row.add_suffix(&label);
|
||||
}
|
||||
}
|
||||
OptionState::Winning => {
|
||||
let icon = gtk4::Image::from_icon_name("emblem-ok-symbolic");
|
||||
row.add_suffix(&icon);
|
||||
}
|
||||
OptionState::Effective => {}
|
||||
}
|
||||
|
||||
let badge_label = gtk4::Label::new(Some(badge));
|
||||
badge_label.add_css_class(css_class);
|
||||
row.add_prefix(&badge_label);
|
||||
|
||||
group.add(&row);
|
||||
}
|
||||
|
||||
outer.append(&group);
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&outer));
|
||||
scrolled.upcast()
|
||||
}
|
||||
|
||||
pub fn has_visible_options(model: &CascadeViewModel) -> bool {
|
||||
model.layers.iter().any(|layer| {
|
||||
layer.options.iter().any(|item| match model.filter {
|
||||
CascadeFilter::All => true,
|
||||
CascadeFilter::ConflictsOnly => item.overridden_by.is_some(),
|
||||
CascadeFilter::ShadowedOnly => matches!(item.state, OptionState::Shadowed),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn filter_options(options: Vec<OptionViewModel>, filter: CascadeFilter) -> Vec<OptionViewModel> {
|
||||
options
|
||||
.into_iter()
|
||||
.filter(|item| match filter {
|
||||
CascadeFilter::All => true,
|
||||
CascadeFilter::ConflictsOnly => item.overridden_by.is_some(),
|
||||
CascadeFilter::ShadowedOnly => matches!(item.state, OptionState::Shadowed),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_badge(source: &LayerSource) -> (&'static str, &'static str) {
|
||||
match source {
|
||||
LayerSource::CompiledDefault => ("DEFAULT", "layer-badge-global"),
|
||||
LayerSource::GlobalXdg => ("GLOBAL", "layer-badge-global"),
|
||||
LayerSource::PerAppXdg(_) => ("PER-APP", "layer-badge-perapp"),
|
||||
LayerSource::AppLocal(_) => ("APP-LOCAL", "layer-badge-perapp"),
|
||||
LayerSource::EnvFile(_) | LayerSource::EnvInline(_) => ("ENV", "layer-badge-env"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn visibility_helper_respects_filter() {
|
||||
let model = CascadeViewModel {
|
||||
layers: vec![LayerViewModel {
|
||||
source: LayerSource::GlobalXdg,
|
||||
label: "Global".to_string(),
|
||||
is_editable: true,
|
||||
options: vec![OptionViewModel {
|
||||
key: "fps".to_string(),
|
||||
value: "enabled".to_string(),
|
||||
state: OptionState::Effective,
|
||||
overridden_by: None,
|
||||
}],
|
||||
}],
|
||||
filter: CascadeFilter::ConflictsOnly,
|
||||
};
|
||||
|
||||
assert!(!has_visible_options(&model));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
use crate::ui::pages::{
|
||||
refresh_live_preview_for_key, refresh_registered_validation_rows, register_option_row,
|
||||
register_validation_row, PageBuildContext,
|
||||
};
|
||||
use crate::ui::widgets::color_utils::{hex_to_rgba, rgba_to_hex};
|
||||
use crate::ui::widgets::validation_label;
|
||||
use crate::window::{recompute_validation, refresh_save_button};
|
||||
use gtk4::prelude::*;
|
||||
use libadwaita::prelude::*;
|
||||
use mangotune::config::parser::Parser;
|
||||
use mangotune::config::schema::get_schema_entry;
|
||||
use mangotune::config::types::{ConfigValue, ValidationResult};
|
||||
use mangotune::config::validator;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
const UNSET_SWATCH_CLASS: &str = "color-swatch-unset";
|
||||
|
||||
pub fn build_color_row(
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
key: &str,
|
||||
ctx: &PageBuildContext,
|
||||
) -> libadwaita::ActionRow {
|
||||
let row = libadwaita::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.build();
|
||||
row.add_css_class("control-row");
|
||||
register_option_row(ctx, key, &row);
|
||||
register_validation_row(ctx, key, &row, subtitle);
|
||||
|
||||
let entry = gtk4::Entry::new();
|
||||
entry.add_css_class("control-field");
|
||||
entry.add_css_class("stacked-color-entry");
|
||||
entry.set_max_length(6);
|
||||
entry.set_width_chars(6);
|
||||
entry.set_max_width_chars(6);
|
||||
entry.set_size_request(50, -1);
|
||||
entry.set_placeholder_text(Some("RRGGBB"));
|
||||
|
||||
let color_dialog = gtk4::ColorDialog::builder()
|
||||
.title("Choose Color")
|
||||
.modal(true)
|
||||
.build();
|
||||
let swatch = gtk4::ColorDialogButton::new(Some(color_dialog));
|
||||
swatch.add_css_class("color-swatch-button");
|
||||
swatch.add_css_class("control-button");
|
||||
swatch.add_css_class("stacked-color-swatch");
|
||||
swatch.set_size_request(50, 14);
|
||||
apply_single_swatch_state(&swatch, None);
|
||||
|
||||
if let Some(text) = current_value(ctx, key) {
|
||||
entry.set_text(&text);
|
||||
apply_single_swatch_state(&swatch, Some(&text));
|
||||
}
|
||||
|
||||
let editor_box = gtk4::Box::new(gtk4::Orientation::Vertical, 3);
|
||||
editor_box.add_css_class("stacked-color-editor");
|
||||
editor_box.set_halign(gtk4::Align::End);
|
||||
editor_box.append(&entry);
|
||||
editor_box.append(&swatch);
|
||||
row.add_suffix(&editor_box);
|
||||
let syncing = Rc::new(Cell::new(false));
|
||||
|
||||
let key_owned = key.to_string();
|
||||
let subtitle_owned = subtitle.to_string();
|
||||
let ctx_clone = ctx.clone();
|
||||
let row_clone = row.clone();
|
||||
let swatch_clone = swatch.clone();
|
||||
let pending_preview_refresh = Rc::new(Cell::new(false));
|
||||
let pending_refresh_for_change = pending_preview_refresh.clone();
|
||||
let syncing_for_entry = syncing.clone();
|
||||
entry.connect_changed(move |input| {
|
||||
if syncing_for_entry.get() {
|
||||
return;
|
||||
}
|
||||
let text = input.text().to_string().to_ascii_uppercase();
|
||||
apply_value(&ctx_clone, &key_owned, text.clone());
|
||||
|
||||
let validation = validate_key(&ctx_clone, &key_owned, &text);
|
||||
validation_label::set_action_row_error(
|
||||
row_clone.upcast_ref(),
|
||||
&subtitle_owned,
|
||||
validation_error_text(&validation),
|
||||
);
|
||||
|
||||
if validation_error_text(&validation).is_none() {
|
||||
syncing_for_entry.set(true);
|
||||
apply_single_swatch_state(&swatch_clone, Some(&text));
|
||||
syncing_for_entry.set(false);
|
||||
} else {
|
||||
syncing_for_entry.set(true);
|
||||
apply_single_swatch_state(&swatch_clone, None);
|
||||
syncing_for_entry.set(false);
|
||||
}
|
||||
|
||||
recompute_validation(&ctx_clone.state);
|
||||
refresh_registered_validation_rows(&ctx_clone);
|
||||
refresh_save_button(&ctx_clone.state, &ctx_clone.save_button);
|
||||
pending_refresh_for_change.set(true);
|
||||
});
|
||||
|
||||
connect_entry_preview_commit(&entry, ctx, key, pending_preview_refresh);
|
||||
|
||||
let entry_for_swatch = entry.clone();
|
||||
let key_for_swatch = key.to_string();
|
||||
let ctx_for_swatch = ctx.clone();
|
||||
let row_for_swatch = row.clone();
|
||||
let subtitle_for_swatch = subtitle.to_string();
|
||||
let syncing_for_swatch = syncing.clone();
|
||||
swatch.connect_rgba_notify(move |button| {
|
||||
if syncing_for_swatch.get() {
|
||||
return;
|
||||
}
|
||||
let hex = rgba_to_hex(&button.rgba());
|
||||
syncing_for_swatch.set(true);
|
||||
entry_for_swatch.set_text(&hex);
|
||||
syncing_for_swatch.set(false);
|
||||
apply_value(&ctx_for_swatch, &key_for_swatch, hex.clone());
|
||||
|
||||
let validation = validate_key(&ctx_for_swatch, &key_for_swatch, &hex);
|
||||
validation_label::set_action_row_error(
|
||||
row_for_swatch.upcast_ref(),
|
||||
&subtitle_for_swatch,
|
||||
validation_error_text(&validation),
|
||||
);
|
||||
|
||||
recompute_validation(&ctx_for_swatch.state);
|
||||
refresh_registered_validation_rows(&ctx_for_swatch);
|
||||
refresh_save_button(&ctx_for_swatch.state, &ctx_for_swatch.save_button);
|
||||
refresh_live_preview_for_key(&ctx_for_swatch, Some(&key_for_swatch));
|
||||
});
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
pub fn build_color_list_row(
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
key: &str,
|
||||
ctx: &PageBuildContext,
|
||||
) -> libadwaita::ActionRow {
|
||||
let row = libadwaita::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.build();
|
||||
row.add_css_class("control-row");
|
||||
register_option_row(ctx, key, &row);
|
||||
register_validation_row(ctx, key, &row, subtitle);
|
||||
|
||||
let editor_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
editor_box.add_css_class("color-list-editor");
|
||||
editor_box.set_halign(gtk4::Align::End);
|
||||
let entries: Rc<Vec<gtk4::Entry>> = Rc::new(
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let entry = gtk4::Entry::new();
|
||||
entry.add_css_class("control-field");
|
||||
entry.add_css_class("color-list-cell-entry");
|
||||
entry.set_max_length(6);
|
||||
entry.set_width_chars(6);
|
||||
entry.set_max_width_chars(6);
|
||||
entry.set_size_request(50, -1);
|
||||
entry.set_placeholder_text(Some("RRGGBB"));
|
||||
entry
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let swatches: Rc<Vec<gtk4::ColorDialogButton>> = Rc::new(
|
||||
(0..3)
|
||||
.map(|index| {
|
||||
let dialog = gtk4::ColorDialog::builder()
|
||||
.title("Choose Color")
|
||||
.modal(true)
|
||||
.build();
|
||||
let swatch = gtk4::ColorDialogButton::new(Some(dialog));
|
||||
swatch.add_css_class("color-swatch-button");
|
||||
swatch.add_css_class("control-button");
|
||||
swatch.add_css_class("color-list-swatch");
|
||||
swatch.set_size_request(50, 14);
|
||||
swatch.set_tooltip_text(Some(&format!("Palette color {}", index + 1)));
|
||||
swatch
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
sync_color_list_editors(&entries, &swatches, "");
|
||||
let syncing = Rc::new(Cell::new(false));
|
||||
|
||||
if let Some(text) = current_value(ctx, key) {
|
||||
sync_color_list_editors(&entries, &swatches, &text);
|
||||
}
|
||||
|
||||
for index in 0..3 {
|
||||
let cell = gtk4::Box::new(gtk4::Orientation::Vertical, 3);
|
||||
cell.add_css_class("color-list-column");
|
||||
cell.append(&entries[index]);
|
||||
cell.append(&swatches[index]);
|
||||
editor_box.append(&cell);
|
||||
}
|
||||
row.add_suffix(&editor_box);
|
||||
|
||||
let key_owned = key.to_string();
|
||||
let subtitle_owned = subtitle.to_string();
|
||||
let ctx_clone = ctx.clone();
|
||||
let row_clone = row.clone();
|
||||
let entries_clone = entries.clone();
|
||||
let swatches_clone = swatches.clone();
|
||||
let pending_preview_refresh = Rc::new(Cell::new(false));
|
||||
for entry in entries.iter() {
|
||||
let key_owned = key_owned.clone();
|
||||
let subtitle_owned = subtitle_owned.clone();
|
||||
let ctx_clone = ctx_clone.clone();
|
||||
let row_clone = row_clone.clone();
|
||||
let entries_clone = entries_clone.clone();
|
||||
let swatches_clone = swatches_clone.clone();
|
||||
let pending_refresh_for_change = pending_preview_refresh.clone();
|
||||
let syncing_for_entry = syncing.clone();
|
||||
entry.connect_changed(move |_| {
|
||||
if syncing_for_entry.get() {
|
||||
return;
|
||||
}
|
||||
let text = compose_color_list_value(&entries_clone);
|
||||
apply_value(&ctx_clone, &key_owned, text.clone());
|
||||
|
||||
let validation = validate_key(&ctx_clone, &key_owned, &text);
|
||||
validation_label::set_action_row_error(
|
||||
row_clone.upcast_ref(),
|
||||
&subtitle_owned,
|
||||
validation_error_text(&validation),
|
||||
);
|
||||
|
||||
syncing_for_entry.set(true);
|
||||
sync_color_list_swatches(&swatches_clone, &text);
|
||||
syncing_for_entry.set(false);
|
||||
|
||||
recompute_validation(&ctx_clone.state);
|
||||
refresh_registered_validation_rows(&ctx_clone);
|
||||
refresh_save_button(&ctx_clone.state, &ctx_clone.save_button);
|
||||
pending_refresh_for_change.set(true);
|
||||
});
|
||||
}
|
||||
connect_group_entry_preview_commit(entries.as_ref(), ctx, key, pending_preview_refresh);
|
||||
|
||||
for (index, swatch) in swatches.iter().enumerate() {
|
||||
let entries_for_swatch = entries.clone();
|
||||
let key_for_swatch = key.to_string();
|
||||
let ctx_for_swatch = ctx.clone();
|
||||
let row_for_swatch = row.clone();
|
||||
let subtitle_for_swatch = subtitle.to_string();
|
||||
let syncing_for_swatch = syncing.clone();
|
||||
swatch.connect_rgba_notify(move |button| {
|
||||
if syncing_for_swatch.get() {
|
||||
return;
|
||||
}
|
||||
let hex = rgba_to_hex(&button.rgba());
|
||||
let mut parts = collect_color_list_parts(&entries_for_swatch);
|
||||
while parts.len() <= index {
|
||||
parts.push("FFFFFF".to_string());
|
||||
}
|
||||
parts[index] = hex;
|
||||
syncing_for_swatch.set(true);
|
||||
sync_color_list_entries(&entries_for_swatch, &parts);
|
||||
syncing_for_swatch.set(false);
|
||||
let value = parts.join(",");
|
||||
apply_value(&ctx_for_swatch, &key_for_swatch, value.clone());
|
||||
|
||||
let validation = validate_key(&ctx_for_swatch, &key_for_swatch, &value);
|
||||
validation_label::set_action_row_error(
|
||||
row_for_swatch.upcast_ref(),
|
||||
&subtitle_for_swatch,
|
||||
validation_error_text(&validation),
|
||||
);
|
||||
|
||||
recompute_validation(&ctx_for_swatch.state);
|
||||
refresh_registered_validation_rows(&ctx_for_swatch);
|
||||
refresh_save_button(&ctx_for_swatch.state, &ctx_for_swatch.save_button);
|
||||
refresh_live_preview_for_key(&ctx_for_swatch, Some(&key_for_swatch));
|
||||
});
|
||||
}
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
fn current_value(ctx: &PageBuildContext, key: &str) -> Option<String> {
|
||||
let Ok(state) = ctx.state.lock() else {
|
||||
return None;
|
||||
};
|
||||
state
|
||||
.config
|
||||
.options
|
||||
.get(key)
|
||||
.and_then(|(_, value)| match value {
|
||||
ConfigValue::Value(value) => Some(value.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_value(ctx: &PageBuildContext, key: &str, value: String) {
|
||||
let Ok(mut state) = ctx.state.lock() else {
|
||||
return;
|
||||
};
|
||||
Parser::set_value(&mut state.config, key, ConfigValue::Value(value));
|
||||
state.dirty = state.config.dirty;
|
||||
}
|
||||
|
||||
fn validate_key(ctx: &PageBuildContext, key: &str, value: &str) -> ValidationResult {
|
||||
let Some(schema) = get_schema_entry(key) else {
|
||||
return ValidationResult::Ok;
|
||||
};
|
||||
let result = validator::validate_value(key, &ConfigValue::Value(value.to_string()), schema);
|
||||
|
||||
if let Ok(mut state) = ctx.state.lock() {
|
||||
if matches!(result, ValidationResult::Ok) {
|
||||
state.validation.remove(key);
|
||||
} else {
|
||||
state.validation.insert(key.to_string(), result.clone());
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn validation_error_text(validation: &ValidationResult) -> Option<&str> {
|
||||
match validation {
|
||||
ValidationResult::Ok => None,
|
||||
ValidationResult::Warning(message) | ValidationResult::Error(message) => {
|
||||
Some(message.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn split_color_list(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| part.to_ascii_uppercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_color_list_parts(entries: &[gtk4::Entry]) -> Vec<String> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| entry.text().to_string())
|
||||
.map(|value| value.trim().to_ascii_uppercase())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn compose_color_list_value(entries: &[gtk4::Entry]) -> String {
|
||||
collect_color_list_parts(entries).join(",")
|
||||
}
|
||||
|
||||
fn sync_color_list_entries(entries: &[gtk4::Entry], parts: &[String]) {
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
entry.set_text(parts.get(index).map(String::as_str).unwrap_or(""));
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_color_list_editors(
|
||||
entries: &[gtk4::Entry],
|
||||
buttons: &[gtk4::ColorDialogButton],
|
||||
value: &str,
|
||||
) {
|
||||
let parts = split_color_list(value);
|
||||
sync_color_list_entries(entries, &parts);
|
||||
sync_color_list_swatches(buttons, value);
|
||||
}
|
||||
|
||||
fn sync_color_list_swatches(buttons: &[gtk4::ColorDialogButton], value: &str) {
|
||||
let parts = split_color_list(value);
|
||||
for (index, button) in buttons.iter().enumerate() {
|
||||
let maybe_hex = parts.get(index).map(String::as_str);
|
||||
apply_single_swatch_state(button, maybe_hex);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_single_swatch_state(button: >k4::ColorDialogButton, value: Option<&str>) {
|
||||
if let Some(hex) = value.and_then(hex_to_rgba) {
|
||||
button.remove_css_class(UNSET_SWATCH_CLASS);
|
||||
button.set_tooltip_text(None);
|
||||
button.set_rgba(&hex);
|
||||
} else {
|
||||
button.add_css_class(UNSET_SWATCH_CLASS);
|
||||
button.set_tooltip_text(Some("Unset color"));
|
||||
button.set_rgba(>k4::gdk::RGBA::new(0.45, 0.47, 0.52, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_entry_preview_commit(
|
||||
entry: >k4::Entry,
|
||||
ctx: &PageBuildContext,
|
||||
key: &str,
|
||||
pending_refresh: Rc<Cell<bool>>,
|
||||
) {
|
||||
let ctx_clone = ctx.clone();
|
||||
entry.connect_activate(move |_| {
|
||||
gtk4::prelude::GtkWindowExt::set_focus(
|
||||
&ctx_clone.parent_window,
|
||||
Option::<>k4::Widget>::None,
|
||||
);
|
||||
});
|
||||
|
||||
let focus = gtk4::EventControllerFocus::new();
|
||||
let key_owned = key.to_string();
|
||||
let ctx_clone = ctx.clone();
|
||||
let pending_refresh_clone = pending_refresh.clone();
|
||||
focus.connect_leave(move |_| {
|
||||
if pending_refresh_clone.replace(false) {
|
||||
refresh_live_preview_for_key(&ctx_clone, Some(&key_owned));
|
||||
}
|
||||
});
|
||||
entry.add_controller(focus);
|
||||
}
|
||||
|
||||
fn connect_group_entry_preview_commit(
|
||||
entries: &[gtk4::Entry],
|
||||
ctx: &PageBuildContext,
|
||||
key: &str,
|
||||
pending_refresh: Rc<Cell<bool>>,
|
||||
) {
|
||||
for entry in entries {
|
||||
let ctx_clone = ctx.clone();
|
||||
entry.connect_activate(move |_| {
|
||||
gtk4::prelude::GtkWindowExt::set_focus(
|
||||
&ctx_clone.parent_window,
|
||||
Option::<>k4::Widget>::None,
|
||||
);
|
||||
});
|
||||
|
||||
let key_owned = key.to_string();
|
||||
let ctx_clone = ctx.clone();
|
||||
let pending_refresh_clone = pending_refresh.clone();
|
||||
let entries_clone = entries.to_vec();
|
||||
let focus = gtk4::EventControllerFocus::new();
|
||||
focus.connect_leave(move |_| {
|
||||
let entries_for_check = entries_clone.clone();
|
||||
let ctx_for_check = ctx_clone.clone();
|
||||
let key_for_check = key_owned.clone();
|
||||
let pending_for_check = pending_refresh_clone.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
if entries_for_check
|
||||
.iter()
|
||||
.any(gtk4::prelude::WidgetExt::has_focus)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if pending_for_check.replace(false) {
|
||||
refresh_live_preview_for_key(&ctx_for_check, Some(&key_for_check));
|
||||
}
|
||||
});
|
||||
});
|
||||
entry.add_controller(focus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use gtk4::gdk;
|
||||
|
||||
pub fn hex_to_rgba(hex: &str) -> Option<gdk::RGBA> {
|
||||
if hex.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
|
||||
Some(gdk::RGBA::new(
|
||||
f32::from(r) / 255.0,
|
||||
f32::from(g) / 255.0,
|
||||
f32::from(b) / 255.0,
|
||||
1.0,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn rgba_to_hex(rgba: &gdk::RGBA) -> String {
|
||||
let r = (rgba.red() * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
let g = (rgba.green() * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
let b = (rgba.blue() * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
format!("{r:02X}{g:02X}{b:02X}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_hex_color() {
|
||||
let rgba = hex_to_rgba("12ABEF").expect("valid color");
|
||||
assert_eq!(rgba_to_hex(&rgba), "12ABEF");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
use crate::ui::pages::{refresh_live_preview_for_key, register_option_row, PageBuildContext};
|
||||
use crate::ui::toast::show_toast;
|
||||
use crate::ui::widgets::tool_page;
|
||||
use crate::window::{recompute_validation, refresh_save_button};
|
||||
use gtk4::gdk;
|
||||
use gtk4::prelude::*;
|
||||
use libadwaita::prelude::*;
|
||||
use mangotune::config::help::display_summary_for_key;
|
||||
use mangotune::config::parser::Parser;
|
||||
use mangotune::config::schema::get_schema_entry;
|
||||
use mangotune::config::types::ConfigValue;
|
||||
use mangotune::config::validator;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct CapturedModifiers {
|
||||
control: Option<&'static str>,
|
||||
shift: Option<&'static str>,
|
||||
alt: Option<&'static str>,
|
||||
super_meta: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub fn build_hotkey_row(title: &str, key: &str, ctx: &PageBuildContext) -> libadwaita::ActionRow {
|
||||
let summary = display_summary_for_key(key);
|
||||
let subtitle = if summary.is_empty() {
|
||||
format!("MangoHud key: {key}")
|
||||
} else {
|
||||
format!("{summary} MangoHud key: {key}.")
|
||||
};
|
||||
let row = libadwaita::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
row.add_css_class("control-row");
|
||||
register_option_row(ctx, key, &row);
|
||||
|
||||
let display = gtk4::Label::new(None);
|
||||
display.add_css_class("control-shortcut");
|
||||
display.add_css_class("caption");
|
||||
display.set_xalign(1.0);
|
||||
display.set_selectable(false);
|
||||
display.set_label(&binding_display_text(current_binding(ctx, key).as_deref()));
|
||||
|
||||
let edit = gtk4::Button::with_label("Capture");
|
||||
edit.add_css_class("control-button");
|
||||
let clear = gtk4::Button::with_label("✕");
|
||||
clear.add_css_class("control-button");
|
||||
clear.set_tooltip_text(Some("Clear this keybind"));
|
||||
|
||||
row.add_suffix(&display);
|
||||
row.add_suffix(&edit);
|
||||
row.add_suffix(&clear);
|
||||
|
||||
let key_owned = key.to_string();
|
||||
let display_clone = display.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
edit.connect_clicked(move |_| {
|
||||
let current = current_binding(&ctx_clone, &key_owned).unwrap_or_default();
|
||||
let captured = Rc::new(RefCell::new(current.clone()));
|
||||
|
||||
let dialog = gtk4::Window::builder()
|
||||
.title("Capture keybind")
|
||||
.modal(true)
|
||||
.transient_for(&ctx_clone.parent_window)
|
||||
.default_width(440)
|
||||
.build();
|
||||
dialog.add_css_class("preferences-shell");
|
||||
|
||||
let (content, body, actions) = tool_page::build_utility_window_shell(
|
||||
"Keybinding",
|
||||
"Capture shortcut",
|
||||
"Click the capture area, then press the key combo you want. MangoTune will save it in MangoHud format automatically.",
|
||||
);
|
||||
|
||||
let capture_box = gtk4::Button::new();
|
||||
capture_box.add_css_class("control-field");
|
||||
capture_box.add_css_class("flat");
|
||||
capture_box.set_hexpand(true);
|
||||
capture_box.set_focus_on_click(true);
|
||||
capture_box.set_halign(gtk4::Align::Fill);
|
||||
|
||||
let capture_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
capture_inner.set_margin_top(10);
|
||||
capture_inner.set_margin_bottom(10);
|
||||
capture_inner.set_margin_start(12);
|
||||
capture_inner.set_margin_end(12);
|
||||
|
||||
let capture_title = gtk4::Label::new(Some("Capture area"));
|
||||
capture_title.set_xalign(0.0);
|
||||
capture_title.add_css_class("dim-label");
|
||||
|
||||
let capture_value = gtk4::Label::new(Some(&binding_display_text(Some(¤t))));
|
||||
capture_value.set_xalign(0.0);
|
||||
capture_value.add_css_class("heading");
|
||||
|
||||
let raw_value = gtk4::Label::new(Some(if current.is_empty() { "Disabled" } else { ¤t }));
|
||||
raw_value.set_xalign(0.0);
|
||||
raw_value.add_css_class("caption");
|
||||
raw_value.add_css_class("dim-label");
|
||||
|
||||
capture_inner.append(&capture_title);
|
||||
capture_inner.append(&capture_value);
|
||||
capture_inner.append(&raw_value);
|
||||
capture_box.set_child(Some(&capture_inner));
|
||||
|
||||
let hint = gtk4::Label::new(Some(
|
||||
"Supports modifiers like Ctrl, Shift, Alt, and Super. Modifier-only binds are ignored.",
|
||||
));
|
||||
hint.set_xalign(0.0);
|
||||
hint.set_wrap(true);
|
||||
hint.add_css_class("dim-label");
|
||||
|
||||
let error = gtk4::Label::new(None);
|
||||
error.set_xalign(0.0);
|
||||
error.set_wrap(true);
|
||||
error.add_css_class("error");
|
||||
error.set_visible(false);
|
||||
|
||||
body.append(&capture_box);
|
||||
body.append(&hint);
|
||||
body.append(&error);
|
||||
|
||||
actions.set_halign(gtk4::Align::End);
|
||||
let clear_capture = gtk4::Button::with_label("Clear");
|
||||
clear_capture.add_css_class("shell-strip-button");
|
||||
let cancel = gtk4::Button::with_label("Cancel");
|
||||
cancel.add_css_class("shell-strip-button");
|
||||
let save = gtk4::Button::with_label("Save");
|
||||
save.add_css_class("suggested-action");
|
||||
save.add_css_class("shell-strip-button");
|
||||
actions.append(&clear_capture);
|
||||
actions.append(&cancel);
|
||||
actions.append(&save);
|
||||
|
||||
let controller = gtk4::EventControllerKey::new();
|
||||
let modifiers = Rc::new(RefCell::new(CapturedModifiers::default()));
|
||||
{
|
||||
let captured = captured.clone();
|
||||
let capture_value = capture_value.clone();
|
||||
let raw_value = raw_value.clone();
|
||||
let error = error.clone();
|
||||
let modifiers = modifiers.clone();
|
||||
controller.connect_key_pressed(move |_, keyval, _, state| {
|
||||
if let Some((slot, token)) = modifier_slot_and_token(keyval) {
|
||||
modifiers.borrow_mut().set(slot, token);
|
||||
return gtk4::glib::Propagation::Stop;
|
||||
}
|
||||
if is_modifier_only(keyval) {
|
||||
return gtk4::glib::Propagation::Stop;
|
||||
}
|
||||
let binding = binding_from_event(keyval, state, &modifiers.borrow());
|
||||
*captured.borrow_mut() = binding.clone();
|
||||
capture_value.set_label(&binding_display_text(Some(&binding)));
|
||||
raw_value.set_label(&binding);
|
||||
error.set_visible(false);
|
||||
gtk4::glib::Propagation::Stop
|
||||
});
|
||||
}
|
||||
{
|
||||
let modifiers = modifiers.clone();
|
||||
controller.connect_key_released(move |_, keyval, _, _| {
|
||||
if let Some((slot, _)) = modifier_slot_and_token(keyval) {
|
||||
modifiers.borrow_mut().clear(slot);
|
||||
}
|
||||
});
|
||||
}
|
||||
capture_box.add_controller(controller);
|
||||
|
||||
{
|
||||
let capture_box = capture_box.clone();
|
||||
let focus_target = capture_box.clone();
|
||||
capture_box.connect_clicked(move |_| {
|
||||
let _ = gtk4::prelude::WidgetExt::grab_focus(&focus_target);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let captured = captured.clone();
|
||||
let capture_value = capture_value.clone();
|
||||
let raw_value = raw_value.clone();
|
||||
let error = error.clone();
|
||||
clear_capture.connect_clicked(move |_| {
|
||||
*captured.borrow_mut() = String::new();
|
||||
capture_value.set_label("Disabled");
|
||||
raw_value.set_label("Disabled");
|
||||
error.set_visible(false);
|
||||
});
|
||||
}
|
||||
|
||||
let attempt_save = {
|
||||
let dialog = dialog.clone();
|
||||
let key_owned = key_owned.clone();
|
||||
let display_clone = display_clone.clone();
|
||||
let ctx_clone = ctx_clone.clone();
|
||||
let captured = captured.clone();
|
||||
let error_clone = error.clone();
|
||||
move || {
|
||||
let binding = captured.borrow().trim().to_string();
|
||||
if binding.is_empty() {
|
||||
apply_binding(&ctx_clone, &key_owned, "");
|
||||
display_clone.set_label("Disabled");
|
||||
dialog.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let validation_ok = get_schema_entry(&key_owned)
|
||||
.map(|schema| {
|
||||
validator::validate_value(
|
||||
&key_owned,
|
||||
&ConfigValue::Value(binding.clone()),
|
||||
schema,
|
||||
)
|
||||
})
|
||||
.map(|item| {
|
||||
!matches!(item, mangotune::config::types::ValidationResult::Error(_))
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if !validation_ok {
|
||||
error_clone.set_label("Invalid keybind format");
|
||||
error_clone.set_visible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
apply_binding(&ctx_clone, &key_owned, &binding);
|
||||
display_clone.set_label(&binding_display_text(Some(&binding)));
|
||||
dialog.close();
|
||||
}
|
||||
};
|
||||
|
||||
let dialog_for_cancel = dialog.clone();
|
||||
cancel.connect_clicked(move |_| {
|
||||
dialog_for_cancel.close();
|
||||
});
|
||||
|
||||
let attempt_save_click = attempt_save.clone();
|
||||
save.connect_clicked(move |_| {
|
||||
attempt_save_click();
|
||||
});
|
||||
|
||||
dialog.set_child(Some(&content));
|
||||
dialog.present();
|
||||
let _ = gtk4::prelude::WidgetExt::grab_focus(&capture_box);
|
||||
});
|
||||
|
||||
let key_owned = key.to_string();
|
||||
let ctx_clone = ctx.clone();
|
||||
let display_clone = display.clone();
|
||||
clear.connect_clicked(move |_| {
|
||||
apply_binding(&ctx_clone, &key_owned, "");
|
||||
display_clone.set_label("Disabled");
|
||||
});
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
fn current_binding(ctx: &PageBuildContext, key: &str) -> Option<String> {
|
||||
let Ok(state) = ctx.state.lock() else {
|
||||
return None;
|
||||
};
|
||||
state
|
||||
.config
|
||||
.options
|
||||
.get(key)
|
||||
.and_then(|(_, value)| match value {
|
||||
ConfigValue::Value(value) => Some(value.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_binding(ctx: &PageBuildContext, key: &str, binding: &str) {
|
||||
let validation_ok = get_schema_entry(key)
|
||||
.map(|schema| {
|
||||
validator::validate_value(key, &ConfigValue::Value(binding.to_string()), schema)
|
||||
})
|
||||
.map(|item| !matches!(item, mangotune::config::types::ValidationResult::Error(_)))
|
||||
.unwrap_or(true);
|
||||
|
||||
if !validation_ok {
|
||||
show_toast(&ctx.toast_overlay, "Invalid keybind format");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(mut state) = ctx.state.lock() {
|
||||
if binding.is_empty() {
|
||||
Parser::set_value(&mut state.config, key, ConfigValue::Disabled);
|
||||
} else {
|
||||
Parser::set_value(
|
||||
&mut state.config,
|
||||
key,
|
||||
ConfigValue::Value(binding.to_string()),
|
||||
);
|
||||
}
|
||||
state.dirty = state.config.dirty;
|
||||
}
|
||||
|
||||
recompute_validation(&ctx.state);
|
||||
refresh_save_button(&ctx.state, &ctx.save_button);
|
||||
refresh_live_preview_for_key(ctx, Some(key));
|
||||
}
|
||||
|
||||
fn is_modifier_only(key: gdk::Key) -> bool {
|
||||
modifier_slot_and_token(key).is_some()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ModifierSlot {
|
||||
Control,
|
||||
Shift,
|
||||
Alt,
|
||||
SuperMeta,
|
||||
}
|
||||
|
||||
impl CapturedModifiers {
|
||||
fn set(&mut self, slot: ModifierSlot, token: &'static str) {
|
||||
match slot {
|
||||
ModifierSlot::Control => self.control = Some(token),
|
||||
ModifierSlot::Shift => self.shift = Some(token),
|
||||
ModifierSlot::Alt => self.alt = Some(token),
|
||||
ModifierSlot::SuperMeta => self.super_meta = Some(token),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self, slot: ModifierSlot) {
|
||||
match slot {
|
||||
ModifierSlot::Control => self.control = None,
|
||||
ModifierSlot::Shift => self.shift = None,
|
||||
ModifierSlot::Alt => self.alt = None,
|
||||
ModifierSlot::SuperMeta => self.super_meta = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn modifier_slot_and_token(key: gdk::Key) -> Option<(ModifierSlot, &'static str)> {
|
||||
Some(match key {
|
||||
gdk::Key::Control_L => (ModifierSlot::Control, "Control_L"),
|
||||
gdk::Key::Control_R => (ModifierSlot::Control, "Control_R"),
|
||||
gdk::Key::Shift_L => (ModifierSlot::Shift, "Shift_L"),
|
||||
gdk::Key::Shift_R => (ModifierSlot::Shift, "Shift_R"),
|
||||
gdk::Key::Alt_L => (ModifierSlot::Alt, "Alt_L"),
|
||||
gdk::Key::Alt_R => (ModifierSlot::Alt, "Alt_R"),
|
||||
gdk::Key::Super_L => (ModifierSlot::SuperMeta, "Super_L"),
|
||||
gdk::Key::Super_R => (ModifierSlot::SuperMeta, "Super_R"),
|
||||
gdk::Key::Meta_L => (ModifierSlot::SuperMeta, "Meta_L"),
|
||||
gdk::Key::Meta_R => (ModifierSlot::SuperMeta, "Meta_R"),
|
||||
gdk::Key::Hyper_L => (ModifierSlot::SuperMeta, "Hyper_L"),
|
||||
gdk::Key::Hyper_R => (ModifierSlot::SuperMeta, "Hyper_R"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn binding_from_event(
|
||||
key: gdk::Key,
|
||||
state: gdk::ModifierType,
|
||||
modifiers: &CapturedModifiers,
|
||||
) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if state.contains(gdk::ModifierType::CONTROL_MASK) {
|
||||
parts.push(modifiers.control.unwrap_or("Control_L").to_string());
|
||||
}
|
||||
if state.contains(gdk::ModifierType::SHIFT_MASK) {
|
||||
parts.push(modifiers.shift.unwrap_or("Shift_L").to_string());
|
||||
}
|
||||
if state.contains(gdk::ModifierType::ALT_MASK) {
|
||||
parts.push(modifiers.alt.unwrap_or("Alt_L").to_string());
|
||||
}
|
||||
if state.contains(gdk::ModifierType::SUPER_MASK) || state.contains(gdk::ModifierType::META_MASK)
|
||||
{
|
||||
parts.push(modifiers.super_meta.unwrap_or("Super_L").to_string());
|
||||
}
|
||||
parts.push(key_token(key));
|
||||
parts.join("+")
|
||||
}
|
||||
|
||||
fn key_token(key: gdk::Key) -> String {
|
||||
let Some(name) = key.name() else {
|
||||
return "Unknown".to_string();
|
||||
};
|
||||
let name = name.as_str();
|
||||
if name.len() == 1 {
|
||||
return name.to_ascii_uppercase();
|
||||
}
|
||||
match name {
|
||||
"Return" => "Return".to_string(),
|
||||
"Escape" => "Escape".to_string(),
|
||||
"space" => "space".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn binding_display_text(binding: Option<&str>) -> String {
|
||||
let Some(binding) = binding.filter(|value| !value.trim().is_empty()) else {
|
||||
return "Disabled".to_string();
|
||||
};
|
||||
binding
|
||||
.split('+')
|
||||
.map(|part| match part {
|
||||
"Control_L" | "Control_R" => "Ctrl".to_string(),
|
||||
"Shift_L" | "Shift_R" => "Shift".to_string(),
|
||||
"Alt_L" | "Alt_R" => "Alt".to_string(),
|
||||
"Super_L" | "Super_R" | "Meta_L" | "Meta_R" => "Super".to_string(),
|
||||
other if other.len() == 1 => other.to_ascii_uppercase(),
|
||||
other => other.replace('_', " "),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" + ")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod cascade_view;
|
||||
pub mod color_row;
|
||||
pub mod color_utils;
|
||||
pub mod hotkey_row;
|
||||
pub mod toggle_row;
|
||||
pub mod tool_page;
|
||||
pub mod validation_label;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
use crate::ui::pages::PageBuildContext;
|
||||
use crate::ui::widgets::toggle_row;
|
||||
use gtk4::prelude::*;
|
||||
use mangotune::config::schema::{entries_for_category, get_schema_entry};
|
||||
use mangotune::config::types::{Category, SchemaEntry};
|
||||
|
||||
pub fn build_tool_page(
|
||||
title: &str,
|
||||
eyebrow: &str,
|
||||
subtitle: &str,
|
||||
chips: &[&str],
|
||||
) -> (gtk4::ScrolledWindow, gtk4::Box) {
|
||||
let root = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
root.add_css_class("tool-page");
|
||||
root.set_margin_top(24);
|
||||
root.set_margin_bottom(24);
|
||||
root.set_margin_start(24);
|
||||
root.set_margin_end(24);
|
||||
root.set_hexpand(true);
|
||||
root.set_halign(gtk4::Align::Fill);
|
||||
|
||||
let header = build_header(title, eyebrow, subtitle, chips);
|
||||
header.add_css_class("tool-page-hero-flat");
|
||||
root.append(&header);
|
||||
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
|
||||
body.add_css_class("tool-page-body");
|
||||
root.append(&body);
|
||||
|
||||
let clamp = libadwaita::Clamp::new();
|
||||
clamp.add_css_class("tool-page-clamp");
|
||||
clamp.set_maximum_size(1120);
|
||||
clamp.set_tightening_threshold(900);
|
||||
clamp.set_child(Some(&root));
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::new();
|
||||
scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
|
||||
scroll.set_min_content_width(0);
|
||||
scroll.set_propagate_natural_width(false);
|
||||
scroll.set_propagate_natural_height(false);
|
||||
scroll.set_child(Some(&clamp));
|
||||
(scroll, body)
|
||||
}
|
||||
|
||||
pub fn build_start_page(
|
||||
title: &str,
|
||||
eyebrow: &str,
|
||||
subtitle: &str,
|
||||
chips: &[&str],
|
||||
) -> (gtk4::ScrolledWindow, gtk4::Box) {
|
||||
build_tool_page(title, eyebrow, subtitle, chips)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_single_category_page(
|
||||
ctx: &PageBuildContext,
|
||||
title: &str,
|
||||
eyebrow: &str,
|
||||
subtitle: &str,
|
||||
chips: &[&str],
|
||||
section_title: &str,
|
||||
section_description: &str,
|
||||
section_badge: Option<&str>,
|
||||
category: Category,
|
||||
) -> gtk4::ScrolledWindow {
|
||||
let (page, body) = build_tool_page(title, eyebrow, subtitle, chips);
|
||||
append_schema_category_section(
|
||||
&body,
|
||||
ctx,
|
||||
section_title,
|
||||
section_description,
|
||||
section_badge,
|
||||
category,
|
||||
);
|
||||
page
|
||||
}
|
||||
|
||||
pub fn append_schema_category_section(
|
||||
body: >k4::Box,
|
||||
ctx: &PageBuildContext,
|
||||
title: &str,
|
||||
description: &str,
|
||||
badge: Option<&str>,
|
||||
category: Category,
|
||||
) {
|
||||
let (section, group) = section_shell(title, description, badge);
|
||||
for entry in entries_for_category(&category) {
|
||||
toggle_row::add_schema_row(&group, entry, ctx);
|
||||
}
|
||||
body.append(§ion);
|
||||
}
|
||||
|
||||
pub fn append_schema_category_section_filtered(
|
||||
body: >k4::Box,
|
||||
ctx: &PageBuildContext,
|
||||
title: &str,
|
||||
description: &str,
|
||||
badge: Option<&str>,
|
||||
category: Category,
|
||||
predicate: impl Fn(&SchemaEntry) -> bool,
|
||||
) {
|
||||
let (section, group) = section_shell(title, description, badge);
|
||||
for entry in entries_for_category(&category)
|
||||
.into_iter()
|
||||
.filter(|entry| predicate(entry))
|
||||
{
|
||||
toggle_row::add_schema_row(&group, entry, ctx);
|
||||
}
|
||||
body.append(§ion);
|
||||
}
|
||||
|
||||
pub fn append_schema_key_section(
|
||||
body: >k4::Box,
|
||||
ctx: &PageBuildContext,
|
||||
title: &str,
|
||||
description: &str,
|
||||
badge: Option<&str>,
|
||||
keys: &[&str],
|
||||
) {
|
||||
let (section, group) = section_shell(title, description, badge);
|
||||
for key in keys {
|
||||
if let Some(entry) = get_schema_entry(key) {
|
||||
toggle_row::add_schema_row(&group, entry, ctx);
|
||||
}
|
||||
}
|
||||
body.append(§ion);
|
||||
}
|
||||
|
||||
pub fn append_custom_section(
|
||||
body: >k4::Box,
|
||||
title: &str,
|
||||
description: &str,
|
||||
badge: Option<&str>,
|
||||
) -> libadwaita::PreferencesGroup {
|
||||
let (section, group) = section_shell(title, description, badge);
|
||||
body.append(§ion);
|
||||
group
|
||||
}
|
||||
|
||||
pub fn append_callout(
|
||||
body: >k4::Box,
|
||||
eyebrow: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
tone_css_class: Option<&str>,
|
||||
) {
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
card.add_css_class("tool-callout");
|
||||
if let Some(class_name) = tone_css_class {
|
||||
card.add_css_class(class_name);
|
||||
}
|
||||
|
||||
let eyebrow_label = gtk4::Label::new(Some(eyebrow));
|
||||
eyebrow_label.add_css_class("tool-page-eyebrow");
|
||||
eyebrow_label.set_xalign(0.0);
|
||||
|
||||
let title_label = gtk4::Label::new(Some(title));
|
||||
title_label.add_css_class("tool-callout-title");
|
||||
title_label.set_xalign(0.0);
|
||||
|
||||
let desc_label = gtk4::Label::new(Some(description));
|
||||
desc_label.add_css_class("tool-callout-subtitle");
|
||||
desc_label.set_wrap(true);
|
||||
desc_label.set_xalign(0.0);
|
||||
|
||||
card.append(&eyebrow_label);
|
||||
card.append(&title_label);
|
||||
card.append(&desc_label);
|
||||
body.append(&card);
|
||||
}
|
||||
|
||||
pub fn build_utility_window_shell(
|
||||
eyebrow: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
) -> (gtk4::Box, gtk4::Box, gtk4::Box) {
|
||||
let root = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
root.add_css_class("utility-window-shell");
|
||||
root.set_margin_top(16);
|
||||
root.set_margin_bottom(16);
|
||||
root.set_margin_start(16);
|
||||
root.set_margin_end(16);
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
header.add_css_class("utility-window-header");
|
||||
|
||||
let eyebrow_label = gtk4::Label::new(Some(eyebrow));
|
||||
eyebrow_label.add_css_class("tool-page-eyebrow");
|
||||
eyebrow_label.set_xalign(0.0);
|
||||
|
||||
let title_label = gtk4::Label::new(Some(title));
|
||||
title_label.add_css_class("utility-window-title");
|
||||
title_label.set_xalign(0.0);
|
||||
|
||||
let description_label = gtk4::Label::new(Some(description));
|
||||
description_label.add_css_class("utility-window-subtitle");
|
||||
description_label.set_wrap(true);
|
||||
description_label.set_xalign(0.0);
|
||||
|
||||
header.append(&eyebrow_label);
|
||||
header.append(&title_label);
|
||||
header.append(&description_label);
|
||||
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
body.add_css_class("utility-window-body");
|
||||
body.set_vexpand(true);
|
||||
|
||||
let footer = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
footer.add_css_class("utility-window-footer");
|
||||
|
||||
root.append(&header);
|
||||
root.append(&body);
|
||||
root.append(&footer);
|
||||
(root, body, footer)
|
||||
}
|
||||
|
||||
fn build_header(title: &str, eyebrow: &str, subtitle: &str, chips: &[&str]) -> gtk4::Box {
|
||||
let hero = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||
hero.add_css_class("tool-page-hero");
|
||||
|
||||
let eyebrow_label = gtk4::Label::new(Some(eyebrow));
|
||||
eyebrow_label.add_css_class("tool-page-eyebrow");
|
||||
eyebrow_label.set_xalign(0.0);
|
||||
|
||||
let title_label = gtk4::Label::new(Some(title));
|
||||
title_label.add_css_class("tool-page-title");
|
||||
title_label.set_xalign(0.0);
|
||||
|
||||
let subtitle_label = gtk4::Label::new(Some(subtitle));
|
||||
subtitle_label.add_css_class("tool-page-subtitle");
|
||||
subtitle_label.set_wrap(true);
|
||||
subtitle_label.set_xalign(0.0);
|
||||
|
||||
let chip_box = gtk4::FlowBox::new();
|
||||
chip_box.add_css_class("tool-chip-flow");
|
||||
chip_box.set_selection_mode(gtk4::SelectionMode::None);
|
||||
chip_box.set_activate_on_single_click(false);
|
||||
chip_box.set_halign(gtk4::Align::Start);
|
||||
chip_box.set_row_spacing(4);
|
||||
chip_box.set_column_spacing(8);
|
||||
for chip in chips {
|
||||
chip_box.insert(&build_chip(chip), -1);
|
||||
}
|
||||
|
||||
hero.append(&eyebrow_label);
|
||||
hero.append(&title_label);
|
||||
hero.append(&subtitle_label);
|
||||
if !chips.is_empty() {
|
||||
hero.append(&chip_box);
|
||||
}
|
||||
hero
|
||||
}
|
||||
|
||||
fn section_shell(
|
||||
title: &str,
|
||||
description: &str,
|
||||
badge: Option<&str>,
|
||||
) -> (gtk4::Box, libadwaita::PreferencesGroup) {
|
||||
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||
section.add_css_class("tool-section-shell");
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
header.add_css_class("tool-section-header");
|
||||
header.set_valign(gtk4::Align::Start);
|
||||
|
||||
let copy = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
copy.set_hexpand(true);
|
||||
|
||||
let title_label = gtk4::Label::new(Some(title));
|
||||
title_label.add_css_class("tool-section-title");
|
||||
title_label.set_xalign(0.0);
|
||||
|
||||
let desc_label = gtk4::Label::new(Some(description));
|
||||
desc_label.add_css_class("tool-section-subtitle");
|
||||
desc_label.set_wrap(true);
|
||||
desc_label.set_xalign(0.0);
|
||||
|
||||
copy.append(&title_label);
|
||||
copy.append(&desc_label);
|
||||
header.append(©);
|
||||
|
||||
if let Some(badge_text) = badge {
|
||||
let badge = build_chip(badge_text);
|
||||
badge.add_css_class("tool-section-badge");
|
||||
badge.set_valign(gtk4::Align::Start);
|
||||
header.append(&badge);
|
||||
}
|
||||
|
||||
let group = libadwaita::PreferencesGroup::new();
|
||||
group.add_css_class("tool-section-group");
|
||||
|
||||
section.append(&header);
|
||||
section.append(&group);
|
||||
(section, group)
|
||||
}
|
||||
|
||||
fn build_chip(label: &str) -> gtk4::Label {
|
||||
let chip = gtk4::Label::new(Some(label));
|
||||
chip.add_css_class("tool-chip");
|
||||
chip
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use gtk4::prelude::*;
|
||||
use libadwaita::prelude::*;
|
||||
|
||||
pub fn set_action_row_error(row: &libadwaita::ActionRow, base_subtitle: &str, error: Option<&str>) {
|
||||
if let Some(message) = error {
|
||||
row.add_css_class("error");
|
||||
row.set_subtitle(message);
|
||||
} else {
|
||||
row.remove_css_class("error");
|
||||
row.set_subtitle(base_subtitle);
|
||||
}
|
||||
}
|
||||
+3207
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user