diff --git a/README.md b/README.md index 98d1376..c8adf37 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ enboxer doctor 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). ## License diff --git a/examples/profile.yaml b/examples/profile.yaml index 78ecf2d..21f1035 100644 --- a/examples/profile.yaml +++ b/examples/profile.yaml @@ -40,6 +40,15 @@ interact: session_hotkeys: 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: - slot: 1 diff --git a/src/gui.rs b/src/gui.rs index 5ee379b..df26cf9 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -6,6 +6,7 @@ use crate::profile::{ 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; @@ -99,6 +100,24 @@ impl App { 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) { if let Some(dir) = self.path.parent() { let _ = std::fs::create_dir_all(dir); @@ -455,8 +474,38 @@ impl App { }); } ui.separator(); - if ui.button("Refresh windows").clicked() { - self.refresh_status(); + 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| { diff --git a/src/layout.rs b/src/layout.rs index 404b5fb..856f939 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -48,7 +48,7 @@ pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec { return vec![]; } let m = pick_monitor(mons, &layout.monitor); - match layout.preset { + let mut out = match layout.preset { LayoutPreset::Stacked => { let (w, h) = constrain(m.width, m.height); (0..n) @@ -63,7 +63,20 @@ pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec { } LayoutPreset::Grid => grid(m, n, layout.pin), 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) { @@ -201,10 +214,22 @@ pub async fn apply_for_profile(profile: &mut Profile) -> Result { } pub async fn apply(slots: &[LayoutSlot], windows: &[(u32, Client)]) -> Result<()> { + let mons = monitors().await.unwrap_or_default(); 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; }; + 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(); hypr::dispatch_lua(&format!( "hl.dsp.window.float({{ window = {sel:?}, action = \"on\" }})" @@ -266,7 +291,25 @@ mod tests { }; let s = generate(&l, 3, &[mon()]); 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] @@ -280,6 +323,29 @@ mod tests { 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] fn main_strip_has_big_first() { let l = Layout { diff --git a/src/profile.rs b/src/profile.rs index c82fb42..e6976c7 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -24,7 +24,7 @@ pub struct Profile { pub game_binds: BTreeMap, #[serde(default)] pub interact: Interact, - #[serde(default)] + #[serde(default = "default_session_hotkeys")] pub session_hotkeys: BTreeMap, #[serde(default)] pub characters: Vec, @@ -44,6 +44,21 @@ fn default_client() -> String { fn default_slots() -> u32 { 2 } +fn default_session_hotkeys() -> BTreeMap { + 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 { Mode::Maps } @@ -331,7 +346,10 @@ impl Profile { pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path) .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()?; Ok(profile) } diff --git a/src/session.rs b/src/session.rs index 73ca4e2..25df94a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -20,6 +20,8 @@ pub struct Session { pub sock: PathBuf, pub vfx: Vec, pub vfx_source: Option, + pub mouse_broadcast: bool, + pub mouse_follow: bool, } impl Session { @@ -32,6 +34,8 @@ impl Session { sock, vfx: Vec::new(), vfx_source: None, + mouse_broadcast: false, + mouse_follow: false, }) } } @@ -325,15 +329,64 @@ fn bind_specs(g: &Session) -> Result> { }); } } - if let Some(cycle) = g.engine.profile.session_hotkeys.get("mode_cycle") { - let parsed = hotkey::parse(cycle)?; - specs.push(BindSpec { - bind: parsed.hypr_bind(), - ipc_bin: bin.clone(), - ipc_args: "mode-cycle".into(), - non_consuming: false, - release: false, - }); + let wm = [ + ("mode_cycle", "mode-cycle"), + ("swap_next", "swap-next"), + ("swap_prev", "swap-prev"), + ("focus_next", "focus-next"), + ("focus_prev", "focus-prev"), + ("focus_main", "focus-main"), + ("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() { for k in vfx_hover_keys() { @@ -372,11 +425,13 @@ async fn dispatch_cmd(session: &Arc>, line: &str) -> String { "status" => { let g = session.lock().await; format!( - "mode={} leader={} binds={} slots={} vfx_source={:?}", + "mode={} leader={} binds={} slots={} mouse_broadcast={} mouse_follow={} vfx_source={:?}", g.engine.mode.as_str(), g.engine.leader_slot, g.binds_on, g.slots.len(), + g.mouse_broadcast, + g.mouse_follow, g.vfx_source ) } @@ -412,6 +467,30 @@ async fn dispatch_cmd(session: &Arc>, line: &str) -> String { } } } + "swap-next" => wm_ok(swap_next(session).await), + "swap-prev" => wm_ok(swap_prev(session).await), + "swap" => match arg.parse::() { + 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::() { + 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::().ok()) + .unwrap_or(272); + wm_ok(broadcast_click(session, btn).await) + } _ => format!("err unknown {cmd}"), } } @@ -447,6 +526,195 @@ async fn set_mode(session: &Arc>, name: Option<&str>) -> Result) -> String { + match r { + Ok(()) => "ok".into(), + Err(e) => format!("err {e}"), + } +} + +async fn swap_next(session: &Arc>) -> 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>) -> 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>, 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>, 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>, 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>) -> 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>) -> 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>) -> 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>) -> 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>, 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::>() + }; + 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 { vfx_hover_keys() .into_iter()