breadhelp/src/services/hyprland.rs
Breadway 7b7fe680fe Migrate config load/save to bread_utils::tomlcfg; timeout-guard hyprctl calls
config.rs's config_dir/load_doc/save_doc/atomic_write were a byte-for-byte
copy of bos-settings/src/config/mod.rs's versions (own doc comment said as
much) introduced in the same fix pass that added them there — now both
delegate to bread_utils::tomlcfg/bread_utils::atomic instead of each
carrying their own copy (path dependency for now, see the TODO in
Cargo.toml).

Also: services/hyprland.rs::query_json used a bare Command::output() with
no timeout, so an unresponsive hyprctl (Hyprland wedged/reloading) could
block clients()/monitors()/layers() indefinitely — switched to
bread_utils::proc::run_json with a 3s cap.

Builds clean; all 8 existing tests pass.
2026-07-17 09:32:55 +08:00

97 lines
3.3 KiB
Rust

use serde::Deserialize;
use std::process::Command;
/// Runtime-only rebind via `hyprctl keyword bind`: takes effect immediately
/// and resets on the next Hyprland reload/login. `hyprland.lua` can't safely
/// be rewritten line-by-line by a program (hand-authored Lua, arbitrary
/// formatting), so this is the only rebind path — callers must label it
/// clearly as temporary in the UI.
///
/// `bind_value` is the full `<mods>,<key>,<dispatcher>,<args>` string, e.g.
/// `"SUPER,U,exec,breadpad"`.
pub fn rebind_temp(bind_value: &str) {
if let Err(e) = Command::new("hyprctl").args(["keyword", "bind", bind_value]).spawn() {
eprintln!("breadhelp: hyprctl rebind failed: {e}");
}
}
#[derive(Debug, Deserialize)]
pub struct Client {
pub class: String,
pub at: (i32, i32),
pub size: (i32, i32),
/// Numeric monitor id — join against `monitors()` by `Monitor::id` for a name.
pub monitor: i32,
}
#[derive(Debug, Deserialize)]
pub struct Monitor {
pub id: i32,
pub name: String,
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
pub focused: bool,
}
#[derive(Debug, Clone)]
pub struct LayerClient {
pub namespace: String,
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
pub monitor: String,
}
fn query_json(cmd: &str) -> Option<serde_json::Value> {
// Timeout-guarded: an unresponsive hyprctl (Hyprland wedged/reloading)
// used to be able to block clients()/monitors()/layers() indefinitely.
bread_utils::proc::run_json("hyprctl", &["-j", cmd], std::time::Duration::from_secs(3))
}
pub fn clients() -> Vec<Client> {
query_json("clients").and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default()
}
pub fn monitors() -> Vec<Monitor> {
query_json("monitors").and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default()
}
/// `hyprctl -j layers` nests surfaces as `{monitor_name: {levels: {level: [surface]}}}`
/// with no fixed schema the `hyprland` crate or any sibling repo has a typed struct
/// for, so this walks the `serde_json::Value` by hand instead of deriving `Deserialize`.
pub fn layers() -> Vec<LayerClient> {
let Some(serde_json::Value::Object(by_monitor)) = query_json("layers") else {
return Vec::new();
};
let mut out = Vec::new();
for (monitor_name, monitor_val) in by_monitor {
let Some(levels) = monitor_val.get("levels").and_then(|v| v.as_object()) else {
continue;
};
for entries in levels.values().filter_map(|v| v.as_array()) {
for entry in entries {
let fields = (
entry.get("namespace").and_then(|v| v.as_str()),
entry.get("x").and_then(|v| v.as_i64()),
entry.get("y").and_then(|v| v.as_i64()),
entry.get("w").and_then(|v| v.as_i64()),
entry.get("h").and_then(|v| v.as_i64()),
);
if let (Some(namespace), Some(x), Some(y), Some(w), Some(h)) = fields {
out.push(LayerClient {
namespace: namespace.to_string(),
x: x as i32,
y: y as i32,
w: w as i32,
h: h as i32,
monitor: monitor_name.clone(),
});
}
}
}
}
out
}