enBoxer/src/gui.rs
en 1b8b2cdf24 GUI: arm_auto_apply waits for window_match; new-team pads characters
- 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.
2026-09-16 05:58:27 +02:00

1761 lines
67 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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("01 = 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());
}
}