Some checks are pending
Release & Packaging / Build Linux Packages (.tar.gz, .deb, .rpm, .AppImage) (push) Waiting to run
Release & Packaging / Build Windows Binaries (.exe) (push) Waiting to run
Release & Packaging / Publish GitHub Release (push) Blocked by required conditions
387 lines
12 KiB
Rust
387 lines
12 KiB
Rust
#[cfg(unix)]
|
|
use std::process::Command;
|
|
#[cfg(unix)]
|
|
use std::env;
|
|
#[cfg(unix)]
|
|
use std::io::{BufRead, BufReader};
|
|
#[cfg(unix)]
|
|
use std::os::unix::net::UnixStream;
|
|
#[cfg(unix)]
|
|
use std::path::PathBuf;
|
|
use crate::config::ScytheConfig;
|
|
pub type VrecConfig = ScytheConfig;
|
|
|
|
pub fn is_hyprland() -> bool {
|
|
#[cfg(unix)]
|
|
{
|
|
env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok()
|
|
|| env::var("XDG_CURRENT_DESKTOP")
|
|
.map(|d| d.to_lowercase().contains("hyprland"))
|
|
.unwrap_or(false)
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Convert standard hotkey format (e.g. "Ctrl+Shift+R") to Hyprland bind format ("CONTROL_SHIFT, R")
|
|
pub fn hotkey_to_hyprland(hotkey: &str) -> Option<(String, String)> {
|
|
let parts: Vec<&str> = hotkey.split('+').map(|s| s.trim()).collect();
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut mods = Vec::new();
|
|
let mut key = "";
|
|
|
|
for (i, &part) in parts.iter().enumerate() {
|
|
if i == parts.len() - 1 {
|
|
key = part;
|
|
} else {
|
|
let m = match part.to_lowercase().as_str() {
|
|
"ctrl" | "control" => "CONTROL",
|
|
"shift" => "SHIFT",
|
|
"alt" => "ALT",
|
|
"super" | "win" | "meta" => "SUPER",
|
|
_ => continue,
|
|
};
|
|
mods.push(m);
|
|
}
|
|
}
|
|
|
|
if key.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mod_str = if mods.is_empty() {
|
|
"".to_string()
|
|
} else {
|
|
mods.join("_")
|
|
};
|
|
|
|
Some((mod_str, key.to_string()))
|
|
}
|
|
|
|
pub fn hotkey_to_hyprland_lua(hotkey: &str) -> Option<String> {
|
|
let parts: Vec<&str> = hotkey.split('+').map(|s| s.trim()).collect();
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut mods = Vec::new();
|
|
let mut key = "";
|
|
|
|
for (i, &part) in parts.iter().enumerate() {
|
|
if i == parts.len() - 1 {
|
|
key = part;
|
|
} else {
|
|
let m = match part.to_lowercase().as_str() {
|
|
"ctrl" | "control" => "CONTROL",
|
|
"shift" => "SHIFT",
|
|
"alt" => "ALT",
|
|
"super" | "win" | "meta" => "SUPER",
|
|
_ => continue,
|
|
};
|
|
mods.push(m);
|
|
}
|
|
}
|
|
|
|
if key.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
if mods.is_empty() {
|
|
Some(key.to_string())
|
|
} else {
|
|
Some(format!("{} + {}", mods.join(" + "), key))
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
static RULES_INITIALIZED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
|
|
#[cfg(unix)]
|
|
/// Dynamically inject binds and window rules into running Hyprland without touching hyprland.conf
|
|
pub fn register_hyprland_binds(config: &ScytheConfig) {
|
|
if !is_hyprland() {
|
|
return;
|
|
}
|
|
|
|
if !RULES_INITIALIZED.swap(true, std::sync::atomic::Ordering::SeqCst) {
|
|
// Register modern Hyprland (0.55+) layer rules for genuine frosted glass blur & floating window rules
|
|
let _ = Command::new("hyprctl")
|
|
.args(["eval", r#"hl.layer_rule({ match = { namespace = "scythe-overlay" }, blur = true, ignore_alpha = 0.1 })"#])
|
|
.output();
|
|
let _ = Command::new("hyprctl")
|
|
.args(["eval", r#"hl.layer_rule({ match = { namespace = "scythe-notification" }, blur = true, ignore_alpha = 0.1 })"#])
|
|
.output();
|
|
let _ = Command::new("hyprctl")
|
|
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-overlay)$" }, float = true, pin = true, move = {"0", "0"}, size = {"100%", "100%"}, no_anim = true, border = false, shadow = false, rounding = 0 })"#])
|
|
.output();
|
|
let _ = Command::new("hyprctl")
|
|
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-toast)$" }, float = true, pin = true, move = {"(monitor_w-340)", "16"}, no_anim = true, border = false, shadow = false, rounding = 0 })"#])
|
|
.output();
|
|
let _ = Command::new("hyprctl")
|
|
.args(["eval", r#"hl.window_rule({ match = { title = "Select Recordings Directory" }, float = true, pin = true, stay_focused = true, center = true })"#])
|
|
.output();
|
|
let _ = Command::new("hyprctl")
|
|
.args(["eval", r#"hl.window_rule({ match = { class = "kdialog" }, float = true, pin = true, stay_focused = true, center = true })"#])
|
|
.output();
|
|
let _ = Command::new("hyprctl")
|
|
.args(["eval", r#"hl.window_rule({ match = { class = "org.kde.kdialog" }, float = true, pin = true, stay_focused = true, center = true })"#])
|
|
.output();
|
|
let _ = Command::new("hyprctl")
|
|
.args(["eval", r#"hl.layer_rule({ match = { namespace = "vrec-overlay" }, blur = true, ignore_alpha = 0.1 })"#])
|
|
.output();
|
|
|
|
// Also register legacy window rules for backward compatibility with older Hyprland versions
|
|
let legacy_rules = [
|
|
"float, class:^(scythe-overlay)$",
|
|
"move 0 0, class:^(scythe-overlay)$",
|
|
"size 100% 100%, class:^(scythe-overlay)$",
|
|
"pin, class:^(scythe-overlay)$",
|
|
"noborder, class:^(scythe-overlay)$",
|
|
"noshadow, class:^(scythe-overlay)$",
|
|
"rounding 0, class:^(scythe-overlay)$",
|
|
"stayfocused, class:^(scythe-overlay)$",
|
|
"float, title:^(Select Recordings Directory)$",
|
|
"pin, title:^(Select Recordings Directory)$",
|
|
"stayfocused, title:^(Select Recordings Directory)$",
|
|
"center, title:^(Select Recordings Directory)$",
|
|
"float, class:^(kdialog)$",
|
|
"pin, class:^(kdialog)$",
|
|
"stayfocused, class:^(kdialog)$",
|
|
"center, class:^(kdialog)$",
|
|
"float, class:^(org.kde.kdialog)$",
|
|
"pin, class:^(org.kde.kdialog)$",
|
|
"stayfocused, class:^(org.kde.kdialog)$",
|
|
"center, class:^(org.kde.kdialog)$",
|
|
"float, class:^(scythe-hud)$",
|
|
"move 0 0, class:^(scythe-hud)$",
|
|
"size 100% 100%, class:^(scythe-hud)$",
|
|
"pin, class:^(scythe-hud)$",
|
|
"float, class:^(scythe-toast)$",
|
|
"pin, class:^(scythe-toast)$",
|
|
"noborder, class:^(scythe-toast)$",
|
|
"move 100%-w 16, class:^(scythe-toast)$",
|
|
"float, class:^(vrec-overlay)$",
|
|
"move 0 0, class:^(vrec-overlay)$",
|
|
"size 100% 100%, class:^(vrec-overlay)$",
|
|
"pin, class:^(vrec-overlay)$",
|
|
"noborder, class:^(vrec-overlay)$",
|
|
];
|
|
for rule in legacy_rules {
|
|
let _ = Command::new("hyprctl")
|
|
.args(["keyword", "windowrulev2", rule])
|
|
.output();
|
|
}
|
|
}
|
|
|
|
let binds = [
|
|
(&config.menu_hotkey, "scythe-ui --menu"),
|
|
(&config.save_hotkey, "scythe-ui --save"),
|
|
(&config.record_hotkey, "scythe-ui --record"),
|
|
(&config.cursor_hotkey, "scythe-ui --cursor"),
|
|
];
|
|
|
|
for (hotkey, cmd) in binds {
|
|
unbind_hotkey(hotkey);
|
|
|
|
// Try modern Hyprland Lua eval first
|
|
if let Some(lua_combo) = hotkey_to_hyprland_lua(hotkey) {
|
|
let lua_script = format!(r#"hl.bind("{}", hl.dsp.exec_cmd("{}"))"#, lua_combo, cmd);
|
|
if let Ok(out) = Command::new("hyprctl").args(["eval", &lua_script]).output()
|
|
&& out.status.success() && String::from_utf8_lossy(&out.stdout).contains("ok") {
|
|
println!("Hyprland dynamic bind active (Lua): {}", lua_combo);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Fallback to legacy keyword bind
|
|
if let Some((mods, key)) = hotkey_to_hyprland(hotkey) {
|
|
let bind_arg = if mods.is_empty() {
|
|
format!("{}, exec, {}", key, cmd)
|
|
} else {
|
|
format!("{}, {}, exec, {}", mods, key, cmd)
|
|
};
|
|
let res = Command::new("hyprctl")
|
|
.args(["keyword", "bind", &bind_arg])
|
|
.output();
|
|
if let Ok(out) = res && out.status.success() {
|
|
println!("Hyprland dynamic bind active (legacy): {}", bind_arg);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub fn register_hyprland_binds_async(config: &ScytheConfig) {
|
|
let cfg = config.clone();
|
|
std::thread::spawn(move || {
|
|
register_hyprland_binds(&cfg);
|
|
});
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub fn unbind_hotkey_async(hotkey: &str) {
|
|
let hk = hotkey.to_string();
|
|
std::thread::spawn(move || {
|
|
unbind_hotkey(&hk);
|
|
});
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub fn register_hyprland_binds(_config: &ScytheConfig) {}
|
|
#[cfg(not(unix))]
|
|
pub fn register_hyprland_binds_async(_config: &ScytheConfig) {}
|
|
#[cfg(not(unix))]
|
|
pub fn unbind_hotkey_async(_hotkey: &str) {}
|
|
|
|
#[cfg(unix)]
|
|
/// Unbind a specific hotkey combo from running Hyprland
|
|
pub fn unbind_hotkey(hotkey: &str) {
|
|
if !is_hyprland() {
|
|
return;
|
|
}
|
|
|
|
if let Some(lua_combo) = hotkey_to_hyprland_lua(hotkey) {
|
|
let lua_script = format!(
|
|
r#"for i = 1, 10 do pcall(function() hl.unbind("{}") end) end"#,
|
|
lua_combo
|
|
);
|
|
let _ = Command::new("hyprctl")
|
|
.args(["eval", &lua_script])
|
|
.output();
|
|
}
|
|
if let Some((mods, key)) = hotkey_to_hyprland(hotkey) {
|
|
let unbind_arg = if mods.is_empty() {
|
|
key
|
|
} else {
|
|
format!("{}, {}", mods, key)
|
|
};
|
|
let _ = Command::new("hyprctl")
|
|
.args(["keyword", "unbind", &unbind_arg])
|
|
.output();
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub fn unbind_hotkey(_hotkey: &str) {}
|
|
|
|
#[cfg(unix)]
|
|
/// Dynamically remove binds from running Hyprland
|
|
pub fn unregister_hyprland_binds(config: &ScytheConfig) {
|
|
if !is_hyprland() {
|
|
return;
|
|
}
|
|
|
|
let hotkeys = [
|
|
&config.menu_hotkey,
|
|
&config.save_hotkey,
|
|
&config.record_hotkey,
|
|
&config.cursor_hotkey,
|
|
];
|
|
|
|
for hotkey in hotkeys {
|
|
unbind_hotkey(hotkey);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub fn unregister_hyprland_binds(_config: &ScytheConfig) {}
|
|
|
|
#[cfg(unix)]
|
|
/// Find Hyprland socket2 for live event listening
|
|
fn get_hyprland_socket2() -> Option<PathBuf> {
|
|
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
|
let xdg_runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
|
|
|
|
let path = PathBuf::from(format!("{}/hypr/{}/.socket2.sock", xdg_runtime, sig));
|
|
if path.exists() {
|
|
return Some(path);
|
|
}
|
|
let fallback = PathBuf::from(format!("/tmp/hypr/{}/.socket2.sock", sig));
|
|
if fallback.exists() {
|
|
return Some(fallback);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
/// Background watcher for Hyprland config reloads so dynamic binds are never lost
|
|
pub fn spawn_hyprland_reload_watcher() {
|
|
if !is_hyprland() {
|
|
return;
|
|
}
|
|
|
|
std::thread::spawn(|| {
|
|
loop {
|
|
if let Some(sock_path) = get_hyprland_socket2()
|
|
&& let Ok(stream) = UnixStream::connect(&sock_path) {
|
|
let reader = BufReader::new(stream);
|
|
for line in reader.lines().map_while(Result::ok) {
|
|
if line.starts_with("configreloaded") {
|
|
println!("Hyprland config reload detected! Re-registering scythe dynamic binds...");
|
|
let config = ScytheConfig::load();
|
|
register_hyprland_binds(&config);
|
|
}
|
|
}
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub fn spawn_hyprland_reload_watcher() {}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_hotkey_to_hyprland_conversion() {
|
|
assert_eq!(
|
|
hotkey_to_hyprland("Ctrl+Shift+R"),
|
|
Some(("CONTROL_SHIFT".to_string(), "R".to_string()))
|
|
);
|
|
assert_eq!(
|
|
hotkey_to_hyprland("Alt+Z"),
|
|
Some(("ALT".to_string(), "Z".to_string()))
|
|
);
|
|
assert_eq!(
|
|
hotkey_to_hyprland("Ctrl+Shift+F9"),
|
|
Some(("CONTROL_SHIFT".to_string(), "F9".to_string()))
|
|
);
|
|
assert_eq!(
|
|
hotkey_to_hyprland("F12"),
|
|
Some(("".to_string(), "F12".to_string()))
|
|
);
|
|
assert_eq!(
|
|
hotkey_to_hyprland("Super+Alt+S"),
|
|
Some(("SUPER_ALT".to_string(), "S".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_hotkey_to_hyprland_lua_conversion() {
|
|
assert_eq!(
|
|
hotkey_to_hyprland_lua("Ctrl+Shift+R"),
|
|
Some("CONTROL + SHIFT + R".to_string())
|
|
);
|
|
assert_eq!(
|
|
hotkey_to_hyprland_lua("Alt+Z"),
|
|
Some("ALT + Z".to_string())
|
|
);
|
|
assert_eq!(
|
|
hotkey_to_hyprland_lua("F12"),
|
|
Some("F12".to_string())
|
|
);
|
|
assert_eq!(
|
|
hotkey_to_hyprland_lua("Super+Alt+S"),
|
|
Some("SUPER + ALT + S".to_string())
|
|
);
|
|
}
|
|
}
|
|
|