Add per-game overlay toggles (gamemode, mangohud, gamescope)

Games live under each Launcher as a Vec<Game>; the launcher itself
never picks up overlays, only games launched through `play` do.

- config: Game struct with gamemode/mangohud/gamescope fields, plus
  add_game / remove_game / set_game_flags methods.
- launcher::play_game wraps the game command as
  `gamescope [args] -- gamemoderun umu-run <exe>` (each layer optional)
  and sets MANGOHUD=1 when enabled.
- CLI: `play`, `games`, `config add-game`, `config remove-game`,
  `config set-game-flags`.
- tray: per-game submenus with Play + checkmark toggles for GameMode /
  MangoHud / Gamescope; toggles persist to disk immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-17 17:24:55 -07:00
parent 22fa1efabf
commit b72c642223
5 changed files with 515 additions and 3 deletions
+170
View File
@@ -46,6 +46,21 @@ enum Commands {
/// List configured launchers and whether they're installed / running
Launchers,
/// Play a specific game through its launcher's prefix, applying the
/// per-game overlay flags (gamemode, mangohud, gamescope).
Play {
/// Launcher name (e.g. battlenet)
launcher: String,
/// Game name (e.g. overwatch)
game: String,
},
/// List configured games per launcher
Games {
/// Only show games for this launcher (omit for all)
launcher: Option<String>,
},
/// Print setup instructions for a launcher (automated wizard coming soon)
Setup {
/// Launcher name
@@ -132,6 +147,60 @@ enum ConfigAction {
/// Short CLI name
name: String,
},
/// Add a game under an existing launcher
AddGame {
/// Launcher that owns this game
launcher: String,
/// Short CLI name for the game (e.g. "overwatch")
name: String,
/// Game exe path relative to drive_c/
#[arg(long, value_name = "PATH")]
exe_path: PathBuf,
/// Display name (defaults to NAME)
#[arg(long)]
display: Option<String>,
/// Wrap the game in gamemoderun
#[arg(long)]
gamemode: bool,
/// Set MANGOHUD=1 for the game
#[arg(long)]
mangohud: bool,
/// Enable gamescope with these args (space-separated, e.g. "-f -W 2560")
#[arg(long, value_name = "ARGS")]
gamescope: Option<String>,
},
/// Remove a game from a launcher
RemoveGame {
launcher: String,
name: String,
},
/// Toggle per-game overlay flags
SetGameFlags {
launcher: String,
name: String,
/// true / false — wrap in gamemoderun
#[arg(long, value_name = "BOOL")]
gamemode: Option<bool>,
/// true / false — set MANGOHUD=1
#[arg(long, value_name = "BOOL")]
mangohud: Option<bool>,
/// Enable gamescope with these args (space-separated)
#[arg(long, value_name = "ARGS", conflicts_with = "no_gamescope")]
gamescope: Option<String>,
/// Disable gamescope wrapping
#[arg(long)]
no_gamescope: bool,
},
}
#[derive(Subcommand)]
@@ -186,6 +255,40 @@ fn main() -> Result<()> {
}
}
Commands::Play { launcher: lname, game: gname } => {
let l = config.find(&lname).ok_or_else(|| {
anyhow::anyhow!("unknown launcher '{lname}' — try `umutray launchers`")
})?;
let g = l.find_game(&gname).ok_or_else(|| {
anyhow::anyhow!(
"launcher '{lname}' has no game named '{gname}' — try `umutray games {lname}`"
)
})?;
launcher::play_game(&config, l, g)?;
}
Commands::Games { launcher: lname } => {
let launchers: Vec<&config::Launcher> = match &lname {
Some(n) => vec![config.find(n).ok_or_else(|| {
anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`")
})?],
None => config.launchers.iter().collect(),
};
for l in launchers {
if l.games.is_empty() {
println!(" {}: (no games)", l.display);
continue;
}
println!(" {}:", l.display);
for g in &l.games {
let installed = g.full_exe_path(l).exists();
let marker = if installed { "\x1b[1;32m✓\x1b[0m" } else { "·" };
let flags = format_game_flags(g);
println!(" {marker} {:14} {}{}", g.name, g.display, flags);
}
}
}
Commands::Setup { name } => {
let l = config.find(&name).ok_or_else(|| {
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
@@ -231,6 +334,55 @@ fn main() -> Result<()> {
let mut c = config;
c.remove_launcher(&name)?;
}
ConfigAction::AddGame {
launcher,
name,
exe_path,
display,
gamemode,
mangohud,
gamescope,
} => {
let mut c = config;
let gs = gamescope.map(|s| {
s.split_whitespace().map(String::from).collect::<Vec<_>>()
});
c.add_game(
&launcher,
name,
display,
exe_path,
gamemode,
mangohud,
gs,
)?;
}
ConfigAction::RemoveGame { launcher, name } => {
let mut c = config;
c.remove_game(&launcher, &name)?;
}
ConfigAction::SetGameFlags {
launcher,
name,
gamemode,
mangohud,
gamescope,
no_gamescope,
} => {
let gs_update = if no_gamescope {
Some(None)
} else {
gamescope.map(|s| {
Some(
s.split_whitespace()
.map(String::from)
.collect::<Vec<_>>(),
)
})
};
let mut c = config;
c.set_game_flags(&launcher, &name, gamemode, mangohud, gs_update)?;
}
},
Commands::Service { action } => match action {
@@ -242,3 +394,21 @@ fn main() -> Result<()> {
Ok(())
}
fn format_game_flags(g: &config::Game) -> String {
let mut tags: Vec<&str> = Vec::new();
if g.gamemode {
tags.push("gamemode");
}
if g.mangohud {
tags.push("mangohud");
}
if g.gamescope.is_some() {
tags.push("gamescope");
}
if tags.is_empty() {
String::new()
} else {
format!(" [{}]", tags.join(", "))
}
}