Both window_match.class and window_match.title patterns are now logged as warnings when regex::Regex::new returns Err. Before, .ok() silently swallowed compile errors and treated a bad pattern as 'not configured', which collapsed back to the original bug: any open window could fire layout-apply. The no-configured-patterns fallback (accept a window with a non-empty class) is preserved for users who haven't set window_match at all.
1183 lines
37 KiB
Rust
1183 lines
37 KiB
Rust
use crate::engine::{Action, Engine, Hold};
|
||
use crate::hotkey::{self, passthrough_id};
|
||
use crate::hypr::{self, BindSpec, Client};
|
||
use crate::overlay::OverlayHub;
|
||
use crate::profile::{runtime_dir, Mode, Profile};
|
||
use crate::vfx::{self, FeedHit};
|
||
use anyhow::{Context, Result};
|
||
use regex::Regex;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||
use tokio::net::UnixListener;
|
||
use tokio::sync::{watch, Mutex};
|
||
use tokio::time::{sleep, Duration};
|
||
|
||
pub struct Session {
|
||
pub engine: Engine,
|
||
pub slots: Vec<(u32, Client)>,
|
||
pub binds_on: bool,
|
||
pub exe: PathBuf,
|
||
pub sock: PathBuf,
|
||
pub vfx: Vec<FeedHit>,
|
||
pub vfx_source: Option<u32>,
|
||
pub mouse_broadcast: bool,
|
||
pub mouse_follow: bool,
|
||
}
|
||
|
||
impl Session {
|
||
pub fn new(profile: Profile, exe: PathBuf, sock: PathBuf) -> Result<Self> {
|
||
Ok(Self {
|
||
engine: Engine::new(profile)?,
|
||
slots: Vec::new(),
|
||
binds_on: false,
|
||
exe,
|
||
sock,
|
||
vfx: Vec::new(),
|
||
vfx_source: None,
|
||
mouse_broadcast: false,
|
||
mouse_follow: false,
|
||
})
|
||
}
|
||
}
|
||
|
||
pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
|
||
std::fs::create_dir_all(runtime_dir()).ok();
|
||
crate::profile::chmod_runtime_dir();
|
||
if sock.exists() {
|
||
let _ = std::fs::remove_file(&sock);
|
||
}
|
||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer"));
|
||
tracing::info!(
|
||
"profile {} slots={} match class={:?} title={:?}",
|
||
profile.name,
|
||
profile.slots,
|
||
profile.window_match.class,
|
||
profile.window_match.title
|
||
);
|
||
let borderless = profile.layout.borderless;
|
||
let class_re = profile.window_match.class.clone();
|
||
let session = Arc::new(Mutex::new(Session::new(profile, exe, sock.clone())?));
|
||
let (vfx_tx, vfx_rx) = watch::channel(Vec::new());
|
||
let hub = vfx::OverlayHub::new();
|
||
let slot_hub = Arc::new(Mutex::new(OverlayHub::new()));
|
||
|
||
hypr::apply_vfx_window_rules().await.ok();
|
||
if borderless {
|
||
if let Some(class) = class_re {
|
||
hypr::apply_borderless_rules(&class).await.ok();
|
||
}
|
||
}
|
||
|
||
let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
|
||
crate::profile::chmod_socket(&sock);
|
||
tracing::info!("ipc {}", sock.display());
|
||
|
||
let s1 = session.clone();
|
||
tokio::spawn(async move {
|
||
loop {
|
||
match listener.accept().await {
|
||
Ok((stream, _)) => {
|
||
let s = s1.clone();
|
||
tokio::spawn(async move {
|
||
if let Err(e) = handle_ipc(s, stream).await {
|
||
tracing::debug!("ipc: {e}");
|
||
}
|
||
});
|
||
}
|
||
Err(e) => tracing::warn!("accept: {e}"),
|
||
}
|
||
}
|
||
});
|
||
|
||
{
|
||
let hub = hub.clone();
|
||
tokio::spawn(vfx::capture_loop(vfx_rx, hub));
|
||
}
|
||
|
||
let s2 = session.clone();
|
||
let hub2 = hub.clone();
|
||
let slot_hub2 = slot_hub.clone();
|
||
tokio::spawn(async move {
|
||
loop {
|
||
if let Err(e) = refresh_slots(&s2, &vfx_tx, &hub2, &slot_hub2).await {
|
||
tracing::warn!("refresh: {e}");
|
||
}
|
||
sleep(Duration::from_millis(400)).await;
|
||
}
|
||
});
|
||
|
||
let s3 = session.clone();
|
||
let events = hypr::listen_events(move |line| {
|
||
let s = s3.clone();
|
||
async move {
|
||
on_event(&s, &line).await;
|
||
Ok(())
|
||
}
|
||
});
|
||
|
||
tokio::select! {
|
||
r = events => {
|
||
tracing::warn!("hyprland event socket closed: {r:?}");
|
||
}
|
||
_ = tokio::signal::ctrl_c() => {
|
||
tracing::info!("ctrl-c");
|
||
}
|
||
}
|
||
let _ = hypr::clear_binds().await;
|
||
hub.kill_all().await;
|
||
slot_hub.lock().await.kill_all();
|
||
Ok(())
|
||
}
|
||
|
||
async fn refresh_slots(
|
||
session: &Arc<Mutex<Session>>,
|
||
vfx_tx: &watch::Sender<Vec<FeedHit>>,
|
||
hub: &Arc<vfx::OverlayHub>,
|
||
slot_hub: &Arc<Mutex<OverlayHub>>,
|
||
) -> Result<()> {
|
||
let clients = hypr::clients().await?;
|
||
let aw = hypr::active_window().await.ok().flatten();
|
||
let cursor = hypr::cursor_pos().await.ok();
|
||
let mut g = session.lock().await;
|
||
let class_re = g
|
||
.engine
|
||
.profile
|
||
.window_match
|
||
.class
|
||
.as_deref()
|
||
.and_then(|p| Regex::new(p).ok());
|
||
let title_re = g
|
||
.engine
|
||
.profile
|
||
.window_match
|
||
.title
|
||
.as_deref()
|
||
.and_then(|p| Regex::new(p).ok());
|
||
|
||
let has_filter = class_re.is_some() || title_re.is_some();
|
||
let mut matched: Vec<Client> = if !has_filter {
|
||
Vec::new()
|
||
} else {
|
||
clients
|
||
.into_iter()
|
||
.filter(|c| {
|
||
if c.class == "enboxer-vfx" {
|
||
return false;
|
||
}
|
||
let class_ok = class_re
|
||
.as_ref()
|
||
.map(|r| r.is_match(&c.class))
|
||
.unwrap_or(true);
|
||
let title_ok = title_re
|
||
.as_ref()
|
||
.map(|r| r.is_match(&c.title))
|
||
.unwrap_or(true);
|
||
class_ok && title_ok && c.mapped && !c.hidden
|
||
})
|
||
.collect()
|
||
};
|
||
matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid));
|
||
|
||
let n = g.engine.profile.slots as usize;
|
||
let new_slots: Vec<(u32, Client)> = matched
|
||
.into_iter()
|
||
.take(n)
|
||
.enumerate()
|
||
.map(|(i, c)| ((i as u32) + 1, c))
|
||
.collect();
|
||
let old_keys: Vec<(u32, &str)> = g
|
||
.slots
|
||
.iter()
|
||
.map(|(s, c)| (*s, c.address.as_str()))
|
||
.collect();
|
||
let new_keys: Vec<(u32, &str)> = new_slots
|
||
.iter()
|
||
.map(|(s, c)| (*s, c.address.as_str()))
|
||
.collect();
|
||
if old_keys != new_keys {
|
||
for (slot, c) in &new_slots {
|
||
tracing::info!("slot {} {} {} {}", slot, c.class, c.title, c.address);
|
||
}
|
||
}
|
||
g.slots = new_slots;
|
||
|
||
if let Some(aw) = aw {
|
||
let leader = g
|
||
.slots
|
||
.iter()
|
||
.find(|(_, c)| c.address == aw.address)
|
||
.map(|(s, _)| *s);
|
||
if let Some(slot) = leader {
|
||
g.engine.set_leader(slot);
|
||
}
|
||
let managed = g.slots.iter().any(|(_, c)| c.address == aw.address)
|
||
|| aw.class == "enboxer-vfx"
|
||
|| aw.title.starts_with("enboxer-vfx");
|
||
if managed {
|
||
if !g.binds_on {
|
||
let specs = bind_specs(&g)?;
|
||
let sock = g.sock.display().to_string();
|
||
drop(g);
|
||
hypr::replace_binds(&specs, &sock).await?;
|
||
let mut g = session.lock().await;
|
||
g.binds_on = true;
|
||
tracing::info!("routing on ({} binds)", specs.len());
|
||
return finish_vfx(g, vfx_tx, hub, cursor).await;
|
||
}
|
||
} else if g.binds_on {
|
||
g.binds_on = false;
|
||
g.vfx_source = None;
|
||
drop(g);
|
||
hypr::clear_binds().await.ok();
|
||
tracing::info!("routing off (focus left the team)");
|
||
let g = session.lock().await;
|
||
return finish_vfx(g, vfx_tx, hub, cursor).await;
|
||
}
|
||
}
|
||
|
||
finish_vfx(g, vfx_tx, hub, cursor).await?;
|
||
sync_slot_overlays(session, slot_hub).await;
|
||
Ok(())
|
||
}
|
||
|
||
/// Keep the slot-number overlay hub in sync with the current slot set. No-op
|
||
/// when `ENBOXER_ENABLE_OVERLAY` is not set; otherwise the spawn stub logs.
|
||
async fn sync_slot_overlays(
|
||
session: &Arc<Mutex<Session>>,
|
||
slot_hub: &Arc<Mutex<OverlayHub>>,
|
||
) {
|
||
let slots = {
|
||
let g = session.lock().await;
|
||
g.slots.clone()
|
||
};
|
||
let mut hub = slot_hub.lock().await;
|
||
hub.sync(&slots);
|
||
}
|
||
|
||
async fn finish_vfx(
|
||
mut g: tokio::sync::MutexGuard<'_, Session>,
|
||
vfx_tx: &watch::Sender<Vec<FeedHit>>,
|
||
hub: &Arc<vfx::OverlayHub>,
|
||
cursor: Option<(i32, i32)>,
|
||
) -> Result<()> {
|
||
let feeds = if let Some((_, primary)) = g
|
||
.slots
|
||
.iter()
|
||
.find(|(s, _)| *s == g.engine.leader_slot)
|
||
.cloned()
|
||
{
|
||
vfx::build_hits(&g.engine.profile.video_fx, &primary, &g.slots)
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
g.vfx = feeds.clone();
|
||
if let Some((x, y)) = cursor {
|
||
let src = vfx::hit_test(&g.vfx, x, y)
|
||
.filter(|h| h.pass_through)
|
||
.map(|h| h.source_slot);
|
||
if src != g.vfx_source {
|
||
g.vfx_source = src;
|
||
g.binds_on = false;
|
||
}
|
||
}
|
||
drop(g);
|
||
let _ = vfx_tx.send(feeds.clone());
|
||
hub.sync(&feeds).await.ok();
|
||
Ok(())
|
||
}
|
||
|
||
async fn on_event(session: &Arc<Mutex<Session>>, line: &str) {
|
||
if let Some(rest) = line.strip_prefix("activewindowv2>>") {
|
||
let addr = rest.trim();
|
||
let mut g = session.lock().await;
|
||
let want = addr.trim().trim_start_matches("0x");
|
||
let leader = g
|
||
.slots
|
||
.iter()
|
||
.find(|(_, c)| {
|
||
c.address
|
||
.trim()
|
||
.trim_start_matches("0x")
|
||
.eq_ignore_ascii_case(want)
|
||
})
|
||
.map(|(s, _)| *s);
|
||
if let Some(slot) = leader {
|
||
g.engine.set_leader(slot);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn bind_specs(g: &Session) -> Result<Vec<BindSpec>> {
|
||
let passthrough = g.engine.profile.passthrough_set()?;
|
||
let mut specs = Vec::new();
|
||
let bin = g.exe.display().to_string();
|
||
for m in &g.engine.profile.maps {
|
||
let id = passthrough_id(&m.hotkey.0).unwrap_or_else(|_| m.hotkey.0.clone());
|
||
if passthrough.contains(&id) {
|
||
continue;
|
||
}
|
||
let parsed = m.hotkey.parse()?;
|
||
specs.push(BindSpec {
|
||
bind: parsed.hypr_bind(),
|
||
ipc_bin: bin.clone(),
|
||
ipc_args: format!("hotkey {}", m.hotkey.0),
|
||
non_consuming: false,
|
||
release: false,
|
||
});
|
||
if m.hold {
|
||
specs.push(BindSpec {
|
||
bind: parsed.hypr_bind(),
|
||
ipc_bin: bin.clone(),
|
||
ipc_args: format!("hotkey-release {}", m.hotkey.0),
|
||
non_consuming: false,
|
||
release: true,
|
||
});
|
||
}
|
||
}
|
||
if g.engine.mode == Mode::Mirror {
|
||
let keys = if g.engine.profile.repeater.keys.is_empty() {
|
||
clone_key_set()
|
||
} else {
|
||
g.engine.profile.repeater.keys.clone()
|
||
};
|
||
for k in keys {
|
||
let id = passthrough_id(&k).unwrap_or_else(|_| k.clone());
|
||
if passthrough.contains(&id) {
|
||
continue;
|
||
}
|
||
if g.engine.profile.map_by_hotkey(&k).is_some() {
|
||
continue;
|
||
}
|
||
let parsed = hotkey::parse(&k)?;
|
||
specs.push(BindSpec {
|
||
bind: parsed.hypr_bind(),
|
||
ipc_bin: bin.clone(),
|
||
ipc_args: format!("hotkey {k}"),
|
||
non_consuming: true,
|
||
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.engine.mode == Mode::Mirror {
|
||
// Mirror mode always broadcasts clicks to other captured windows
|
||
// (T13). We install the binds independently of `mouse_broadcast`
|
||
// so the behaviour is automatic for the operator.
|
||
for btn in ["mouse:272", "mouse:273"] {
|
||
if specs.iter().any(|s| s.bind == btn) {
|
||
continue;
|
||
}
|
||
specs.push(BindSpec {
|
||
bind: btn.to_string(),
|
||
ipc_bin: bin.clone(),
|
||
ipc_args: format!("mirror-click {btn}"),
|
||
non_consuming: true,
|
||
release: false,
|
||
});
|
||
}
|
||
}
|
||
if g.vfx_source.is_some() {
|
||
for k in vfx_hover_keys() {
|
||
if specs.iter().any(|s| s.bind == k) {
|
||
continue;
|
||
}
|
||
specs.push(BindSpec {
|
||
bind: k.clone(),
|
||
ipc_bin: bin.clone(),
|
||
ipc_args: format!("vfxkey {k}"),
|
||
non_consuming: false,
|
||
release: false,
|
||
});
|
||
}
|
||
}
|
||
Ok(specs)
|
||
}
|
||
|
||
async fn handle_ipc(session: Arc<Mutex<Session>>, stream: tokio::net::UnixStream) -> Result<()> {
|
||
let (r, mut w) = stream.into_split();
|
||
let mut lines = BufReader::new(r).lines();
|
||
while let Some(line) = lines.next_line().await? {
|
||
let reply = dispatch_cmd(&session, &line).await;
|
||
w.write_all(reply.as_bytes()).await?;
|
||
w.write_all(b"\n").await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
|
||
let mut parts = line.splitn(2, ' ');
|
||
let cmd = parts.next().unwrap_or("");
|
||
let arg = parts.next().unwrap_or("").trim();
|
||
match cmd {
|
||
"ping" => "ok".into(),
|
||
"status" => {
|
||
let g = session.lock().await;
|
||
format!(
|
||
"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
|
||
)
|
||
}
|
||
"mode-cycle" => match set_mode(session, None).await {
|
||
Ok(m) => format!("mode={}", m.as_str()),
|
||
Err(e) => format!("err {e}"),
|
||
},
|
||
"mode" => match set_mode(session, Some(arg)).await {
|
||
Ok(m) => format!("mode={}", m.as_str()),
|
||
Err(e) => format!("err {e}"),
|
||
},
|
||
"hotkey" => match fire(session, arg, Hold::Tap).await {
|
||
Ok(()) => "ok".into(),
|
||
Err(e) => format!("err {e}"),
|
||
},
|
||
"hotkey-release" => match fire(session, arg, Hold::Up).await {
|
||
Ok(()) => "ok".into(),
|
||
Err(e) => format!("err {e}"),
|
||
},
|
||
"vfxkey" => {
|
||
if let Some(rest) = arg.strip_prefix("mouse:") {
|
||
match rest.parse::<u32>() {
|
||
Ok(btn) => match fire_vfx_click(session, btn).await {
|
||
Ok(()) => "ok".into(),
|
||
Err(e) => format!("err {e}"),
|
||
},
|
||
Err(_) => format!("err bad mouse {arg}"),
|
||
}
|
||
} else {
|
||
match fire_vfx(session, arg).await {
|
||
Ok(()) => "ok".into(),
|
||
Err(e) => format!("err {e}"),
|
||
}
|
||
}
|
||
}
|
||
"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)
|
||
}
|
||
"mirror-click" => {
|
||
let btn = arg
|
||
.strip_prefix("mouse:")
|
||
.and_then(|s| s.parse::<u32>().ok())
|
||
.unwrap_or(272);
|
||
wm_ok(broadcast_mirror_click(session, btn).await)
|
||
}
|
||
"type" => wm_ok(type_to_others(session, arg).await),
|
||
"clipboard" => wm_ok(clipboard_to_others(session).await),
|
||
_ => format!("err unknown {cmd}"),
|
||
}
|
||
}
|
||
|
||
async fn set_mode(session: &Arc<Mutex<Session>>, name: Option<&str>) -> Result<Mode> {
|
||
let aw = hypr::active_window().await.ok().flatten();
|
||
let (mode, specs, sock, managed) = {
|
||
let mut g = session.lock().await;
|
||
let mode = if let Some(n) = name {
|
||
Mode::parse_name(n).ok_or_else(|| anyhow::anyhow!("unknown mode {n}"))?
|
||
} else {
|
||
g.engine.cycle_mode()
|
||
};
|
||
g.engine.mode = mode;
|
||
let managed = aw.as_ref().is_some_and(|aw| {
|
||
g.slots.iter().any(|(_, c)| c.address == aw.address)
|
||
|| aw.class == "enboxer-vfx"
|
||
|| aw.title.starts_with("enboxer-vfx")
|
||
});
|
||
let specs = if managed { Some(bind_specs(&g)?) } else { None };
|
||
let sock = g.sock.display().to_string();
|
||
(mode, specs, sock, managed)
|
||
};
|
||
if let Some(specs) = specs {
|
||
hypr::replace_binds(&specs, &sock).await?;
|
||
let mut g = session.lock().await;
|
||
g.binds_on = managed;
|
||
}
|
||
hypr::notify(&format!("enBoxer: {}", mode.label()))
|
||
.await
|
||
.ok();
|
||
tracing::info!("mode {}", mode.as_str());
|
||
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 type_to_others(session: &Arc<Mutex<Session>>, text: &str) -> Result<()> {
|
||
let others = {
|
||
let g = session.lock().await;
|
||
others_clients(&g.slots, g.engine.leader_slot)
|
||
};
|
||
for key in crate::hotkey::type_keys(text) {
|
||
for c in &others {
|
||
hypr::deliver_key(c, &key, None).await.ok();
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Pure: the captured slots that are NOT the current leader. Both `type_to_others`
|
||
/// and `clipboard_to_others` route through this so the "exclude leader" shape is
|
||
/// testable without a session.
|
||
pub fn others_clients(slots: &[(u32, Client)], leader: u32) -> Vec<Client> {
|
||
slots
|
||
.iter()
|
||
.filter(|(s, _)| *s != leader)
|
||
.map(|(_, c)| c.clone())
|
||
.collect()
|
||
}
|
||
|
||
/// Pure: the non-leader captured slots that should receive a mirror-mode
|
||
/// mouse click for `button`. The leader already got it (the user clicked
|
||
/// on the primary). Returns `(target_client, button)` pairs in slot-number
|
||
/// order so the daemon's click loop is deterministic. The X/Y of the
|
||
/// click are forwarded from the cursor position in
|
||
/// `broadcast_mirror_click`.
|
||
///
|
||
/// Press-and-hold guard is OUT OF SCOPE for T13 (Master has not requested
|
||
/// it yet); the caller should hold the mouse binds as a follow-up.
|
||
/// T13-todo: press-and-hold guard — repeated button-down without release
|
||
/// should re-fire on a configurable cadence.
|
||
pub fn mirror_clicks_to(
|
||
slots: &[(u32, Client)],
|
||
leader: u32,
|
||
button: u32,
|
||
) -> Vec<(Client, u32)> {
|
||
slots
|
||
.iter()
|
||
.filter(|(s, _)| *s != leader)
|
||
.map(|(_, c)| (c.clone(), button))
|
||
.collect()
|
||
}
|
||
|
||
async fn clipboard_to_others(session: &Arc<Mutex<Session>>) -> Result<()> {
|
||
let text = read_clipboard().await?;
|
||
let others = {
|
||
let g = session.lock().await;
|
||
others_clients(&g.slots, g.engine.leader_slot)
|
||
};
|
||
for c in &others {
|
||
hypr::deliver_key(c, "Ctrl+v", None).await.ok();
|
||
}
|
||
tracing::info!("clipboard broadcast: {} bytes to {} slot(s)", text.len(), others.len());
|
||
Ok(())
|
||
}
|
||
|
||
async fn read_clipboard() -> Result<String> {
|
||
use std::process::Stdio;
|
||
use tokio::process::Command;
|
||
for cmd in ["wl-paste", "xclip"] {
|
||
let args: &[&str] = if cmd == "xclip" {
|
||
&["-o", "-selection", "clipboard"]
|
||
} else {
|
||
&["-n"]
|
||
};
|
||
let out = Command::new(cmd)
|
||
.args(args)
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.output()
|
||
.await;
|
||
if let Ok(out) = out {
|
||
if out.status.success() {
|
||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
||
}
|
||
}
|
||
}
|
||
anyhow::bail!("no clipboard tool (install wl-paste or xclip)")
|
||
}
|
||
|
||
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(())
|
||
}
|
||
|
||
/// Mirror-mode click broadcast: when `mode == Mirror`, every click on the
|
||
/// primary is also delivered to every other captured slot at the
|
||
/// matching relative position. Independent of the `mouse_broadcast`
|
||
/// toggle — mirror mode always does this. The cursor moves to each
|
||
/// target's relative position, the click is delivered (focus, send,
|
||
/// restore via `hypr::deliver_click` for native Wayland; direct for
|
||
/// XWayland), and the cursor returns to where the user clicked.
|
||
async fn broadcast_mirror_click(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
|
||
let (cx, cy, plan) = {
|
||
let g = session.lock().await;
|
||
if g.engine.mode != Mode::Mirror {
|
||
return Ok(());
|
||
}
|
||
let Some((_, primary)) = g.slots.iter().find(|(s, _)| *s == g.engine.leader_slot) else {
|
||
return Ok(());
|
||
};
|
||
let cx = hypr::cursor_pos().await?.0;
|
||
let cy = hypr::cursor_pos().await?.1;
|
||
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;
|
||
let plan: Vec<(Client, i32, i32)> = mirror_clicks_to(&g.slots, g.engine.leader_slot, button)
|
||
.into_iter()
|
||
.map(|(c, _btn)| {
|
||
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, x, y)
|
||
})
|
||
.collect();
|
||
(cx, cy, plan)
|
||
};
|
||
for (c, x, y) in plan {
|
||
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> {
|
||
vfx_hover_keys()
|
||
.into_iter()
|
||
.filter(|k| !k.starts_with("mouse:"))
|
||
.collect()
|
||
}
|
||
|
||
fn vfx_hover_keys() -> Vec<String> {
|
||
let mut v: Vec<String> = [
|
||
"Space",
|
||
"Return",
|
||
"Escape",
|
||
"Tab",
|
||
"BackSpace",
|
||
"grave",
|
||
"minus",
|
||
"equal",
|
||
"mouse:272",
|
||
"mouse:273",
|
||
]
|
||
.into_iter()
|
||
.map(str::to_string)
|
||
.collect();
|
||
for c in b'a'..=b'z' {
|
||
v.push((c as char).to_string());
|
||
}
|
||
for c in b'0'..=b'9' {
|
||
v.push((c as char).to_string());
|
||
}
|
||
v
|
||
}
|
||
|
||
async fn fire_vfx(session: &Arc<Mutex<Session>>, hotkey: &str) -> Result<()> {
|
||
let (src, slots) = {
|
||
let g = session.lock().await;
|
||
let src = g
|
||
.vfx_source
|
||
.ok_or_else(|| anyhow::anyhow!("no vfx hover"))?;
|
||
(src, g.slots.clone())
|
||
};
|
||
execute(
|
||
vec![Action::Send {
|
||
key: hotkey.to_string(),
|
||
slots: vec![src],
|
||
hold: Hold::Tap,
|
||
}],
|
||
&slots,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn fire_vfx_click(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
|
||
let (cx, cy) = hypr::cursor_pos().await?;
|
||
let (feed, src) = {
|
||
let g = session.lock().await;
|
||
let slot = g
|
||
.vfx_source
|
||
.ok_or_else(|| anyhow::anyhow!("no vfx hover"))?;
|
||
let feed = vfx::hit_test(&g.vfx, cx, cy)
|
||
.filter(|h| h.pass_through && h.source_slot == slot)
|
||
.ok_or_else(|| anyhow::anyhow!("vfx hover lost"))?
|
||
.clone();
|
||
let src = g
|
||
.slots
|
||
.iter()
|
||
.find(|(s, _)| *s == slot)
|
||
.map(|(_, c)| c.clone())
|
||
.ok_or_else(|| anyhow::anyhow!("source slot gone"))?;
|
||
(feed, src)
|
||
};
|
||
let (sx, sy) = vfx::map_click(&feed, cx, cy);
|
||
hypr::move_cursor(sx, sy).await?;
|
||
hypr::deliver_click(&src, button).await?;
|
||
hypr::move_cursor(cx, cy).await?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn fire(session: &Arc<Mutex<Session>>, hotkey: &str, edge: Hold) -> Result<()> {
|
||
let (actions, slots) = {
|
||
let mut g = session.lock().await;
|
||
if let Some(src) = g.vfx_source {
|
||
if g.engine.profile.map_by_hotkey(hotkey).is_none()
|
||
&& g.engine
|
||
.profile
|
||
.session_hotkeys
|
||
.values()
|
||
.all(|h| !h.eq_ignore_ascii_case(hotkey))
|
||
{
|
||
let hold = if matches!(edge, Hold::Up) {
|
||
Hold::Up
|
||
} else {
|
||
Hold::Tap
|
||
};
|
||
(
|
||
vec![Action::Send {
|
||
key: hotkey.to_string(),
|
||
slots: vec![src],
|
||
hold,
|
||
}],
|
||
g.slots.clone(),
|
||
)
|
||
} else {
|
||
(g.engine.fire(hotkey, edge)?, g.slots.clone())
|
||
}
|
||
} else {
|
||
(g.engine.fire(hotkey, edge)?, g.slots.clone())
|
||
}
|
||
};
|
||
execute(actions, &slots).await
|
||
}
|
||
|
||
async fn execute(actions: Vec<Action>, slots: &[(u32, Client)]) -> Result<()> {
|
||
for a in actions {
|
||
match a {
|
||
Action::Sleep(d) => sleep(d).await,
|
||
Action::Send {
|
||
key,
|
||
slots: ids,
|
||
hold,
|
||
} => {
|
||
let parsed_state = match hold {
|
||
Hold::Down => Some("down"),
|
||
Hold::Up => Some("up"),
|
||
Hold::Tap => None,
|
||
};
|
||
for id in ids {
|
||
if let Some((_, c)) = slots.iter().find(|(s, _)| *s == id) {
|
||
// Per-slot failures must NOT abort the rest of the
|
||
// chain. For example, a smart_interact (CTM on -> Alt+J
|
||
// -> sleep -> CTM off) must keep going through every
|
||
// captured slot even if one wlr-keyboard barf means
|
||
// Alt+J never lands on that client — otherwise CTM
|
||
// can stay on for the survivors and the user has to
|
||
// manually reset it.
|
||
if let Err(e) =
|
||
hypr::deliver_key(c, &key, parsed_state).await
|
||
{
|
||
tracing::warn!(
|
||
"deliver_key slot {id} key {key:?} failed: {e}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn ipc_send(sock: &Path, line: &str) -> Result<String> {
|
||
use tokio::io::AsyncBufReadExt;
|
||
let mut stream = tokio::net::UnixStream::connect(sock)
|
||
.await
|
||
.with_context(|| format!("connect {}", sock.display()))?;
|
||
stream.write_all(line.as_bytes()).await?;
|
||
stream.write_all(b"\n").await?;
|
||
let mut reader = BufReader::new(stream);
|
||
let mut reply = String::new();
|
||
reader.read_line(&mut reply).await?;
|
||
Ok(reply.trim().to_string())
|
||
}
|
||
|
||
pub fn default_sock() -> PathBuf {
|
||
runtime_dir().join("enboxer.sock")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::hypr::Client;
|
||
|
||
fn client(addr: &str, at: (i32, i32)) -> Client {
|
||
Client {
|
||
address: addr.into(),
|
||
class: "wow".into(),
|
||
title: addr.into(),
|
||
pid: 1,
|
||
at: [at.0, at.1],
|
||
size: [800, 600],
|
||
mapped: true,
|
||
hidden: false,
|
||
xwayland: true,
|
||
focus_history_id: 0,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn others_excludes_leader() {
|
||
let slots = vec![
|
||
(1, client("0xa", (0, 0))),
|
||
(2, client("0xb", (1000, 0))),
|
||
(3, client("0xc", (0, 1000))),
|
||
];
|
||
let others = others_clients(&slots, 2);
|
||
assert_eq!(others.len(), 2);
|
||
assert_eq!(others[0].address, "0xa");
|
||
assert_eq!(others[1].address, "0xc");
|
||
}
|
||
|
||
#[test]
|
||
fn others_is_empty_when_only_leader() {
|
||
let slots = vec![(1, client("0xa", (0, 0)))];
|
||
assert!(others_clients(&slots, 1).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn type_routing_plan_covers_every_other_slot_per_char() {
|
||
// 2 others, clipboard-shaped text "Hi\n" (3 type_keys after expansion).
|
||
let slots = vec![
|
||
(1, client("0xa", (0, 0))),
|
||
(2, client("0xb", (1000, 0))),
|
||
(3, client("0xc", (0, 1000))),
|
||
];
|
||
let text = "Hi\n";
|
||
let keys = crate::hotkey::type_keys(text);
|
||
assert_eq!(keys, vec!["Shift+h".to_string(), "i".into(), "Return".into()]);
|
||
let plan: Vec<(String, String)> = others_clients(&slots, 1)
|
||
.iter()
|
||
.flat_map(|c| keys.iter().map(move |k| (c.address.clone(), k.clone())))
|
||
.collect();
|
||
// 2 others × 3 keys = 6 deliver_key calls.
|
||
assert_eq!(plan.len(), 6);
|
||
// Each "other" gets every key.
|
||
for c in ["0xb", "0xc"] {
|
||
let ks: Vec<&String> = plan
|
||
.iter()
|
||
.filter(|(addr, _)| addr == c)
|
||
.map(|(_, k)| k)
|
||
.collect();
|
||
let expected: Vec<&String> = keys.iter().collect();
|
||
assert_eq!(ks, expected);
|
||
}
|
||
// Leader "0xa" never appears in the routing plan.
|
||
assert!(plan.iter().all(|(addr, _)| addr != "0xa"));
|
||
}
|
||
|
||
#[test]
|
||
fn clipboard_routes_ctrl_v_to_each_other_slot() {
|
||
let slots = vec![
|
||
(1, client("0xa", (0, 0))),
|
||
(2, client("0xb", (1000, 0))),
|
||
(3, client("0xc", (0, 1000))),
|
||
];
|
||
let others = others_clients(&slots, 2);
|
||
let plan: Vec<(&Client, &str)> =
|
||
others.iter().map(|c| (c, "Ctrl+v")).collect();
|
||
assert_eq!(plan.len(), 2);
|
||
assert_eq!(plan[0].0.address, "0xa");
|
||
assert_eq!(plan[1].0.address, "0xc");
|
||
}
|
||
|
||
#[test]
|
||
fn mirror_clicks_to_excludes_leader() {
|
||
let slots = vec![
|
||
(1, client("0xa", (0, 0))),
|
||
(2, client("0xb", (1000, 0))),
|
||
(3, client("0xc", (0, 1000))),
|
||
];
|
||
let plan = mirror_clicks_to(&slots, 2, 272);
|
||
assert_eq!(plan.len(), 2);
|
||
assert_eq!(plan[0].0.address, "0xa");
|
||
assert_eq!(plan[0].1, 272);
|
||
assert_eq!(plan[1].0.address, "0xc");
|
||
assert_eq!(plan[1].1, 272);
|
||
// Leader ("0xb") is never in the plan.
|
||
assert!(plan.iter().all(|(c, _)| c.address != "0xb"));
|
||
}
|
||
|
||
#[test]
|
||
fn mirror_clicks_to_is_empty_when_only_leader() {
|
||
let slots = vec![(1, client("0xa", (0, 0)))];
|
||
assert!(mirror_clicks_to(&slots, 1, 273).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn mirror_clicks_to_carries_button_code() {
|
||
let slots = vec![
|
||
(1, client("0xa", (0, 0))),
|
||
(2, client("0xb", (1000, 0))),
|
||
];
|
||
let plan = mirror_clicks_to(&slots, 1, 273);
|
||
assert_eq!(plan.len(), 1);
|
||
assert_eq!(plan[0].1, 273, "right-click survives the filter");
|
||
}
|
||
}
|