Add control-panel GUI; Video FX rects in pixels or fractions.

`enboxer` with no command opens File/Session/Video/Maps. Native Wayland
clients get a brief focus steal; XWayland/Wine stays unfocused.
This commit is contained in:
en 2026-09-15 08:26:07 +02:00
parent 936f123b18
commit 32731fe59b
11 changed files with 3827 additions and 38 deletions

3088
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,12 @@ tokio = { version = "1", features = [
] } ] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
eframe = { version = "0.31", default-features = false, features = [
"glow",
"wayland",
"x11",
"default_fonts",
] }
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1" pretty_assertions = "1"

View File

@ -51,15 +51,13 @@ Needs Hyprland 0.56 (Lua dispatchers), `hyprctl`, `grim`, and `mpv` (Video FX ov
## Use ## Use
```bash ```bash
mkdir -p ~/.config/enboxer enboxer # control panel (File / Session / Video / Maps)
cp examples/profile.yaml ~/.config/enboxer/profile.yaml enboxer run -c ~/.config/enboxer/profile.yaml # daemon only
# edit window_match, passthrough, maps, character names enboxer doctor
enboxer doctor -c ~/.config/enboxer/profile.yaml
enboxer macros -c ~/.config/enboxer/profile.yaml
enboxer run -c ~/.config/enboxer/profile.yaml
``` ```
The GUI saves `~/.config/enboxer/profile.yaml`. Start routing from **Session → Start routing**.
`enboxer press Alt+G` fires a map without a Hyprland bind (daemon must be running). `enboxer press Alt+G` fires a map without a Hyprland bind (daemon must be running).
## License ## License

View File

@ -28,6 +28,10 @@ hl.bind("ALT + G", function() … end, { description = "enboxer:loot" })
Binds exist only while a managed game window or an `enboxer-vfx` overlay is focused. Binds exist only while a managed game window or an `enboxer-vfx` overlay is focused.
Keys to **other** slots use `send_shortcut` on a window address. That reaches **XWayland** clients (Wine WoW) while they are not focused. Native Wayland terminals often ignore unfocused synthetic keys; the game clients this is built for are XWayland. enBoxer does not start XWayland. Hyprland already runs it. Wine WoW is almost always an **XWayland** window (`xwayland: true` in `hyprctl clients`). Keys go in through XWayland as normal X key events — the same path as a keyboard from the games point of view, not a memory inject.
If a window is **native Wayland** (including `winewayland`), unfocused inject often does nothing. For those, enBoxer focuses the window, sends the key, then restores focus.
The Session page lists each window as `XWayland/Wine` or `Wayland`.
Visible-pixel capture: `grim`. Overlay: `mpv --wayland-app-id=enboxer-vfx` plus JSON IPC to reload frames. Covered windows: `hyprland-toplevel-export` (not wired yet). Visible-pixel capture: `grim`. Overlay: `mpv --wayland-app-id=enboxer-vfx` plus JSON IPC to reload frames. Covered windows: `hyprland-toplevel-export` (not wired yet).

View File

@ -1,8 +1,15 @@
# Live crop (Video FX) # Live crop (Video FX)
This is a picture-in-picture of **another** game window, drawn on top of the one you are playing. You pick **two rectangles**:
Example: your main character fills the monitor. A rectangle in the corner shows your second characters party frames or bags. When the mouse is in that rectangle, clicks and keys go to the second client, not to the main. 1. **Source** — which part of another client to copy, and how large that crop is.
2. **Viewer** — where that crop is drawn on the primary client, and how large it is shown.
Numbers `0``1` are fractions of that window. Numbers **greater than 1** are pixels inside the window. In the GUI, **Pick source / Pick viewer** uses `slurp` and stores pixels.
When the mouse is over the viewer, **clicks and keys go only to the source client**, not to the primary and not to the rest of the team.
Configure this in the **Video FX** page of the control panel (`enboxer` with no arguments).
It is not a second monitor and not a window swap. The other client can sit behind the main one; you still see the cropped piece. It is not a second monitor and not a window swap. The other client can sit behind the main one; you still see the cropped piece.

614
src/gui.rs Normal file
View File

@ -0,0 +1,614 @@
//! Control panel: menus for profile, routing mode, maps, and live crops.
use crate::hotkey::Hotkey;
use crate::macros::print_macros;
use crate::profile::{
default_config_path, Group, Map, Mode, NormRect, Profile, Repeater, Step, VideoFx, WindowMatch,
};
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,
Maps,
Video,
Macros,
}
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,
};
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![],
}
}
struct App {
path: PathBuf,
profile: Profile,
page: Page,
status: String,
daemon: Option<Child>,
slot_info: String,
error: Option<String>,
}
impl App {
fn exe() -> PathBuf {
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer"))
}
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();
match Command::new(Self::exe())
.args(["run", "-c"])
.arg(&self.path)
.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),
}
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();
}
}
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();
}
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.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::Maps, "Maps");
ui.selectable_value(&mut self.page, Page::Video, "Video FX");
ui.selectable_value(&mut self.page, Page::Macros, "Game macros");
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::Maps => self.page_maps(ui),
Page::Video => self.page_video(ui),
Page::Macros => self.page_macros(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.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();
if ui.button("Refresh windows").clicked() {
self.refresh_status();
}
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 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);
});
}
}

View File

@ -106,6 +106,45 @@ pub async fn send_key(window: &str, combo: &str, state: Option<&str>) -> Result<
Ok(()) Ok(())
} }
pub async fn focus_window(selector: &str) -> Result<()> {
dispatch_lua(&format!("hl.dsp.focus({{ window = {selector:?} }})")).await?;
Ok(())
}
/// XWayland (Wine) accepts keys while unfocused. Native Wayland often does not,
/// so we briefly focus, send, then restore.
pub async fn deliver_key(client: &Client, combo: &str, state: Option<&str>) -> Result<()> {
let sel = client.address_selector();
if client.xwayland {
return send_key(&sel, combo, state).await;
}
let prev = active_window().await.ok().flatten();
let _ = focus_window(&sel).await;
send_key(&sel, combo, state).await?;
if let Some(p) = prev {
if p.address != client.address {
let _ = focus_window(&p.address_selector()).await;
}
}
Ok(())
}
pub async fn deliver_click(client: &Client, button: u32) -> Result<()> {
let sel = client.address_selector();
if client.xwayland {
return send_mouse_click(&sel, button).await;
}
let prev = active_window().await.ok().flatten();
let _ = focus_window(&sel).await;
send_mouse_click(&sel, button).await?;
if let Some(p) = prev {
if p.address != client.address {
let _ = focus_window(&p.address_selector()).await;
}
}
Ok(())
}
pub async fn send_mouse_click(window: &str, button: u32) -> Result<()> { pub async fn send_mouse_click(window: &str, button: u32) -> Result<()> {
// 272 = BTN_LEFT, 273 = BTN_RIGHT // 272 = BTN_LEFT, 273 = BTN_RIGHT
let expr = format!( let expr = format!(

View File

@ -1,4 +1,5 @@
pub mod engine; pub mod engine;
pub mod gui;
pub mod hotkey; pub mod hotkey;
pub mod hypr; pub mod hypr;
pub mod macros; pub mod macros;

View File

@ -14,7 +14,7 @@ use tracing_subscriber::EnvFilter;
)] )]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
cmd: Cmd, cmd: Option<Cmd>,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@ -51,6 +51,8 @@ enum Cmd {
#[arg(short, long)] #[arg(short, long)]
config: Option<PathBuf>, config: Option<PathBuf>,
}, },
/// Control panel (default if you run `enboxer` with no command)
Gui,
/// Called by Hyprland binds; not for humans /// Called by Hyprland binds; not for humans
Ipc { Ipc {
#[arg(long)] #[arg(long)]
@ -67,7 +69,8 @@ async fn main() -> Result<()> {
.with_env_filter(EnvFilter::from_default_env().add_directive("enboxer=info".parse()?)) .with_env_filter(EnvFilter::from_default_env().add_directive("enboxer=info".parse()?))
.init(); .init();
let cli = Cli::parse(); let cli = Cli::parse();
match cli.cmd { match cli.cmd.unwrap_or(Cmd::Gui) {
Cmd::Gui => enboxer::gui::run(),
Cmd::Run { config } => { Cmd::Run { config } => {
let path = config.unwrap_or_else(profile::default_config_path); let path = config.unwrap_or_else(profile::default_config_path);
let profile = Profile::load(&path)?; let profile = Profile::load(&path)?;

View File

@ -232,6 +232,11 @@ impl Default for NormRect {
} }
impl NormRect { impl NormRect {
/// Values > 1 are pixels inside the window; otherwise fractions 0..=1 of the window.
pub fn is_pixels(&self) -> bool {
self.x.abs() > 1.0 || self.y.abs() > 1.0 || self.w > 1.0 || self.h > 1.0
}
pub fn to_pixels( pub fn to_pixels(
&self, &self,
win_x: i32, win_x: i32,
@ -239,11 +244,29 @@ impl NormRect {
win_w: i32, win_w: i32,
win_h: i32, win_h: i32,
) -> (i32, i32, i32, i32) { ) -> (i32, i32, i32, i32) {
let x = win_x + (self.x * win_w as f64).round() as i32; if self.is_pixels() {
let y = win_y + (self.y * win_h as f64).round() as i32; (
let w = (self.w * win_w as f64).round() as i32; win_x + self.x.round() as i32,
let h = (self.h * win_h as f64).round() as i32; win_y + self.y.round() as i32,
(x, y, w.max(1), h.max(1)) self.w.round() as i32,
self.h.round() as i32,
)
} else {
let x = win_x + (self.x * win_w as f64).round() as i32;
let y = win_y + (self.y * win_h as f64).round() as i32;
let w = (self.w * win_w as f64).round() as i32;
let h = (self.h * win_h as f64).round() as i32;
(x, y, w.max(1), h.max(1))
}
}
pub fn from_global_pixels(gx: i32, gy: i32, gw: i32, gh: i32, win_x: i32, win_y: i32) -> Self {
Self {
x: (gx - win_x) as f64,
y: (gy - win_y) as f64,
w: gw as f64,
h: gh as f64,
}
} }
} }
@ -341,3 +364,32 @@ pub fn runtime_dir() -> PathBuf {
std::env::temp_dir().join("enboxer") std::env::temp_dir().join("enboxer")
} }
} }
#[cfg(test)]
mod tests {
use super::NormRect;
#[test]
fn fraction_rect() {
let r = NormRect {
x: 0.25,
y: 0.0,
w: 0.5,
h: 1.0,
};
assert!(!r.is_pixels());
assert_eq!(r.to_pixels(100, 50, 200, 100), (150, 50, 100, 100));
}
#[test]
fn pixel_rect() {
let r = NormRect {
x: 10.0,
y: 20.0,
w: 80.0,
h: 40.0,
};
assert!(r.is_pixels());
assert_eq!(r.to_pixels(100, 50, 200, 100), (110, 70, 80, 40));
}
}

View File

@ -500,26 +500,27 @@ async fn fire_vfx(session: &Arc<Mutex<Session>>, hotkey: &str) -> Result<()> {
async fn fire_vfx_click(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> { async fn fire_vfx_click(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
let (cx, cy) = hypr::cursor_pos().await?; let (cx, cy) = hypr::cursor_pos().await?;
let (feed, src_addr) = { let (feed, src) = {
let g = session.lock().await; let g = session.lock().await;
let src = g let slot = g
.vfx_source .vfx_source
.ok_or_else(|| anyhow::anyhow!("no vfx hover"))?; .ok_or_else(|| anyhow::anyhow!("no vfx hover"))?;
let feed = vfx::hit_test(&g.vfx, cx, cy) let feed = vfx::hit_test(&g.vfx, cx, cy)
.filter(|h| h.pass_through && h.source_slot == src) .filter(|h| h.pass_through && h.source_slot == slot)
.ok_or_else(|| anyhow::anyhow!("vfx hover lost"))? .ok_or_else(|| anyhow::anyhow!("vfx hover lost"))?
.clone(); .clone();
let addr = g let src = g
.slots .slots
.iter() .iter()
.find(|(s, _)| *s == src) .find(|(s, _)| *s == slot)
.map(|(_, c)| c.address_selector()) .map(|(_, c)| c.clone())
.ok_or_else(|| anyhow::anyhow!("source slot gone"))?; .ok_or_else(|| anyhow::anyhow!("source slot gone"))?;
(feed, addr) (feed, src)
}; };
let (sx, sy) = vfx::map_click(&feed, cx, cy); let (sx, sy) = vfx::map_click(&feed, cx, cy);
hypr::move_cursor(sx, sy).await?; hypr::move_cursor(sx, sy).await?;
hypr::send_mouse_click(&src_addr, button).await?; hypr::deliver_click(&src, button).await?;
hypr::move_cursor(cx, cy).await?;
Ok(()) Ok(())
} }
@ -573,7 +574,7 @@ async fn execute(actions: Vec<Action>, slots: &[(u32, Client)]) -> Result<()> {
}; };
for id in ids { for id in ids {
if let Some((_, c)) = slots.iter().find(|(s, _)| *s == id) { if let Some((_, c)) = slots.iter().find(|(s, _)| *s == id) {
hypr::send_key(&c.address_selector(), &key, parsed_state).await?; hypr::deliver_key(c, &key, parsed_state).await?;
} }
} }
} }