//! 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::::new(), maps: vec![], video_fx: vec![], layout: Default::default(), } } struct App { path: PathBuf, profile: Profile, page: Page, status: String, daemon: Option, slot_info: String, error: Option, allow_layout: bool, confirm_apply: bool, type_buf: String, profile_name: String, monitors: Vec, selected_layout_slot: Option, layout_drag: Option<(usize, egui::Vec2)>, profile_names_list: Vec, /// 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, /// New-team wizard state. The Lutris picker only appears while this /// is `Some(_)`. new_team: Option, /// Switch / Refresh / Show / Delete: list of every YAML team on /// disk. Refreshed lazily on menu open. known: Vec, /// Show Lutris config dialog contents. show_lutris: Option, /// Delete confirm. confirm_delete: Option, /// In-memory Lutris games for the picker. Loaded once when the /// new-team wizard opens. lutris_cache: Vec, } #[derive(Default, Clone)] struct NewTeamState { name: String, members: u32, lutris_slug: Option, } 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::(&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 = 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 { 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::>(&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 = 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::>(&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 = 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 = 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::>(&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/.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 = 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::(&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()); } }