Window swap/focus/reset hotkeys; clamp layout tiles to the monitor.

Do not run live compositor tests that move the user's windows.
This commit is contained in:
en 2026-09-15 08:56:01 +02:00
parent 50b2aebee6
commit af45b054cc
6 changed files with 429 additions and 17 deletions

View File

@ -58,6 +58,8 @@ enboxer doctor
The GUI saves `~/.config/enboxer/profile.yaml`. Start routing from **Session → Start routing**. The GUI saves `~/.config/enboxer/profile.yaml`. Start routing from **Session → Start routing**.
**Layout** places every captured client (stacked / grid / main+strip), then **Make main** swaps a minion into the large tile. Hotkeys: Ctrl+` swap next, Ctrl+F1… focus slot, Ctrl+Shift+F2… swap that slot to main, Ctrl+Shift+R reset layout.
`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

@ -40,6 +40,15 @@ interact:
session_hotkeys: session_hotkeys:
mode_cycle: "Shift+Alt+M" mode_cycle: "Shift+Alt+M"
swap_next: "Ctrl+grave"
swap_prev: "Ctrl+Shift+grave"
focus_next: "Ctrl+Shift+N"
focus_prev: "Ctrl+Shift+P"
focus_main: "Ctrl+F1"
reset_all: "Ctrl+Shift+R"
stay_on_top: "Ctrl+Shift+T"
mouse_follow: "Ctrl+Shift+F"
mouse_broadcast: "Ctrl+Shift+B"
characters: characters:
- slot: 1 - slot: 1

View File

@ -6,6 +6,7 @@ use crate::profile::{
default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, Repeater, Step, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, Repeater, Step,
VideoFx, WindowMatch, VideoFx, WindowMatch,
}; };
use crate::session;
use anyhow::Result; use anyhow::Result;
use eframe::egui::{self, Color32}; use eframe::egui::{self, Color32};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@ -99,6 +100,24 @@ impl App {
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer")) 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 save(&mut self) { fn save(&mut self) {
if let Some(dir) = self.path.parent() { if let Some(dir) = self.path.parent() {
let _ = std::fs::create_dir_all(dir); let _ = std::fs::create_dir_all(dir);
@ -455,8 +474,38 @@ impl App {
}); });
} }
ui.separator(); ui.separator();
if ui.button("Refresh windows").clicked() { ui.horizontal(|ui| {
self.refresh_status(); 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."); ui.label("XWayland/Wine windows get keys while in the background. Native Wayland windows are focused briefly.");
egui::ScrollArea::vertical().show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| {

View File

@ -48,7 +48,7 @@ pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec<LayoutSlot> {
return vec![]; return vec![];
} }
let m = pick_monitor(mons, &layout.monitor); let m = pick_monitor(mons, &layout.monitor);
match layout.preset { let mut out = match layout.preset {
LayoutPreset::Stacked => { LayoutPreset::Stacked => {
let (w, h) = constrain(m.width, m.height); let (w, h) = constrain(m.width, m.height);
(0..n) (0..n)
@ -63,7 +63,20 @@ pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec<LayoutSlot> {
} }
LayoutPreset::Grid => grid(m, n, layout.pin), LayoutPreset::Grid => grid(m, n, layout.pin),
LayoutPreset::MainStrip => main_strip(m, n, layout), LayoutPreset::MainStrip => main_strip(m, n, layout),
};
for s in &mut out {
clamp_to_monitor(s, m);
} }
out
}
fn clamp_to_monitor(s: &mut LayoutSlot, m: &Monitor) {
s.w = s.w.clamp(64, m.width.max(64));
s.h = s.h.clamp(64, m.height.max(64));
let max_x = m.x + m.width - s.w;
let max_y = m.y + m.height - s.h;
s.x = s.x.clamp(m.x.min(max_x), max_x.max(m.x));
s.y = s.y.clamp(m.y.min(max_y), max_y.max(m.y));
} }
fn constrain(w: i32, h: i32) -> (i32, i32) { fn constrain(w: i32, h: i32) -> (i32, i32) {
@ -201,10 +214,22 @@ pub async fn apply_for_profile(profile: &mut Profile) -> Result<usize> {
} }
pub async fn apply(slots: &[LayoutSlot], windows: &[(u32, Client)]) -> Result<()> { pub async fn apply(slots: &[LayoutSlot], windows: &[(u32, Client)]) -> Result<()> {
let mons = monitors().await.unwrap_or_default();
for (i, win) in windows { for (i, win) in windows {
let Some(geom) = slots.get((*i as usize).saturating_sub(1)) else { let Some(geom0) = slots.get((*i as usize).saturating_sub(1)) else {
continue; continue;
}; };
let mut geom = geom0.clone();
if let Some(m) = mons.iter().find(|m| {
geom.x >= m.x && geom.y >= m.y && geom.x < m.x + m.width && geom.y < m.y + m.height
}) {
clamp_to_monitor(&mut geom, m);
} else if let Some(m) = mons.first() {
clamp_to_monitor(&mut geom, m);
}
if geom.w < 64 || geom.h < 64 {
continue;
}
let sel = win.address_selector(); let sel = win.address_selector();
hypr::dispatch_lua(&format!( hypr::dispatch_lua(&format!(
"hl.dsp.window.float({{ window = {sel:?}, action = \"on\" }})" "hl.dsp.window.float({{ window = {sel:?}, action = \"on\" }})"
@ -266,7 +291,25 @@ mod tests {
}; };
let s = generate(&l, 3, &[mon()]); let s = generate(&l, 3, &[mon()]);
assert_eq!(s.len(), 3); assert_eq!(s.len(), 3);
assert!(s.iter().all(|w| w.w == 1920 && w.h == 1080 && w.x == 0)); assert!(s
.iter()
.all(|w| w.w == 1920 && w.h == 1080 && w.x == 0 && w.y == 0));
}
#[test]
fn clamp_rejects_negative() {
let m = mon();
let mut s = LayoutSlot {
x: -400,
y: -500,
w: 1280,
h: 1440,
pin: false,
};
clamp_to_monitor(&mut s, &m);
assert!(s.x >= 0 && s.y >= 0);
assert!(s.x + s.w <= m.width);
assert!(s.y + s.h <= m.height);
} }
#[test] #[test]
@ -280,6 +323,29 @@ mod tests {
assert!(s.iter().all(|w| w.w > 0 && w.h > 0)); assert!(s.iter().all(|w| w.w > 0 && w.h > 0));
} }
#[test]
fn swap_main_exchanges_first() {
let mut s = vec![
LayoutSlot {
x: 0,
y: 0,
w: 100,
h: 100,
pin: true,
},
LayoutSlot {
x: 100,
y: 0,
w: 50,
h: 50,
pin: false,
},
];
swap_main(&mut s, 1);
assert_eq!(s[0].w, 50);
assert_eq!(s[1].w, 100);
}
#[test] #[test]
fn main_strip_has_big_first() { fn main_strip_has_big_first() {
let l = Layout { let l = Layout {

View File

@ -24,7 +24,7 @@ pub struct Profile {
pub game_binds: BTreeMap<String, String>, pub game_binds: BTreeMap<String, String>,
#[serde(default)] #[serde(default)]
pub interact: Interact, pub interact: Interact,
#[serde(default)] #[serde(default = "default_session_hotkeys")]
pub session_hotkeys: BTreeMap<String, String>, pub session_hotkeys: BTreeMap<String, String>,
#[serde(default)] #[serde(default)]
pub characters: Vec<Character>, pub characters: Vec<Character>,
@ -44,6 +44,21 @@ fn default_client() -> String {
fn default_slots() -> u32 { fn default_slots() -> u32 {
2 2
} }
fn default_session_hotkeys() -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
m.insert("mode_cycle".into(), "Shift+Alt+M".into());
m.insert("swap_next".into(), "Ctrl+grave".into());
m.insert("swap_prev".into(), "Ctrl+Shift+grave".into());
m.insert("focus_next".into(), "Ctrl+Shift+N".into());
m.insert("focus_prev".into(), "Ctrl+Shift+P".into());
m.insert("focus_main".into(), "Ctrl+F1".into());
m.insert("reset_all".into(), "Ctrl+Shift+R".into());
m.insert("stay_on_top".into(), "Ctrl+Shift+T".into());
m.insert("mouse_follow".into(), "Ctrl+Shift+F".into());
m.insert("mouse_broadcast".into(), "Ctrl+Shift+B".into());
m
}
fn default_mode() -> Mode { fn default_mode() -> Mode {
Mode::Maps Mode::Maps
} }
@ -331,7 +346,10 @@ impl Profile {
pub fn load(path: &Path) -> Result<Self> { pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path) let text = std::fs::read_to_string(path)
.with_context(|| format!("read profile {}", path.display()))?; .with_context(|| format!("read profile {}", path.display()))?;
let profile: Profile = serde_yaml::from_str(&text).context("parse profile YAML")?; let mut profile: Profile = serde_yaml::from_str(&text).context("parse profile YAML")?;
for (k, v) in default_session_hotkeys() {
profile.session_hotkeys.entry(k).or_insert(v);
}
profile.validate()?; profile.validate()?;
Ok(profile) Ok(profile)
} }

View File

@ -20,6 +20,8 @@ pub struct Session {
pub sock: PathBuf, pub sock: PathBuf,
pub vfx: Vec<FeedHit>, pub vfx: Vec<FeedHit>,
pub vfx_source: Option<u32>, pub vfx_source: Option<u32>,
pub mouse_broadcast: bool,
pub mouse_follow: bool,
} }
impl Session { impl Session {
@ -32,6 +34,8 @@ impl Session {
sock, sock,
vfx: Vec::new(), vfx: Vec::new(),
vfx_source: None, vfx_source: None,
mouse_broadcast: false,
mouse_follow: false,
}) })
} }
} }
@ -325,15 +329,64 @@ fn bind_specs(g: &Session) -> Result<Vec<BindSpec>> {
}); });
} }
} }
if let Some(cycle) = g.engine.profile.session_hotkeys.get("mode_cycle") { let wm = [
let parsed = hotkey::parse(cycle)?; ("mode_cycle", "mode-cycle"),
specs.push(BindSpec { ("swap_next", "swap-next"),
bind: parsed.hypr_bind(), ("swap_prev", "swap-prev"),
ipc_bin: bin.clone(), ("focus_next", "focus-next"),
ipc_args: "mode-cycle".into(), ("focus_prev", "focus-prev"),
non_consuming: false, ("focus_main", "focus-main"),
release: false, ("reset_all", "reset-all"),
}); ("stay_on_top", "stay-on-top"),
("mouse_follow", "mouse-follow"),
("mouse_broadcast", "mouse-broadcast"),
];
for (name, ipc) in wm {
if let Some(hk) = g.engine.profile.session_hotkeys.get(name) {
let parsed = hotkey::parse(hk)?;
specs.push(BindSpec {
bind: parsed.hypr_bind(),
ipc_bin: bin.clone(),
ipc_args: ipc.into(),
non_consuming: false,
release: false,
});
}
}
for i in 1..=g.engine.profile.slots {
let focus = format!("Ctrl+F{i}");
let swap = format!("Ctrl+Shift+F{i}");
if let Ok(p) = hotkey::parse(&focus) {
specs.push(BindSpec {
bind: p.hypr_bind(),
ipc_bin: bin.clone(),
ipc_args: format!("focus {i}"),
non_consuming: false,
release: false,
});
}
if i > 1 {
if let Ok(p) = hotkey::parse(&swap) {
specs.push(BindSpec {
bind: p.hypr_bind(),
ipc_bin: bin.clone(),
ipc_args: format!("swap {i}"),
non_consuming: false,
release: false,
});
}
}
}
if g.mouse_broadcast {
for btn in ["mouse:272", "mouse:273"] {
specs.push(BindSpec {
bind: btn.to_string(),
ipc_bin: bin.clone(),
ipc_args: format!("mouse-click {btn}"),
non_consuming: true,
release: false,
});
}
} }
if g.vfx_source.is_some() { if g.vfx_source.is_some() {
for k in vfx_hover_keys() { for k in vfx_hover_keys() {
@ -372,11 +425,13 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
"status" => { "status" => {
let g = session.lock().await; let g = session.lock().await;
format!( format!(
"mode={} leader={} binds={} slots={} vfx_source={:?}", "mode={} leader={} binds={} slots={} mouse_broadcast={} mouse_follow={} vfx_source={:?}",
g.engine.mode.as_str(), g.engine.mode.as_str(),
g.engine.leader_slot, g.engine.leader_slot,
g.binds_on, g.binds_on,
g.slots.len(), g.slots.len(),
g.mouse_broadcast,
g.mouse_follow,
g.vfx_source g.vfx_source
) )
} }
@ -412,6 +467,30 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
} }
} }
} }
"swap-next" => wm_ok(swap_next(session).await),
"swap-prev" => wm_ok(swap_prev(session).await),
"swap" => match arg.parse::<u32>() {
Ok(n) => wm_ok(swap_as_main(session, n).await),
Err(_) => "err bad slot".into(),
},
"focus-next" => wm_ok(focus_step(session, 1).await),
"focus-prev" => wm_ok(focus_step(session, -1).await),
"focus-main" => wm_ok(focus_slot(session, 1).await),
"focus" => match arg.parse::<u32>() {
Ok(n) => wm_ok(focus_slot(session, n).await),
Err(_) => "err bad slot".into(),
},
"reset-all" => wm_ok(reset_layout(session).await),
"stay-on-top" => wm_ok(toggle_pin(session).await),
"mouse-follow" => wm_ok(toggle_mouse_follow(session).await),
"mouse-broadcast" => wm_ok(toggle_mouse_broadcast(session).await),
"mouse-click" => {
let btn = arg
.strip_prefix("mouse:")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(272);
wm_ok(broadcast_click(session, btn).await)
}
_ => format!("err unknown {cmd}"), _ => format!("err unknown {cmd}"),
} }
} }
@ -447,6 +526,195 @@ async fn set_mode(session: &Arc<Mutex<Session>>, name: Option<&str>) -> Result<M
Ok(mode) Ok(mode)
} }
fn wm_ok(r: Result<()>) -> String {
match r {
Ok(()) => "ok".into(),
Err(e) => format!("err {e}"),
}
}
async fn swap_next(session: &Arc<Mutex<Session>>) -> Result<()> {
let n = {
let g = session.lock().await;
let len = g.slots.len() as u32;
if len < 2 {
return Ok(());
}
let cur = g.engine.leader_slot;
if cur >= len {
2
} else {
cur + 1
}
};
swap_as_main(session, n.max(2)).await
}
async fn swap_prev(session: &Arc<Mutex<Session>>) -> Result<()> {
let n = {
let g = session.lock().await;
let len = g.slots.len() as u32;
if len < 2 {
return Ok(());
}
let cur = g.engine.leader_slot;
if cur <= 2 {
len
} else {
cur - 1
}
};
swap_as_main(session, n).await
}
async fn swap_as_main(session: &Arc<Mutex<Session>>, n: u32) -> Result<()> {
let (wins, tiles) = {
let mut g = session.lock().await;
if n < 2 || n as usize > g.slots.len() {
return Ok(());
}
g.slots.swap(0, n as usize - 1);
for (i, (id, _)) in g.slots.iter_mut().enumerate() {
*id = i as u32 + 1;
}
g.engine.set_leader(1);
(g.slots.clone(), g.engine.profile.layout.slots.clone())
};
if !tiles.is_empty() {
crate::layout::apply(&tiles, &wins).await?;
}
if let Some((_, c)) = wins.first() {
hypr::focus_window(&c.address_selector()).await.ok();
}
hypr::notify(&format!("main is slot 1 (was {n})"))
.await
.ok();
Ok(())
}
async fn focus_step(session: &Arc<Mutex<Session>>, dir: i32) -> Result<()> {
let n = {
let g = session.lock().await;
let len = g.slots.len() as i32;
if len == 0 {
return Ok(());
}
let cur = g.engine.leader_slot as i32;
let next = ((cur - 1 + dir).rem_euclid(len)) + 1;
next as u32
};
focus_slot(session, n).await
}
async fn focus_slot(session: &Arc<Mutex<Session>>, n: u32) -> Result<()> {
let addr = {
let mut g = session.lock().await;
g.engine.set_leader(n);
g.slots
.iter()
.find(|(s, _)| *s == n)
.map(|(_, c)| c.address_selector())
};
if let Some(a) = addr {
hypr::focus_window(&a).await?;
}
Ok(())
}
async fn reset_layout(session: &Arc<Mutex<Session>>) -> Result<()> {
let (wins, tiles) = {
let g = session.lock().await;
(g.slots.clone(), g.engine.profile.layout.slots.clone())
};
if tiles.is_empty() {
anyhow::bail!("no saved layout tiles");
}
crate::layout::apply(&tiles, &wins).await?;
hypr::notify("layout reset").await.ok();
Ok(())
}
async fn toggle_pin(session: &Arc<Mutex<Session>>) -> Result<()> {
let addr = {
let g = session.lock().await;
g.slots
.iter()
.find(|(s, _)| *s == g.engine.leader_slot)
.map(|(_, c)| c.address_selector())
};
if let Some(a) = addr {
hypr::dispatch_lua(&format!(
"hl.dsp.window.pin({{ window = {a:?}, action = \"toggle\" }})"
))
.await?;
}
Ok(())
}
async fn toggle_mouse_follow(session: &Arc<Mutex<Session>>) -> Result<()> {
let on = {
let mut g = session.lock().await;
g.mouse_follow = !g.mouse_follow;
g.mouse_follow
};
let v = if on { 1 } else { 0 };
let _ = hypr::hyprctl(["keyword", "input:follow_mouse", &v.to_string()]).await;
hypr::notify(&format!(
"focus follows mouse {}",
if on { "on" } else { "off" }
))
.await
.ok();
Ok(())
}
async fn toggle_mouse_broadcast(session: &Arc<Mutex<Session>>) -> Result<()> {
{
let mut g = session.lock().await;
g.mouse_broadcast = !g.mouse_broadcast;
g.binds_on = false;
hypr::notify(&format!(
"mouse broadcast {}",
if g.mouse_broadcast { "on" } else { "off" }
))
.await
.ok();
}
Ok(())
}
async fn broadcast_click(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
let (cx, cy) = hypr::cursor_pos().await?;
let others = {
let g = session.lock().await;
if !g.mouse_broadcast {
return Ok(());
}
let Some((_, primary)) = g.slots.iter().find(|(s, _)| *s == g.engine.leader_slot) else {
return Ok(());
};
let pw = primary.size[0].max(1) as f64;
let ph = primary.size[1].max(1) as f64;
let nx = (cx - primary.at[0]) as f64 / pw;
let ny = (cy - primary.at[1]) as f64 / ph;
g.slots
.iter()
.filter(|(s, _)| *s != g.engine.leader_slot)
.map(|(_, c)| {
let x = c.at[0] + (nx * c.size[0] as f64).round() as i32;
let y = c.at[1] + (ny * c.size[1] as f64).round() as i32;
(c.clone(), x, y)
})
.collect::<Vec<_>>()
};
for (c, x, y) in others {
hypr::move_cursor(x, y).await.ok();
hypr::deliver_click(&c, button).await.ok();
}
hypr::move_cursor(cx, cy).await.ok();
Ok(())
}
fn clone_key_set() -> Vec<String> { fn clone_key_set() -> Vec<String> {
vfx_hover_keys() vfx_hover_keys()
.into_iter() .into_iter()