- arm_auto_apply (gui.rs ~1625): compile profile.window_match.class and .title regexes once, then poll hyprctl for a client that matches before firing layout-apply. Fall back to 'first non-empty class' when neither pattern is configured, so existing profiles keep working. Closes the security/correctness gap where any open window could trigger layout-apply against the desktop. - New-team wizard (gui.rs ~1438): profile.characters.truncate is replaced with resize(slots, Character::default()) so a wizard with N members and zero existing characters now actually gets N Launch buttons. Character gains #[derive(... Default)]. - CHANGELOG entries. cargo test 96+/0; clippy clean.
1761 lines
67 KiB
Rust
1761 lines
67 KiB
Rust
//! Control panel: menus for profile, routing mode, maps, and live crops.
|
||
|
||
use crate::hotkey::Hotkey;
|
||
use crate::macros::print_macros;
|
||
use crate::profile::{
|
||
Character, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile,
|
||
Repeater, Step, VideoFx, WindowMatch,
|
||
};
|
||
use crate::session;
|
||
use anyhow::Result;
|
||
use eframe::egui::{self, Color32};
|
||
use std::collections::BTreeMap;
|
||
use std::path::PathBuf;
|
||
use std::process::{Child, Command, Stdio};
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
enum Page {
|
||
Session,
|
||
Layout,
|
||
Maps,
|
||
Video,
|
||
Macros,
|
||
/// The "Teams / Lutris" sub-page. The Lutris picker is only ever
|
||
/// shown on this page (T15 — keeps Lutris off the Session /
|
||
/// Layout / Maps / Video / Macros pages).
|
||
Teams,
|
||
}
|
||
|
||
pub fn run() -> Result<()> {
|
||
let path = default_config_path();
|
||
let profile = if path.exists() {
|
||
Profile::load(&path).unwrap_or_else(|_| default_profile())
|
||
} else {
|
||
let example = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/profile.yaml");
|
||
if example.exists() {
|
||
Profile::load(&example).unwrap_or_else(|_| default_profile())
|
||
} else {
|
||
default_profile()
|
||
}
|
||
};
|
||
let native = eframe::NativeOptions {
|
||
viewport: egui::ViewportBuilder::default()
|
||
.with_title("enBoxer")
|
||
.with_inner_size([980.0, 720.0]),
|
||
..Default::default()
|
||
};
|
||
let app = App {
|
||
path,
|
||
profile,
|
||
page: Page::Session,
|
||
status: String::new(),
|
||
daemon: None,
|
||
slot_info: String::new(),
|
||
error: None,
|
||
allow_layout: false,
|
||
confirm_apply: false,
|
||
type_buf: String::new(),
|
||
profile_name: String::new(),
|
||
monitors: Vec::new(),
|
||
selected_layout_slot: None,
|
||
layout_drag: None,
|
||
profile_names_list: Vec::new(),
|
||
teams: AppTeams::default(),
|
||
};
|
||
eframe::run_native("enBoxer", native, Box::new(|_cc| Ok(Box::new(app))))
|
||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||
Ok(())
|
||
}
|
||
|
||
fn default_profile() -> Profile {
|
||
let mut game_binds = BTreeMap::new();
|
||
game_binds.insert("interact".into(), "g".into());
|
||
game_binds.insert("ctm_on".into(), "Shift+F3".into());
|
||
game_binds.insert("ctm_off".into(), "Shift+F4".into());
|
||
game_binds.insert("assist".into(), "Shift+F2".into());
|
||
game_binds.insert("follow".into(), "Shift+F1".into());
|
||
let mut session_hotkeys = BTreeMap::new();
|
||
session_hotkeys.insert("mode_cycle".into(), "Shift+Alt+M".into());
|
||
Profile {
|
||
name: "team".into(),
|
||
client: "wow-retail".into(),
|
||
slots: 2,
|
||
window_match: WindowMatch {
|
||
class: Some("(?i)wow|warcraft".into()),
|
||
title: None,
|
||
},
|
||
passthrough: vec!["e".into(), "s".into(), "d".into(), "f".into()],
|
||
mode_default: Mode::Maps,
|
||
repeater: Repeater::default(),
|
||
game_binds,
|
||
interact: Default::default(),
|
||
session_hotkeys,
|
||
characters: vec![],
|
||
groups: BTreeMap::<String, Group>::new(),
|
||
maps: vec![],
|
||
video_fx: vec![],
|
||
layout: Default::default(),
|
||
}
|
||
}
|
||
|
||
struct App {
|
||
path: PathBuf,
|
||
profile: Profile,
|
||
page: Page,
|
||
status: String,
|
||
daemon: Option<Child>,
|
||
slot_info: String,
|
||
error: Option<String>,
|
||
allow_layout: bool,
|
||
confirm_apply: bool,
|
||
type_buf: String,
|
||
profile_name: String,
|
||
monitors: Vec<crate::layout::Monitor>,
|
||
selected_layout_slot: Option<usize>,
|
||
layout_drag: Option<(usize, egui::Vec2)>,
|
||
profile_names_list: Vec<String>,
|
||
/// T15 state. The Lutris picker and the team builder are scoped
|
||
/// here so they never leak onto other pages.
|
||
teams: AppTeams,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct AppTeams {
|
||
/// Slug of the team currently loaded into `self.profile`. `None`
|
||
/// means we are running from the legacy flat `profile.yaml`.
|
||
active_slug: Option<String>,
|
||
/// New-team wizard state. The Lutris picker only appears while this
|
||
/// is `Some(_)`.
|
||
new_team: Option<NewTeamState>,
|
||
/// Switch / Refresh / Show / Delete: list of every YAML team on
|
||
/// disk. Refreshed lazily on menu open.
|
||
known: Vec<String>,
|
||
/// Show Lutris config dialog contents.
|
||
show_lutris: Option<crate::lutris::LutrisGame>,
|
||
/// Delete confirm.
|
||
confirm_delete: Option<String>,
|
||
/// In-memory Lutris games for the picker. Loaded once when the
|
||
/// new-team wizard opens.
|
||
lutris_cache: Vec<crate::lutris::LutrisGame>,
|
||
}
|
||
|
||
#[derive(Default, Clone)]
|
||
struct NewTeamState {
|
||
name: String,
|
||
members: u32,
|
||
lutris_slug: Option<String>,
|
||
}
|
||
|
||
impl App {
|
||
fn exe() -> PathBuf {
|
||
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer"))
|
||
}
|
||
|
||
fn ipc(&mut self, verb: &str, arg: &str) {
|
||
let sock = session::default_sock();
|
||
let mut cmd = Command::new(Self::exe());
|
||
cmd.arg("ipc").arg("--sock").arg(&sock).arg(verb);
|
||
if !arg.is_empty() {
|
||
cmd.arg(arg);
|
||
}
|
||
match cmd.output() {
|
||
Ok(o) => {
|
||
self.status = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||
if !o.status.success() {
|
||
self.error = Some(String::from_utf8_lossy(&o.stderr).into());
|
||
}
|
||
}
|
||
Err(e) => self.error = Some(format!("daemon not running? {e}")),
|
||
}
|
||
}
|
||
|
||
fn profiles_dir() -> PathBuf {
|
||
default_config_path()
|
||
.parent()
|
||
.map(|p| p.join("profiles"))
|
||
.unwrap_or_else(|| PathBuf::from("profiles"))
|
||
}
|
||
|
||
fn refresh_profiles(&mut self) {
|
||
let names = list_profiles_in(&Self::profiles_dir());
|
||
self.status = format!("{} named profile(s)", names.len());
|
||
self.profile_names_list = names;
|
||
}
|
||
|
||
fn save_named_profile(&mut self) {
|
||
let name = self.profile_name.trim();
|
||
if name.is_empty() {
|
||
self.error = Some("enter a profile name".into());
|
||
return;
|
||
}
|
||
let dir = Self::profiles_dir();
|
||
let _ = std::fs::create_dir_all(&dir);
|
||
self.path = dir.join(format!("{name}.yaml"));
|
||
self.profile.name = name.to_string();
|
||
self.save();
|
||
}
|
||
|
||
fn load_named_profile(&mut self) {
|
||
let name = self.profile_name.trim();
|
||
if name.is_empty() {
|
||
self.error = Some("enter a profile name".into());
|
||
return;
|
||
}
|
||
let path = Self::profiles_dir().join(format!("{name}.yaml"));
|
||
match Profile::load(&path) {
|
||
Ok(p) => {
|
||
self.profile = p;
|
||
self.path = path;
|
||
self.status = format!("loaded {}", self.profile.name);
|
||
}
|
||
Err(e) => self.error = Some(e.to_string()),
|
||
}
|
||
}
|
||
|
||
fn save(&mut self) {
|
||
if let Some(dir) = self.path.parent() {
|
||
let _ = std::fs::create_dir_all(dir);
|
||
}
|
||
match serde_yaml::to_string(&self.profile) {
|
||
Ok(text) => match std::fs::write(&self.path, text) {
|
||
Ok(()) => self.status = format!("saved {}", self.path.display()),
|
||
Err(e) => self.error = Some(e.to_string()),
|
||
},
|
||
Err(e) => self.error = Some(e.to_string()),
|
||
}
|
||
}
|
||
|
||
fn start_daemon(&mut self) {
|
||
self.save();
|
||
self.stop_daemon();
|
||
let mut cmd = Command::new(Self::exe());
|
||
cmd.args(["run", "-c"]).arg(&self.path);
|
||
if self.allow_layout {
|
||
cmd.env("ENBOXER_ALLOW_LAYOUT", "1");
|
||
}
|
||
match cmd
|
||
.stdin(Stdio::null())
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.spawn()
|
||
{
|
||
Ok(c) => {
|
||
self.daemon = Some(c);
|
||
self.status = "routing started".into();
|
||
}
|
||
Err(e) => self.error = Some(e.to_string()),
|
||
}
|
||
}
|
||
|
||
fn stop_daemon(&mut self) {
|
||
if let Some(mut c) = self.daemon.take() {
|
||
let _ = c.kill();
|
||
let _ = c.wait();
|
||
self.status = "routing stopped".into();
|
||
}
|
||
}
|
||
|
||
fn refresh_status(&mut self) {
|
||
if let Ok(out) = Command::new(Self::exe()).arg("status").output() {
|
||
self.slot_info = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||
}
|
||
if let Ok(out) = Command::new("hyprctl").args(["-j", "clients"]).output() {
|
||
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
|
||
if let Some(arr) = v.as_array() {
|
||
let mut lines = Vec::new();
|
||
for c in arr {
|
||
let class = c.get("class").and_then(|x| x.as_str()).unwrap_or("");
|
||
let title = c.get("title").and_then(|x| x.as_str()).unwrap_or("");
|
||
let xw = c.get("xwayland").and_then(|x| x.as_bool()).unwrap_or(false);
|
||
let kind = if xw { "XWayland/Wine" } else { "Wayland" };
|
||
lines.push(format!("{kind} {class} {title}"));
|
||
}
|
||
if !self.slot_info.is_empty() {
|
||
self.slot_info.push('\n');
|
||
}
|
||
self.slot_info.push_str(&lines.join("\n"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn set_mode(&mut self, m: &str) {
|
||
let _ = Command::new(Self::exe()).args(["mode", m]).status();
|
||
self.profile.mode_default = Mode::parse_name(m).unwrap_or(self.profile.mode_default);
|
||
self.status = format!("mode {m}");
|
||
}
|
||
|
||
fn slurp_into(&mut self, target: RectTarget) {
|
||
let out = Command::new("slurp").args(["-f", "%x %y %w %h"]).output();
|
||
let Ok(out) = out else {
|
||
self.error = Some("slurp is not installed".into());
|
||
return;
|
||
};
|
||
if !out.status.success() {
|
||
return;
|
||
}
|
||
let s = String::from_utf8_lossy(&out.stdout);
|
||
let parts: Vec<i32> = s
|
||
.split_whitespace()
|
||
.filter_map(|p| p.parse().ok())
|
||
.collect();
|
||
if parts.len() != 4 {
|
||
self.error = Some(format!("slurp: {s}"));
|
||
return;
|
||
}
|
||
let (gx, gy, gw, gh) = (parts[0], parts[1], parts[2], parts[3]);
|
||
let (ox, oy) = window_origin_containing(gx + gw / 2, gy + gh / 2).unwrap_or((0, 0));
|
||
let rect = NormRect::from_global_pixels(gx, gy, gw, gh, ox, oy);
|
||
match target {
|
||
RectTarget::Source(i) => {
|
||
if let Some(fx) = self.profile.video_fx.get_mut(i) {
|
||
fx.source = rect;
|
||
self.status = format!(
|
||
"source {i} = {},{} {}x{} (window pixels)",
|
||
rect.x, rect.y, rect.w, rect.h
|
||
);
|
||
}
|
||
}
|
||
RectTarget::Viewer(i) => {
|
||
if let Some(fx) = self.profile.video_fx.get_mut(i) {
|
||
fx.viewer = rect;
|
||
self.status = format!(
|
||
"viewer {i} = {},{} {}x{} (window pixels)",
|
||
rect.x, rect.y, rect.w, rect.h
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
enum RectTarget {
|
||
Source(usize),
|
||
Viewer(usize),
|
||
}
|
||
|
||
struct SlotHit {
|
||
i: usize,
|
||
resp: egui::Response,
|
||
rect: egui::Rect,
|
||
mon_left: f32,
|
||
mon_top: f32,
|
||
mon_x: i32,
|
||
mon_y: i32,
|
||
}
|
||
|
||
/// Pure: list `*.yaml` profile names (no `.yaml` suffix) in `dir`. Missing dir = empty list.
|
||
pub fn list_profiles_in(dir: &std::path::Path) -> Vec<String> {
|
||
let mut out = Vec::new();
|
||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||
return out;
|
||
};
|
||
for entry in rd.flatten() {
|
||
let Ok(name) = entry.file_name().into_string() else {
|
||
continue;
|
||
};
|
||
if let Some(stripped) = name.strip_suffix(".yaml") {
|
||
if !stripped.is_empty() {
|
||
out.push(stripped.to_string());
|
||
}
|
||
}
|
||
}
|
||
out.sort();
|
||
out
|
||
}
|
||
|
||
/// Pure: turn an arbitrary team name into a filesystem-safe slug. Lower-
|
||
/// cases, replaces non-alphanumeric runs with `-`, trims leading/trailing
|
||
/// dashes. Empty input yields an empty string (the caller rejects).
|
||
pub fn slugify(name: &str) -> String {
|
||
let mut out = String::with_capacity(name.len());
|
||
let mut last_dash = true;
|
||
for c in name.chars() {
|
||
let lc = c.to_ascii_lowercase();
|
||
if lc.is_ascii_alphanumeric() {
|
||
out.push(lc);
|
||
last_dash = false;
|
||
} else if !last_dash {
|
||
out.push('-');
|
||
last_dash = true;
|
||
}
|
||
}
|
||
while out.ends_with('-') {
|
||
out.pop();
|
||
}
|
||
out
|
||
}
|
||
|
||
fn window_origin_containing(x: i32, y: i32) -> Option<(i32, i32)> {
|
||
let out = Command::new("hyprctl")
|
||
.args(["-j", "clients"])
|
||
.output()
|
||
.ok()?;
|
||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||
for c in v.as_array()? {
|
||
let at = c.get("at")?.as_array()?;
|
||
let size = c.get("size")?.as_array()?;
|
||
let ax = at.first()?.as_i64()? as i32;
|
||
let ay = at.get(1)?.as_i64()? as i32;
|
||
let w = size.first()?.as_i64()? as i32;
|
||
let h = size.get(1)?.as_i64()? as i32;
|
||
if x >= ax && y >= ay && x < ax + w && y < ay + h {
|
||
return Some((ax, ay));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
impl eframe::App for App {
|
||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||
if let Some(c) = self.daemon.as_mut() {
|
||
if let Ok(Some(_)) = c.try_wait() {
|
||
self.daemon = None;
|
||
self.status = "routing exited".into();
|
||
}
|
||
}
|
||
|
||
if self.confirm_apply {
|
||
egui::Window::new("Move game windows?")
|
||
.collapsible(false)
|
||
.resizable(false)
|
||
.show(ctx, |ui| {
|
||
ui.label("This floats and resizes captured game clients. It can upset Hyprland if something goes wrong.");
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Cancel").clicked() {
|
||
self.confirm_apply = false;
|
||
}
|
||
if ui.button("Apply anyway").clicked() {
|
||
self.confirm_apply = false;
|
||
self.generate_layout_preview();
|
||
self.apply_layout();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
egui::TopBottomPanel::top("menu").show(ctx, |ui| {
|
||
egui::menu::bar(ui, |ui| {
|
||
ui.menu_button("File", |ui| {
|
||
if ui.button("Save").clicked() {
|
||
self.save();
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Reload from disk").clicked() {
|
||
if let Ok(p) = Profile::load(&self.path) {
|
||
self.profile = p;
|
||
self.status = "reloaded".into();
|
||
}
|
||
ui.close_menu();
|
||
}
|
||
ui.separator();
|
||
ui.label("Profile name");
|
||
ui.text_edit_singleline(&mut self.profile_name);
|
||
if ui.button("Save as named profile").clicked() {
|
||
self.save_named_profile();
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Load named profile").clicked() {
|
||
self.load_named_profile();
|
||
ui.close_menu();
|
||
}
|
||
ui.menu_button("Load named…", |ui| {
|
||
if ui.button("Refresh list").clicked() {
|
||
self.refresh_profiles();
|
||
ui.close_menu();
|
||
}
|
||
if self.profile_names_list.is_empty() {
|
||
ui.label("(none — Refresh or Save as first)");
|
||
} else {
|
||
for name in &self.profile_names_list.clone() {
|
||
if ui.button(name).clicked() {
|
||
self.profile_name = name.clone();
|
||
self.load_named_profile();
|
||
ui.close_menu();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
if ui.button("Quit").clicked() {
|
||
self.stop_daemon();
|
||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||
}
|
||
});
|
||
ui.menu_button("Session", |ui| {
|
||
if ui.button("Start routing").clicked() {
|
||
self.start_daemon();
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Stop routing").clicked() {
|
||
self.stop_daemon();
|
||
ui.close_menu();
|
||
}
|
||
ui.separator();
|
||
if ui.button("Mode: maps").clicked() {
|
||
self.set_mode("maps");
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Mode: mirror").clicked() {
|
||
self.set_mode("mirror");
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Mode: off").clicked() {
|
||
self.set_mode("off");
|
||
ui.close_menu();
|
||
}
|
||
ui.separator();
|
||
self.menu_launch(ui);
|
||
});
|
||
ui.menu_button("Teams", |ui| {
|
||
if ui.button("New team…").clicked() {
|
||
self.teams.new_team = Some(NewTeamState {
|
||
name: String::new(),
|
||
members: 2,
|
||
lutris_slug: None,
|
||
});
|
||
// Lutris picker is only loaded here.
|
||
if let Some(dir) = crate::lutris::default_dir() {
|
||
self.teams.lutris_cache = crate::lutris::load_all(&dir);
|
||
}
|
||
self.page = Page::Teams;
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Switch team…").clicked() {
|
||
self.teams.known = crate::team::list_teams();
|
||
self.page = Page::Teams;
|
||
ui.close_menu();
|
||
}
|
||
let active = self.teams.active_slug.clone();
|
||
if ui
|
||
.add_enabled(active.is_some(), egui::Button::new("Refresh Lutris for current team"))
|
||
.clicked()
|
||
{
|
||
self.refresh_lutris_for_active();
|
||
ui.close_menu();
|
||
}
|
||
if ui
|
||
.add_enabled(active.is_some(), egui::Button::new("Show Lutris config for current team"))
|
||
.clicked()
|
||
{
|
||
self.show_lutris_for_active();
|
||
ui.close_menu();
|
||
}
|
||
if ui
|
||
.add_enabled(active.is_some(), egui::Button::new("Delete team…"))
|
||
.clicked()
|
||
{
|
||
self.teams.confirm_delete = active;
|
||
ui.close_menu();
|
||
}
|
||
});
|
||
ui.menu_button("Layout", |ui| {
|
||
if ui.button("Window layout…").clicked() {
|
||
self.page = Page::Layout;
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Apply layout now").clicked() {
|
||
self.apply_layout();
|
||
ui.close_menu();
|
||
}
|
||
});
|
||
ui.menu_button("Video", |ui| {
|
||
if ui.button("Add crop").clicked() {
|
||
self.profile.video_fx.push(VideoFx {
|
||
name: format!("crop{}", self.profile.video_fx.len() + 1),
|
||
enabled: true,
|
||
source_slot: 2,
|
||
source: NormRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
w: 0.4,
|
||
h: 0.4,
|
||
},
|
||
viewer: NormRect {
|
||
x: 0.02,
|
||
y: 0.02,
|
||
w: 0.28,
|
||
h: 0.28,
|
||
},
|
||
pass_through: true,
|
||
fps: 12,
|
||
});
|
||
self.page = Page::Video;
|
||
ui.close_menu();
|
||
}
|
||
});
|
||
ui.menu_button("Help", |ui| {
|
||
if ui.button("Refresh window list").clicked() {
|
||
self.refresh_status();
|
||
ui.close_menu();
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
|
||
ui.horizontal(|ui| {
|
||
ui.label(if self.daemon.is_some() {
|
||
egui::RichText::new("routing ON").color(Color32::LIGHT_GREEN)
|
||
} else {
|
||
egui::RichText::new("routing off").color(Color32::GRAY)
|
||
});
|
||
ui.separator();
|
||
ui.label(&self.status);
|
||
if let Some(e) = &self.error {
|
||
ui.colored_label(Color32::LIGHT_RED, e);
|
||
}
|
||
});
|
||
});
|
||
|
||
egui::SidePanel::left("nav")
|
||
.resizable(false)
|
||
.show(ctx, |ui| {
|
||
ui.heading("enBoxer");
|
||
ui.selectable_value(&mut self.page, Page::Session, "Session");
|
||
ui.selectable_value(&mut self.page, Page::Layout, "Layout");
|
||
ui.selectable_value(&mut self.page, Page::Maps, "Maps");
|
||
ui.selectable_value(&mut self.page, Page::Video, "Video FX");
|
||
ui.selectable_value(&mut self.page, Page::Macros, "Game macros");
|
||
ui.selectable_value(&mut self.page, Page::Teams, "Teams / Lutris");
|
||
ui.separator();
|
||
if ui.button("Start").clicked() {
|
||
self.start_daemon();
|
||
}
|
||
if ui.button("Stop").clicked() {
|
||
self.stop_daemon();
|
||
}
|
||
if ui.button("Save").clicked() {
|
||
self.save();
|
||
}
|
||
});
|
||
|
||
egui::CentralPanel::default().show(ctx, |ui| match self.page {
|
||
Page::Session => self.page_session(ui),
|
||
Page::Layout => self.page_layout(ui),
|
||
Page::Maps => self.page_maps(ui),
|
||
Page::Video => self.page_video(ui),
|
||
Page::Macros => self.page_macros(ui),
|
||
Page::Teams => self.page_teams(ui),
|
||
});
|
||
}
|
||
|
||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||
self.stop_daemon();
|
||
}
|
||
}
|
||
|
||
impl App {
|
||
fn page_session(&mut self, ui: &mut egui::Ui) {
|
||
ui.heading("Session");
|
||
ui.horizontal(|ui| {
|
||
ui.label("Profile name");
|
||
ui.text_edit_singleline(&mut self.profile.name);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Slots");
|
||
ui.add(egui::DragValue::new(&mut self.profile.slots).range(1..=16));
|
||
});
|
||
let mut class = self.profile.window_match.class.clone().unwrap_or_default();
|
||
ui.horizontal(|ui| {
|
||
ui.label("Window class regex");
|
||
if ui.text_edit_singleline(&mut class).changed() {
|
||
self.profile.window_match.class = if class.is_empty() { None } else { Some(class) };
|
||
}
|
||
});
|
||
let mut title = self.profile.window_match.title.clone().unwrap_or_default();
|
||
ui.horizontal(|ui| {
|
||
ui.label("Window title regex");
|
||
if ui.text_edit_singleline(&mut title).changed() {
|
||
self.profile.window_match.title = if title.is_empty() { None } else { Some(title) };
|
||
}
|
||
});
|
||
ui.label("Passthrough (not intercepted; e.g. e s d f)");
|
||
let mut pass = self.profile.passthrough.join(" ");
|
||
if ui.text_edit_singleline(&mut pass).changed() {
|
||
self.profile.passthrough = pass.split_whitespace().map(|s| s.to_string()).collect();
|
||
}
|
||
ui.separator();
|
||
ui.label("Mode");
|
||
ui.horizontal(|ui| {
|
||
if ui
|
||
.selectable_label(self.profile.mode_default == Mode::Maps, "1 maps")
|
||
.clicked()
|
||
{
|
||
self.set_mode("maps");
|
||
}
|
||
if ui
|
||
.selectable_label(self.profile.mode_default == Mode::Mirror, "2 mirror")
|
||
.clicked()
|
||
{
|
||
self.set_mode("mirror");
|
||
}
|
||
if ui
|
||
.selectable_label(self.profile.mode_default == Mode::Off, "3 off")
|
||
.clicked()
|
||
{
|
||
self.set_mode("off");
|
||
}
|
||
});
|
||
ui.label(
|
||
"maps = only listed hotkeys. mirror = clone to other clients. off = front window only.",
|
||
);
|
||
ui.checkbox(
|
||
&mut self.allow_layout,
|
||
"Allow moving/resizing game windows (required for Apply / swap geometry)",
|
||
);
|
||
ui.horizontal(|ui| {
|
||
ui.label("Type to others");
|
||
ui.text_edit_singleline(&mut self.type_buf);
|
||
if ui.button("Send").clicked() && !self.type_buf.is_empty() {
|
||
let t = self.type_buf.clone();
|
||
self.ipc("type", &t);
|
||
}
|
||
});
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Send clipboard to others (Ctrl+V)").clicked() {
|
||
self.ipc("clipboard", "");
|
||
}
|
||
ui.label("(needs wl-paste or xclip)");
|
||
});
|
||
ui.separator();
|
||
ui.label("Characters (assist/follow keys = macros on EVERY account for that name)");
|
||
while self.profile.characters.len() < self.profile.slots as usize {
|
||
let n = self.profile.characters.len() as u32 + 1;
|
||
self.profile.characters.push(crate::profile::Character {
|
||
slot: n,
|
||
name: format!("Toon{n}"),
|
||
match_title: None,
|
||
assist_key: String::new(),
|
||
follow_key: String::new(),
|
||
lutris_game: None,
|
||
wine_prefix: None,
|
||
auto_apply: false,
|
||
});
|
||
}
|
||
for ch in &mut self.profile.characters {
|
||
ui.horizontal(|ui| {
|
||
ui.label(format!("slot {}", ch.slot));
|
||
ui.text_edit_singleline(&mut ch.name);
|
||
ui.label("assist");
|
||
ui.text_edit_singleline(&mut ch.assist_key);
|
||
ui.label("follow");
|
||
ui.text_edit_singleline(&mut ch.follow_key);
|
||
});
|
||
}
|
||
ui.separator();
|
||
ui.label("Game binds (keys you set in WoW)");
|
||
for key in ["interact", "ctm_on", "ctm_off", "assist", "follow"] {
|
||
let mut val = self
|
||
.profile
|
||
.game_binds
|
||
.get(key)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
ui.horizontal(|ui| {
|
||
ui.label(key);
|
||
if ui.text_edit_singleline(&mut val).changed() {
|
||
self.profile.game_binds.insert(key.into(), val);
|
||
}
|
||
});
|
||
}
|
||
ui.separator();
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Capture / refresh").clicked() {
|
||
self.refresh_status();
|
||
}
|
||
if ui.button("Reset to saved layout").clicked() {
|
||
self.ipc("reset-all", "");
|
||
}
|
||
if ui.button("Swap next into main").clicked() {
|
||
self.ipc("swap-next", "");
|
||
}
|
||
});
|
||
ui.checkbox(
|
||
&mut self.profile.layout.pin,
|
||
"Stay on top (new layout tiles)",
|
||
);
|
||
ui.label("Captured clients (Focus / Make main):");
|
||
if let Ok(out) = Command::new("hyprctl").args(["-j", "clients"]).output() {
|
||
if let Ok(clients) = serde_json::from_slice::<Vec<crate::hypr::Client>>(&out.stdout) {
|
||
let wins = crate::layout::select_windows(&self.profile, clients);
|
||
for (slot, c) in &wins {
|
||
ui.horizontal(|ui| {
|
||
let kind = if c.xwayland { "X11" } else { "Wayland" };
|
||
ui.label(format!("#{slot} {kind} {} {}", c.class, c.title));
|
||
if ui.small_button("Focus").clicked() {
|
||
self.ipc("focus", &slot.to_string());
|
||
}
|
||
if *slot > 1 && ui.small_button("Make main").clicked() {
|
||
self.ipc("swap", &slot.to_string());
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
ui.label("XWayland/Wine windows get keys while in the background. Native Wayland windows are focused briefly.");
|
||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||
ui.monospace(&self.slot_info);
|
||
});
|
||
}
|
||
|
||
fn page_maps(&mut self, ui: &mut egui::Ui) {
|
||
ui.heading("Maps");
|
||
ui.label("A map intercepts a hotkey and sends steps to current / others / all.");
|
||
if ui.button("Add map").clicked() {
|
||
self.profile.maps.push(Map {
|
||
name: format!("map{}", self.profile.maps.len() + 1),
|
||
hotkey: Hotkey("1".into()),
|
||
hold: false,
|
||
steps: vec![Step {
|
||
key: Some("1".into()),
|
||
bind: None,
|
||
delay_ms: None,
|
||
target: "all".into(),
|
||
}],
|
||
release_steps: vec![],
|
||
});
|
||
}
|
||
let mut remove = None;
|
||
for (i, m) in self.profile.maps.iter_mut().enumerate() {
|
||
ui.separator();
|
||
ui.horizontal(|ui| {
|
||
ui.label("name");
|
||
ui.text_edit_singleline(&mut m.name);
|
||
ui.label("hotkey");
|
||
ui.text_edit_singleline(&mut m.hotkey.0);
|
||
ui.checkbox(&mut m.hold, "hold");
|
||
if ui.button("Remove").clicked() {
|
||
remove = Some(i);
|
||
}
|
||
});
|
||
if ui.button("Add step").clicked() {
|
||
m.steps.push(Step {
|
||
key: Some("1".into()),
|
||
bind: None,
|
||
delay_ms: None,
|
||
target: "others".into(),
|
||
});
|
||
}
|
||
let mut drop_step = None;
|
||
for (si, st) in m.steps.iter_mut().enumerate() {
|
||
ui.horizontal(|ui| {
|
||
ui.label("key");
|
||
let mut k = st.key.clone().unwrap_or_default();
|
||
if ui.text_edit_singleline(&mut k).changed() {
|
||
st.key = if k.is_empty() { None } else { Some(k) };
|
||
}
|
||
ui.label("bind");
|
||
let mut b = st.bind.clone().unwrap_or_default();
|
||
if ui.text_edit_singleline(&mut b).changed() {
|
||
st.bind = if b.is_empty() { None } else { Some(b) };
|
||
}
|
||
ui.label("delay_ms");
|
||
let mut d = st.delay_ms.unwrap_or(0);
|
||
if ui
|
||
.add(egui::DragValue::new(&mut d).range(0..=10000))
|
||
.changed()
|
||
{
|
||
st.delay_ms = if d == 0 { None } else { Some(d) };
|
||
}
|
||
ui.label("target");
|
||
ui.text_edit_singleline(&mut st.target);
|
||
if ui.small_button("x").clicked() {
|
||
drop_step = Some(si);
|
||
}
|
||
});
|
||
}
|
||
if let Some(si) = drop_step {
|
||
m.steps.remove(si);
|
||
}
|
||
}
|
||
if let Some(i) = remove {
|
||
self.profile.maps.remove(i);
|
||
}
|
||
}
|
||
|
||
fn page_video(&mut self, ui: &mut egui::Ui) {
|
||
ui.heading("Video FX");
|
||
ui.label(
|
||
"Source = crop of another client. Viewer = where that crop is drawn on the primary.",
|
||
);
|
||
ui.label("0–1 = fraction of that window. Numbers > 1 = pixels inside the window. Slurp stores pixels.");
|
||
ui.label("Hover the viewer: clicks and keys go to the source client only.");
|
||
if ui.button("Add crop").clicked() {
|
||
self.profile.video_fx.push(VideoFx {
|
||
name: format!("crop{}", self.profile.video_fx.len() + 1),
|
||
enabled: true,
|
||
source_slot: 2.min(self.profile.slots),
|
||
source: NormRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
w: 0.4,
|
||
h: 0.4,
|
||
},
|
||
viewer: NormRect {
|
||
x: 0.02,
|
||
y: 0.02,
|
||
w: 0.28,
|
||
h: 0.28,
|
||
},
|
||
pass_through: true,
|
||
fps: 12,
|
||
});
|
||
}
|
||
let mut remove = None;
|
||
let mut slurp_src = None;
|
||
let mut slurp_view = None;
|
||
for (i, fx) in self.profile.video_fx.iter_mut().enumerate() {
|
||
ui.separator();
|
||
ui.horizontal(|ui| {
|
||
ui.checkbox(&mut fx.enabled, "on");
|
||
ui.text_edit_singleline(&mut fx.name);
|
||
ui.label("from slot");
|
||
ui.add(egui::DragValue::new(&mut fx.source_slot).range(1..=16));
|
||
ui.checkbox(&mut fx.pass_through, "clicks+keys through");
|
||
if ui.button("Remove").clicked() {
|
||
remove = Some(i);
|
||
}
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("source x y w h");
|
||
ui.add(egui::DragValue::new(&mut fx.source.x));
|
||
ui.add(egui::DragValue::new(&mut fx.source.y));
|
||
ui.add(egui::DragValue::new(&mut fx.source.w));
|
||
ui.add(egui::DragValue::new(&mut fx.source.h));
|
||
if ui.button("Pick source (slurp)").clicked() {
|
||
slurp_src = Some(i);
|
||
}
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("viewer x y w h");
|
||
ui.add(egui::DragValue::new(&mut fx.viewer.x));
|
||
ui.add(egui::DragValue::new(&mut fx.viewer.y));
|
||
ui.add(egui::DragValue::new(&mut fx.viewer.w));
|
||
ui.add(egui::DragValue::new(&mut fx.viewer.h));
|
||
if ui.button("Pick viewer (slurp)").clicked() {
|
||
slurp_view = Some(i);
|
||
}
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("fps");
|
||
ui.add(egui::DragValue::new(&mut fx.fps).range(1..=30));
|
||
});
|
||
}
|
||
if let Some(i) = remove {
|
||
self.profile.video_fx.remove(i);
|
||
}
|
||
if let Some(i) = slurp_src {
|
||
self.slurp_into(RectTarget::Source(i));
|
||
}
|
||
if let Some(i) = slurp_view {
|
||
self.slurp_into(RectTarget::Viewer(i));
|
||
}
|
||
}
|
||
|
||
fn apply_layout(&mut self) {
|
||
if !self.allow_layout {
|
||
self.error = Some("enable “Allow moving game windows” on Session first".into());
|
||
return;
|
||
}
|
||
self.save();
|
||
match Command::new(Self::exe())
|
||
.env("ENBOXER_ALLOW_LAYOUT", "1")
|
||
.args(["layout-apply", "-c"])
|
||
.arg(&self.path)
|
||
.output()
|
||
{
|
||
Ok(o) => {
|
||
self.status = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||
if !o.status.success() {
|
||
self.error = Some(String::from_utf8_lossy(&o.stderr).into());
|
||
}
|
||
}
|
||
Err(e) => self.error = Some(e.to_string()),
|
||
}
|
||
}
|
||
|
||
fn generate_layout_preview(&mut self) {
|
||
let out = Command::new("hyprctl").args(["-j", "monitors"]).output();
|
||
let Ok(out) = out else {
|
||
self.error = Some("hyprctl monitors failed".into());
|
||
return;
|
||
};
|
||
let mons: Vec<crate::layout::Monitor> =
|
||
serde_json::from_slice(&out.stdout).unwrap_or_default();
|
||
self.monitors = mons.clone();
|
||
self.profile.layout.slots =
|
||
crate::layout::generate(&self.profile.layout, self.profile.slots, &mons);
|
||
self.status = format!("generated {} tiles", self.profile.layout.slots.len());
|
||
}
|
||
|
||
fn refresh_monitors(&mut self) {
|
||
match Command::new("hyprctl").args(["-j", "monitors"]).output() {
|
||
Ok(o) => match serde_json::from_slice::<Vec<crate::layout::Monitor>>(&o.stdout) {
|
||
Ok(m) => {
|
||
self.monitors = m;
|
||
self.status = format!("{} monitor(s)", self.monitors.len());
|
||
}
|
||
Err(e) => self.error = Some(format!("parse monitors: {e}")),
|
||
},
|
||
Err(e) => self.error = Some(format!("hyprctl monitors: {e}")),
|
||
}
|
||
}
|
||
|
||
fn layout_canvas(&mut self, ui: &mut egui::Ui) {
|
||
if self.monitors.is_empty() {
|
||
self.refresh_monitors();
|
||
}
|
||
if let Some(idx) = self.selected_layout_slot {
|
||
if idx >= self.profile.layout.slots.len() {
|
||
self.selected_layout_slot = None;
|
||
}
|
||
}
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Refresh monitors").clicked() {
|
||
self.refresh_monitors();
|
||
}
|
||
ui.label(format!(
|
||
"{} monitor(s), {} slot(s)",
|
||
self.monitors.len(),
|
||
self.profile.layout.slots.len()
|
||
));
|
||
});
|
||
let mons = self.monitors.clone();
|
||
if mons.is_empty() {
|
||
ui.label("(no monitors reported by hyprctl)");
|
||
return;
|
||
}
|
||
let avail = ui.available_size();
|
||
let total_w: i32 = mons.iter().map(|m| m.width).sum();
|
||
let max_h: i32 = mons.iter().map(|m| m.height).max().unwrap_or(0);
|
||
let total_w = total_w.max(1) as f32;
|
||
let max_h = max_h.max(1) as f32;
|
||
let pad = 8.0;
|
||
let scale = ((avail.x - pad) / total_w)
|
||
.min((avail.y - pad) / max_h)
|
||
.max(0.02);
|
||
let drawn_w = total_w * scale + pad;
|
||
let drawn_h = max_h * scale + pad;
|
||
let (_, canvas_rect) = ui.allocate_space(egui::vec2(drawn_w, drawn_h));
|
||
let origin = canvas_rect.min + egui::vec2(pad * 0.5, pad * 0.5);
|
||
let painter = ui.painter_at(canvas_rect);
|
||
|
||
let mut mon_canvas_x: Vec<f32> = Vec::with_capacity(mons.len());
|
||
let mut x_off = 0.0_f32;
|
||
for m in &mons {
|
||
mon_canvas_x.push(x_off);
|
||
let r = egui::Rect::from_min_size(
|
||
origin + egui::vec2(x_off, 0.0),
|
||
egui::vec2(m.width as f32 * scale, m.height as f32 * scale),
|
||
);
|
||
painter.rect_filled(r, 2.0, Color32::from_rgb(40, 40, 50));
|
||
painter.text(
|
||
r.left_top() + egui::vec2(4.0, 2.0),
|
||
egui::Align2::LEFT_TOP,
|
||
format!("{} {}x{}", m.name, m.width, m.height),
|
||
egui::FontId::monospace(10.0),
|
||
Color32::LIGHT_GRAY,
|
||
);
|
||
x_off += m.width as f32 * scale;
|
||
}
|
||
|
||
let selected = self.selected_layout_slot;
|
||
let slots = self.profile.layout.slots.clone();
|
||
let mut hits: Vec<SlotHit> = Vec::with_capacity(slots.len());
|
||
for (i, slot) in slots.iter().enumerate() {
|
||
let mon_idx = mons
|
||
.iter()
|
||
.position(|m| {
|
||
slot.x >= m.x
|
||
&& slot.y >= m.y
|
||
&& slot.x < m.x + m.width
|
||
&& slot.y < m.y + m.height
|
||
})
|
||
.unwrap_or(0);
|
||
let m = &mons[mon_idx];
|
||
let mon_left = origin.x + mon_canvas_x[mon_idx];
|
||
let mon_top = origin.y;
|
||
let px = mon_left + (slot.x - m.x) as f32 * scale;
|
||
let py = mon_top + (slot.y - m.y) as f32 * scale;
|
||
let pw = (slot.w as f32 * scale).max(2.0);
|
||
let ph = (slot.h as f32 * scale).max(2.0);
|
||
let r = egui::Rect::from_min_size(egui::pos2(px, py), egui::vec2(pw, ph));
|
||
let resp = ui.interact(
|
||
r,
|
||
ui.id().with(("layout_slot", i)),
|
||
egui::Sense::click_and_drag(),
|
||
);
|
||
let fill = if selected == Some(i) {
|
||
Color32::from_rgb(220, 140, 40)
|
||
} else {
|
||
Color32::from_rgb(80, 140, 200)
|
||
};
|
||
painter.rect_filled(r, 0.0, fill);
|
||
painter.text(
|
||
r.center(),
|
||
egui::Align2::CENTER_CENTER,
|
||
format!("#{}", i + 1),
|
||
egui::FontId::monospace(14.0),
|
||
Color32::BLACK,
|
||
);
|
||
hits.push(SlotHit {
|
||
i,
|
||
resp,
|
||
rect: r,
|
||
mon_left,
|
||
mon_top,
|
||
mon_x: m.x,
|
||
mon_y: m.y,
|
||
});
|
||
}
|
||
|
||
for h in hits {
|
||
if h.resp.clicked() {
|
||
self.selected_layout_slot = Some(h.i);
|
||
}
|
||
if h.resp.drag_started() {
|
||
let grab = h.resp.interact_pointer_pos().unwrap_or(h.rect.center()) - h.rect.min;
|
||
self.layout_drag = Some((h.i, grab));
|
||
}
|
||
if h.resp.dragged() && self.layout_drag.map(|(idx, _)| idx) == Some(h.i) {
|
||
if let (Some(pos), Some((_, grab))) =
|
||
(h.resp.interact_pointer_pos(), self.layout_drag)
|
||
{
|
||
let new_min = pos - grab;
|
||
let dx_pix = ((new_min.x - h.mon_left) / scale).round() as i32;
|
||
let dy_pix = ((new_min.y - h.mon_top) / scale).round() as i32;
|
||
let slot = &mut self.profile.layout.slots[h.i];
|
||
slot.x = h.mon_x + dx_pix;
|
||
slot.y = h.mon_y + dy_pix;
|
||
let mon = mons
|
||
.iter()
|
||
.find(|mm| {
|
||
slot.x >= mm.x
|
||
&& slot.y >= mm.y
|
||
&& slot.x < mm.x + mm.width
|
||
&& slot.y < mm.y + mm.height
|
||
})
|
||
.cloned()
|
||
.unwrap_or_else(|| mons[0].clone());
|
||
let max_x = mon.x + mon.width - slot.w;
|
||
let max_y = mon.y + mon.height - slot.h;
|
||
slot.x = slot.x.clamp(mon.x, max_x.max(mon.x));
|
||
slot.y = slot.y.clamp(mon.y, max_y.max(mon.y));
|
||
}
|
||
}
|
||
if h.resp.drag_stopped() && self.layout_drag.map(|(idx, _)| idx) == Some(h.i) {
|
||
self.layout_drag = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn capture_layout(&mut self) {
|
||
let out = Command::new("hyprctl").args(["-j", "clients"]).output();
|
||
let Ok(out) = out else {
|
||
return;
|
||
};
|
||
let Ok(clients) = serde_json::from_slice::<Vec<crate::hypr::Client>>(&out.stdout) else {
|
||
self.error = Some("parse clients".into());
|
||
return;
|
||
};
|
||
let windows = crate::layout::select_windows(&self.profile, clients);
|
||
self.profile.layout.slots = crate::layout::capture_from(&windows);
|
||
self.status = format!("captured {} window positions", windows.len());
|
||
}
|
||
|
||
fn page_layout(&mut self, ui: &mut egui::Ui) {
|
||
ui.heading("Window layout");
|
||
ui.label("Place each game client: stacked, equal grid, or one large main plus a strip of minions. Drag tiles below. Save and Apply moves captured windows (only if allowed).");
|
||
self.layout_canvas(ui);
|
||
ui.horizontal(|ui| {
|
||
ui.label("Windows");
|
||
ui.add(egui::DragValue::new(&mut self.profile.slots).range(1..=16));
|
||
ui.label("Monitor");
|
||
ui.text_edit_singleline(&mut self.profile.layout.monitor);
|
||
ui.label("(empty = largest)");
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.selectable_value(
|
||
&mut self.profile.layout.preset,
|
||
LayoutPreset::Stacked,
|
||
"All stacked",
|
||
);
|
||
ui.selectable_value(
|
||
&mut self.profile.layout.preset,
|
||
LayoutPreset::Grid,
|
||
"Same size grid",
|
||
);
|
||
ui.selectable_value(
|
||
&mut self.profile.layout.preset,
|
||
LayoutPreset::MainStrip,
|
||
"Main + strip",
|
||
);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.checkbox(
|
||
&mut self.profile.layout.one_row,
|
||
"One row/col for small windows",
|
||
);
|
||
ui.checkbox(&mut self.profile.layout.main_at_bottom, "Main at bottom");
|
||
ui.checkbox(&mut self.profile.layout.pin, "Stay on top");
|
||
ui.checkbox(
|
||
&mut self.profile.layout.auto_apply,
|
||
"Auto-apply when captured",
|
||
);
|
||
ui.checkbox(
|
||
&mut self.profile.layout.borderless,
|
||
"Borderless (Hyprland window rule)",
|
||
);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Generate").clicked() {
|
||
self.generate_layout_preview();
|
||
}
|
||
if ui.button("Capture current positions").clicked() {
|
||
self.capture_layout();
|
||
}
|
||
if ui
|
||
.add(egui::Button::new("Save and Apply").fill(Color32::from_rgb(180, 120, 40)))
|
||
.clicked()
|
||
{
|
||
self.confirm_apply = true;
|
||
}
|
||
});
|
||
ui.separator();
|
||
ui.label("Per-window x y w h (pixels). Slot 1 is the main tile.");
|
||
while self.profile.layout.slots.len() < self.profile.slots as usize {
|
||
self.profile.layout.slots.push(crate::profile::LayoutSlot {
|
||
x: 0,
|
||
y: 0,
|
||
w: 800,
|
||
h: 600,
|
||
pin: false,
|
||
});
|
||
}
|
||
self.profile
|
||
.layout
|
||
.slots
|
||
.truncate(self.profile.slots as usize);
|
||
for (i, s) in self.profile.layout.slots.iter_mut().enumerate() {
|
||
ui.horizontal(|ui| {
|
||
ui.label(format!("#{}", i + 1));
|
||
ui.add(egui::DragValue::new(&mut s.x).prefix("x "));
|
||
ui.add(egui::DragValue::new(&mut s.y).prefix("y "));
|
||
ui.add(egui::DragValue::new(&mut s.w).prefix("w "));
|
||
ui.add(egui::DragValue::new(&mut s.h).prefix("h "));
|
||
ui.checkbox(&mut s.pin, "pin");
|
||
});
|
||
}
|
||
}
|
||
|
||
fn page_macros(&mut self, ui: &mut egui::Ui) {
|
||
ui.heading("In-game macros");
|
||
ui.label("Bind these on every account. enBoxer only sends keystrokes.");
|
||
let text = print_macros(&self.profile);
|
||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||
ui.monospace(text);
|
||
});
|
||
}
|
||
|
||
fn page_teams(&mut self, ui: &mut egui::Ui) {
|
||
ui.heading("Teams / Lutris");
|
||
ui.label("A team bundles a Profile with a Lutris game launcher and per-character wine overrides. Each team is one YAML in ~/.config/enboxer/teams/<slug>.yaml.");
|
||
ui.separator();
|
||
if let Some(slug) = &self.teams.active_slug {
|
||
ui.label(format!("Active team: {slug}"));
|
||
} else {
|
||
ui.label("Active: (legacy profile — no team selected)");
|
||
}
|
||
|
||
// ----- New team wizard (Lutris picker lives only here). -----
|
||
if let Some(wiz) = &mut self.teams.new_team {
|
||
ui.separator();
|
||
ui.label("New team");
|
||
ui.horizontal(|ui| {
|
||
ui.label("name");
|
||
ui.text_edit_singleline(&mut wiz.name);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("members");
|
||
ui.add(egui::DragValue::new(&mut wiz.members).range(1..=16));
|
||
});
|
||
ui.label("Lutris game");
|
||
if self.teams.lutris_cache.is_empty() {
|
||
ui.label("(no Lutris games found in ~/.config/lutris/games)");
|
||
} else {
|
||
egui::ComboBox::from_label("")
|
||
.selected_text(
|
||
self.teams
|
||
.lutris_cache
|
||
.iter()
|
||
.find(|g| Some(&g.slug) == wiz.lutris_slug.as_ref())
|
||
.map(|g| format!("{} ({})", g.name, g.slug))
|
||
.unwrap_or_else(|| "(none)".into()),
|
||
)
|
||
.show_ui(ui, |ui| {
|
||
if ui
|
||
.selectable_label(wiz.lutris_slug.is_none(), "(none — manual profile)")
|
||
.clicked()
|
||
{
|
||
wiz.lutris_slug = None;
|
||
}
|
||
for g in &self.teams.lutris_cache {
|
||
let selected = wiz.lutris_slug.as_deref() == Some(g.slug.as_str());
|
||
if ui.selectable_label(selected, format!("{} ({})", g.name, g.slug)).clicked() {
|
||
wiz.lutris_slug = Some(g.slug.clone());
|
||
// Reuse the Lutris name as the default team name when blank.
|
||
if wiz.name.trim().is_empty() {
|
||
wiz.name = g.slug.clone();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Create team").clicked() {
|
||
self.create_team();
|
||
}
|
||
if ui.button("Cancel").clicked() {
|
||
self.teams.new_team = None;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ----- Switch team list. -----
|
||
ui.separator();
|
||
ui.label("Switch team");
|
||
if self.teams.known.is_empty() {
|
||
ui.label("(no teams — New team… to create one)");
|
||
} else {
|
||
for slug in self.teams.known.clone() {
|
||
let active = self.teams.active_slug.as_deref() == Some(slug.as_str());
|
||
let label = if active {
|
||
format!("{slug} ✓")
|
||
} else {
|
||
slug.clone()
|
||
};
|
||
if ui.button(label).clicked() {
|
||
self.switch_team(&slug);
|
||
}
|
||
}
|
||
}
|
||
if ui.button("Refresh list").clicked() {
|
||
self.teams.known = crate::team::list_teams();
|
||
}
|
||
|
||
// ----- Delete confirm. -----
|
||
if let Some(slug) = self.teams.confirm_delete.clone() {
|
||
egui::Window::new(format!("Delete team {slug}?"))
|
||
.collapsible(false)
|
||
.resizable(false)
|
||
.show(ui.ctx(), |ui| {
|
||
ui.label("Removes the YAML. Lutris is NOT touched.");
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Cancel").clicked() {
|
||
self.teams.confirm_delete = None;
|
||
}
|
||
if ui.button("Delete").clicked() {
|
||
self.delete_team(&slug);
|
||
self.teams.confirm_delete = None;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----- Lutris config preview. -----
|
||
if let Some(g) = self.teams.show_lutris.clone() {
|
||
egui::Window::new(format!("Lutris config: {}", g.slug))
|
||
.collapsible(false)
|
||
.resizable(true)
|
||
.show(ui.ctx(), |ui| {
|
||
ui.monospace(format!(
|
||
"name: {}\nrunner: {}\nexe: {}\nargs: {}\nprefix: {}\ndxvk: {}\nvkd3d: {}\nesync: {}\nfsync: {}\ndll: {}\nenv: {} entries",
|
||
g.name,
|
||
g.runner,
|
||
g.exe,
|
||
g.args,
|
||
g.prefix,
|
||
g.dxvk,
|
||
g.vkd3d,
|
||
g.esync,
|
||
g.fsync,
|
||
g.dll_overrides,
|
||
g.env.len()
|
||
));
|
||
if !g.env.is_empty() {
|
||
ui.label("env vars:");
|
||
for (k, v) in &g.env {
|
||
ui.monospace(format!(" {k}={v}"));
|
||
}
|
||
}
|
||
if ui.button("Close").clicked() {
|
||
self.teams.show_lutris = None;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
fn menu_launch(&mut self, ui: &mut egui::Ui) {
|
||
let mut to_launch: Vec<u32> = Vec::new();
|
||
for ch in &self.profile.characters {
|
||
if ch.lutris_game.is_some()
|
||
&& ui.button(format!("Launch slot {} ({})", ch.slot, ch.name)).clicked()
|
||
{
|
||
to_launch.push(ch.slot);
|
||
ui.close_menu();
|
||
}
|
||
}
|
||
if self.profile.characters.iter().any(|c| c.lutris_game.is_some()) {
|
||
ui.separator();
|
||
if ui.button("Launch all").clicked() {
|
||
to_launch = self
|
||
.profile
|
||
.characters
|
||
.iter()
|
||
.filter(|c| c.lutris_game.is_some())
|
||
.map(|c| c.slot)
|
||
.collect();
|
||
ui.close_menu();
|
||
}
|
||
}
|
||
for slot in to_launch {
|
||
self.launch_character(slot);
|
||
}
|
||
}
|
||
|
||
fn create_team(&mut self) {
|
||
let Some(wiz) = self.teams.new_team.clone() else { return };
|
||
let name = wiz.name.trim();
|
||
if name.is_empty() {
|
||
self.error = Some("team name required".into());
|
||
return;
|
||
}
|
||
let slug = slugify(name);
|
||
if slug.is_empty() {
|
||
self.error = Some("team name has no alphanumeric characters".into());
|
||
return;
|
||
}
|
||
let mut profile = self.profile.clone();
|
||
profile.name = name.to_string();
|
||
profile.slots = wiz.members.max(1);
|
||
// Pad to slot count: truncate any extras, but extend with defaults
|
||
// so a brand-new profile (members > 0, characters == 0) actually
|
||
// gets a Launch row per member.
|
||
profile.characters
|
||
.resize(profile.slots as usize, Character::default());
|
||
// Character::default() leaves slot = 0, which collides with the
|
||
// Launch button lookup (find(|c| c.slot == slot)). Assign slot 1..=N
|
||
// to any character still at 0, leaving properly-assigned entries
|
||
// untouched.
|
||
for (i, ch) in profile.characters.iter_mut().enumerate() {
|
||
if ch.slot == 0 {
|
||
ch.slot = (i as u32) + 1;
|
||
}
|
||
}
|
||
// Apply the Lutris game pick to every character (including the
|
||
// freshly-padded ones).
|
||
if let Some(slug_l) = wiz.lutris_slug.clone() {
|
||
for ch in &mut profile.characters {
|
||
ch.lutris_game = Some(slug_l.clone());
|
||
}
|
||
}
|
||
let mut team = crate::team::Team::from_profile(profile);
|
||
team.slug = slug.clone();
|
||
let path = crate::team::team_path(&slug);
|
||
if let Err(e) = team.save(&path) {
|
||
self.error = Some(format!("save team: {e}"));
|
||
return;
|
||
}
|
||
if let Err(e) = crate::team::write_current_team(&slug) {
|
||
self.error = Some(format!("write current_team: {e}"));
|
||
return;
|
||
}
|
||
self.teams.active_slug = Some(slug.clone());
|
||
self.teams.new_team = None;
|
||
self.teams.known = crate::team::list_teams();
|
||
self.status = format!("team {slug} created");
|
||
}
|
||
|
||
fn switch_team(&mut self, slug: &str) {
|
||
let path = crate::team::team_path(slug);
|
||
match crate::team::Team::load(&path) {
|
||
Ok(team) => {
|
||
self.profile = team.profile.clone();
|
||
self.path = path;
|
||
if let Err(e) = crate::team::write_current_team(slug) {
|
||
self.error = Some(format!("write current_team: {e}"));
|
||
}
|
||
self.teams.active_slug = Some(slug.to_string());
|
||
self.status = format!("loaded team {slug}");
|
||
}
|
||
Err(e) => self.error = Some(format!("load team: {e}")),
|
||
}
|
||
}
|
||
|
||
fn delete_team(&mut self, slug: &str) {
|
||
let path = crate::team::team_path(slug);
|
||
match std::fs::remove_file(&path) {
|
||
Ok(()) => {
|
||
self.teams.known = crate::team::list_teams();
|
||
if self.teams.active_slug.as_deref() == Some(slug) {
|
||
self.teams.active_slug = None;
|
||
crate::team::clear_current_team();
|
||
}
|
||
self.status = format!("deleted team {slug}");
|
||
}
|
||
Err(e) => self.error = Some(format!("delete team: {e}")),
|
||
}
|
||
}
|
||
|
||
fn refresh_lutris_for_active(&mut self) {
|
||
let Some(slug) = self.teams.active_slug.clone() else { return };
|
||
// Re-derive the Lutris slug from the first character with a
|
||
// `lutris_game`. If none, this is a no-op.
|
||
let lutris_slug = self
|
||
.profile
|
||
.characters
|
||
.iter()
|
||
.find_map(|c| c.lutris_game.clone());
|
||
let Some(lslug) = lutris_slug else {
|
||
self.status = "no Lutris game bound to this team".into();
|
||
return;
|
||
};
|
||
let dir = match crate::lutris::default_dir() {
|
||
Some(d) => d,
|
||
None => {
|
||
self.error = Some("no Lutris directory found".into());
|
||
return;
|
||
}
|
||
};
|
||
let path = dir.join(format!("{lslug}.yml"));
|
||
if !path.exists() {
|
||
self.error = Some(format!("Lutris game {lslug} not found"));
|
||
return;
|
||
}
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(text) => match crate::lutris::parse(&lslug, &text) {
|
||
Some(_) => {
|
||
self.status = format!("refreshed Lutris config for {slug} ({lslug})");
|
||
}
|
||
None => self.error = Some(format!("parse Lutris {lslug}")),
|
||
},
|
||
Err(e) => self.error = Some(format!("read Lutris: {e}")),
|
||
}
|
||
}
|
||
|
||
fn show_lutris_for_active(&mut self) {
|
||
let Some(lslug) = self
|
||
.profile
|
||
.characters
|
||
.iter()
|
||
.find_map(|c| c.lutris_game.clone())
|
||
else {
|
||
self.error = Some("no Lutris game bound to this team".into());
|
||
return;
|
||
};
|
||
let dir = match crate::lutris::default_dir() {
|
||
Some(d) => d,
|
||
None => {
|
||
self.error = Some("no Lutris directory found".into());
|
||
return;
|
||
}
|
||
};
|
||
let path = dir.join(format!("{lslug}.yml"));
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(text) => match crate::lutris::parse(&lslug, &text) {
|
||
Some(g) => self.teams.show_lutris = Some(g),
|
||
None => self.error = Some(format!("parse Lutris {lslug}")),
|
||
},
|
||
Err(e) => self.error = Some(format!("read Lutris: {e}")),
|
||
}
|
||
}
|
||
|
||
fn launch_character(&mut self, slot: u32) {
|
||
let Some(ch) = self.profile.characters.iter().find(|c| c.slot == slot).cloned() else {
|
||
self.error = Some(format!("no character in slot {slot}"));
|
||
return;
|
||
};
|
||
let Some(lslug) = ch.lutris_game.clone() else {
|
||
self.error = Some(format!("slot {slot} has no Lutris game"));
|
||
return;
|
||
};
|
||
let dir = match crate::lutris::default_dir() {
|
||
Some(d) => d,
|
||
None => {
|
||
self.error = Some("no Lutris directory found".into());
|
||
return;
|
||
}
|
||
};
|
||
let Ok(text) = std::fs::read_to_string(dir.join(format!("{lslug}.yml"))) else {
|
||
self.error = Some(format!("read Lutris {lslug}"));
|
||
return;
|
||
};
|
||
let Some(game) = crate::lutris::parse(&lslug, &text) else {
|
||
self.error = Some(format!("parse Lutris {lslug}"));
|
||
return;
|
||
};
|
||
let opts = crate::launcher::TeamLaunchOpts {
|
||
wine_prefix: ch.wine_prefix.clone(),
|
||
};
|
||
let plan = crate::launcher::spawn_plan(&ch, &game, &opts);
|
||
self.spawn_plan(&plan, &ch);
|
||
}
|
||
|
||
/// Spawn `plan` detached. `ch` carries `auto_apply` so we know
|
||
/// whether to poll hyprctl after the spawn.
|
||
fn spawn_plan(&mut self, plan: &crate::launcher::SpawnPlan, ch: &crate::profile::Character) {
|
||
let mut cmd = Command::new(&plan.exe);
|
||
cmd.args(&plan.args)
|
||
.stdin(Stdio::null())
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null());
|
||
for (k, v) in &plan.env {
|
||
cmd.env(k, v);
|
||
}
|
||
match cmd.spawn() {
|
||
Ok(_child) => {
|
||
self.status = plan.summary.clone();
|
||
if ch.auto_apply {
|
||
self.status.push_str(" (auto-apply armed)");
|
||
self.arm_auto_apply(ch.slot);
|
||
}
|
||
}
|
||
Err(e) => self.error = Some(format!("launch: {e}")),
|
||
}
|
||
}
|
||
|
||
/// Poll hyprctl for the matched window and call the existing
|
||
/// layout-apply IPC. This is the only path that moves windows
|
||
/// automatically after a Launch; everything else is gated.
|
||
fn arm_auto_apply(&mut self, _slot: u32) {
|
||
// Spawn a background thread that polls hyprctl for up to 30 s
|
||
// and then sends the layout-apply IPC. Detached; logs on error.
|
||
let profile_path = self.path.clone();
|
||
let allow = self.allow_layout;
|
||
// Compile the profile's window_match patterns once, outside the
|
||
// poll loop. If a pattern is malformed we treat it as "not
|
||
// configured" rather than crashing the auto-apply thread.
|
||
let class_pat = self
|
||
.profile
|
||
.window_match
|
||
.class
|
||
.as_deref()
|
||
.and_then(|p| regex::Regex::new(p).ok());
|
||
let title_pat = self
|
||
.profile
|
||
.window_match
|
||
.title
|
||
.as_deref()
|
||
.and_then(|p| regex::Regex::new(p).ok());
|
||
let any_pattern = class_pat.is_some() || title_pat.is_some();
|
||
std::thread::Builder::new()
|
||
.name("enboxer-auto-apply".into())
|
||
.spawn(move || {
|
||
use std::process::Command as SyncCommand;
|
||
let start = std::time::Instant::now();
|
||
let deadline = std::time::Duration::from_secs(30);
|
||
// Returns true if the client's class/title match the
|
||
// configured patterns. If neither pattern is configured
|
||
// (no `window_match` set on the profile), accept the
|
||
// first client with a non-empty class so existing
|
||
// profiles keep working.
|
||
let matched = |cls: &str, ttl: &str| -> bool {
|
||
let class_ok = class_pat.as_ref().is_none_or(|re| re.is_match(cls));
|
||
let title_ok = title_pat.as_ref().is_none_or(|re| re.is_match(ttl));
|
||
if !any_pattern {
|
||
!cls.is_empty()
|
||
} else {
|
||
class_ok && title_ok
|
||
}
|
||
};
|
||
while start.elapsed() < deadline {
|
||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||
let out = SyncCommand::new("hyprctl")
|
||
.args(["-j", "clients"])
|
||
.output();
|
||
if let Ok(out) = out {
|
||
if let Ok(v) =
|
||
serde_json::from_slice::<serde_json::Value>(&out.stdout)
|
||
{
|
||
if let Some(arr) = v.as_array() {
|
||
let hit = arr.iter().any(|c| {
|
||
let cls = c
|
||
.get("class")
|
||
.and_then(|x| x.as_str())
|
||
.unwrap_or("");
|
||
let ttl = c
|
||
.get("title")
|
||
.and_then(|x| x.as_str())
|
||
.unwrap_or("");
|
||
matched(cls, ttl)
|
||
});
|
||
if hit {
|
||
let mut cmd = SyncCommand::new(
|
||
std::env::current_exe()
|
||
.unwrap_or_else(|_| std::path::PathBuf::from("enboxer")),
|
||
);
|
||
cmd.args(["layout-apply", "-c"]).arg(&profile_path);
|
||
if allow {
|
||
cmd.env("ENBOXER_ALLOW_LAYOUT", "1");
|
||
}
|
||
let _ = cmd.status();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.ok();
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{list_profiles_in, slugify};
|
||
use std::path::PathBuf;
|
||
|
||
fn tmp_dir(label: &str) -> PathBuf {
|
||
let dir = std::env::temp_dir().join(format!("enboxer_test_{label}_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
dir
|
||
}
|
||
|
||
#[test]
|
||
fn list_profiles_returns_yaml_names_sorted() {
|
||
let dir = tmp_dir("profiles_list");
|
||
std::fs::write(dir.join("alpha.yaml"), "name: alpha\n").unwrap();
|
||
std::fs::write(dir.join("beta.yaml"), "name: beta\n").unwrap();
|
||
std::fs::write(dir.join("readme.txt"), "ignore me").unwrap();
|
||
std::fs::write(dir.join(".yaml"), "hidden").unwrap();
|
||
let names = list_profiles_in(&dir);
|
||
assert_eq!(names, vec!["alpha".to_string(), "beta".to_string()]);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn slugify_handles_spaces_and_punctuation() {
|
||
assert_eq!(slugify("My Cool Team #1"), "my-cool-team-1");
|
||
assert_eq!(slugify("wow-classic"), "wow-classic");
|
||
assert_eq!(slugify(" --strip-- "), "strip");
|
||
assert_eq!(slugify(""), "");
|
||
assert_eq!(slugify("!@#$%^"), "");
|
||
}
|
||
|
||
#[test]
|
||
fn list_profiles_missing_dir_is_empty() {
|
||
let dir = std::env::temp_dir().join(format!(
|
||
"enboxer_does_not_exist_{}_{}",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
let names = list_profiles_in(&dir);
|
||
assert!(names.is_empty());
|
||
}
|
||
}
|