use std::collections::HashMap; use std::path::PathBuf; use std::process::Command; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::sync::{Mutex, OnceLock, PoisonError}; use std::time::Duration; use std::{fs, thread}; use amane::Service; use crate::config; use crate::parse::{self, HyprKind, Value}; use crate::theme; /* * Hyprland's own options in the config window. the list and its help come from Hyprland * (`hyprctl descriptions`, `getoption`), so nothing here names an option. a change is * kept in config.toml under [hyprland.*], applied at once with `hl.config` and written * to ~/.config/hypr/amane.lua, which hyprland.lua loads last with require("amane"), so * Hyprland's config keeps owning them */ pub struct HyprOption { // "decoration:blur:size"; &'static because text inputs are named by it pub name: &'static str, pub help: String, pub kind: HyprKind, pub default: String, pub range: Option<(f64, f64)>, // named values for a number, like resize_corner's top_left = 1 pub choices: Vec<(String, i64)>, pub current: String, // Hyprland's own config sets it pub in_config: bool, } impl HyprOption { // "decoration.blur.size", as hl.config names it pub fn key(&self) -> String { self.name.replace(':', ".") } pub fn path(&self) -> String { format!("hyprland.{}", self.key()) } pub fn category(&self) -> &'static str { self.name.split(':').next().unwrap_or("") } // "blur" for decoration:blur:size; options right under a category have none pub fn group(&self) -> &'static str { let parts: Vec<&'static str> = self.name.split(':').collect(); if parts.len() > 2 { parts[1] } else { "" } } pub fn label(&self) -> &'static str { self.name.rsplit(':').next().unwrap_or(self.name) } } #[derive(Default)] pub struct Hypr { pub options: Vec, pub loaded: bool, // the last change's result: (what happened, whether it went wrong) pub message: Option<(String, bool)>, // hyprland.lua loads amane.lua, so changes outlive a reload pub required: bool, } impl Service for Hypr { fn new() -> Self { Self::default() } fn listen() {} } enum Job { Load, // an option as Hyprland names it (decoration:blur:size) or as config.toml does (decoration.blur.size) Set(String, String), Reset(String), Sync, } // one worker does every hyprctl call, in order; it starts with the first job fn send(job: Job) { static JOBS: OnceLock> = OnceLock::new(); let jobs = JOBS.get_or_init(|| { let (sender, receiver) = mpsc::channel(); thread::spawn(move || work(&receiver)); sender }); let _ = jobs.send(job); } // reads every option and its value, for the window pub fn load() { send(Job::Load); } pub fn set(name: &str, text: &str) { send(Job::Set(name.to_string(), text.to_string())); } pub fn reset(name: &str) { send(Job::Reset(name.to_string())); } // config.toml was read: write amane.lua to match, once things settle pub fn sync_soon() { send(Job::Sync); } /* * a slider sends many values a second: only the newest per option is applied, and * amane.lua is written once nothing has changed for half a second, since Hyprland may * reload its whole config whenever a file it loaded changes */ fn work(jobs: &Receiver) { let mut unwritten = false; loop { let first = match jobs.recv_timeout(Duration::from_millis(500)) { Ok(job) => job, Err(RecvTimeoutError::Timeout) => { if unwritten { write_lua(); unwritten = false; } continue; } Err(RecvTimeoutError::Disconnected) => return, }; let mut batch: Vec = vec![first]; batch.extend(jobs.try_iter()); for (index, job) in batch.iter().enumerate() { let newer = |name: &str| batch[index + 1..].iter().any(|later| matches!(later, Job::Set(other, _) | Job::Reset(other) if other == name)); match job { Job::Load => read_all(), Job::Set(name, text) if !newer(name) => { if let Some(name) = resolve(name) { apply(name, text); } unwritten = true; } Job::Reset(name) if !newer(name) => { if let Some(name) = resolve(name) { give_back(name); } unwritten = false; } Job::Sync => unwritten = true, _ => {} } } } } // Hyprland's name for an option given either way; reads the list first if it hasn't been fn resolve(given: &str) -> Option<&'static str> { if !Hypr::read().loaded { read_all(); } let key = given.replace(':', "."); let found = Hypr::read().options.iter().find(|option| option.key() == key).map(|option| option.name); if found.is_none() { Hypr::write().message = Some((format!("Hyprland has no option {given}"), true)); } found } fn hyprctl(arguments: &[&str]) -> String { Command::new("hyprctl").args(arguments).output().map(|done| String::from_utf8_lossy(&done.stdout).into_owned()).unwrap_or_default() } // option names live as long as the shell: each is leaked once, however often the list is read fn interned(name: &str) -> &'static str { static NAMES: Mutex> = Mutex::new(Vec::new()); let mut names = NAMES.lock().unwrap_or_else(PoisonError::into_inner); if let Some(known) = names.iter().find(|known| **known == name) { return known; } let leaked: &'static str = Box::leak(name.to_string().into_boxed_str()); names.push(leaked); leaked } // (kind, value as text, set by Hyprland's own config) of these options, in one hyprctl call fn values_of(names: &[&str]) -> HashMap { if names.is_empty() { return HashMap::new(); } let batch = names.iter().map(|name| format!("j/getoption {name}")).collect::>().join("; "); parse::json(&hyprctl(&["--batch", &batch])).iter().filter_map(parse::hypr_option).map(|(name, kind, text, set)| (name, (kind, text, set))).collect() } fn read_all() { let described = parse::json(&hyprctl(&["descriptions", "-j"])); let Some(Value::List(list)) = described.first() else { Hypr::write().message = Some((String::from("Hyprland didn't answer `hyprctl descriptions`"), true)); return; }; let names: Vec<&str> = list.iter().filter_map(|option| option.get("name")?.text()).collect(); let values = values_of(&names); let options: Vec = list .iter() .filter_map(|option| { let name = option.get("name")?.text()?; let (kind, current, in_config) = values.get(name)?.clone(); let number = |key: &str| option.get(key).and_then(Value::number); let choices = match option.get("map") { Some(Value::List(pairs)) => pairs .iter() .filter_map(|pair| match pair { Value::Table(entries) => entries.first().and_then(|(label, value)| Some((label.clone(), value.number()? as i64))), _ => None, }) .collect(), _ => Vec::new(), }; Some(HyprOption { name: interned(name), help: option.get("description").and_then(Value::text).unwrap_or("").to_string(), kind, default: option.get("default").and_then(|value| parse::hypr_text(kind, value)).unwrap_or_default(), range: number("min").zip(number("max")), choices, current, in_config, }) }) .collect(); let required = fs::read_to_string(hypr_dir().join("hyprland.lua")).is_ok_and(|lua| ["require(\"amane\")", "require('amane')", "require \"amane\""].iter().any(|line| lua.contains(line))); let mut hypr = Hypr::write(); hypr.options = options; hypr.loaded = true; hypr.required = required; } fn kind_of(name: &str) -> Option { if let Some(option) = Hypr::read().options.iter().find(|option| option.name == name) { return Some(option.kind); } values_of(&[name]).get(name).map(|(kind, _, _)| *kind) } // a change from the window: checked, kept in config.toml, applied at once fn apply(name: &'static str, text: &str) { let Some(kind) = kind_of(name) else { Hypr::write().message = Some((format!("Hyprland has no option {name}"), true)); return; }; let lua = match parse::hypr_lua(kind, text) { Ok(lua) => lua, Err(why) => { Hypr::write().message = Some((format!("{name} {why}"), true)); return; } }; let key = name.replace(':', "."); let value = match kind { HyprKind::Bool => Value::Bool(lua == "true"), HyprKind::Int => lua.parse().map(Value::Int).unwrap_or(Value::Text(lua.clone())), HyprKind::Float => lua.parse().map(Value::Float).unwrap_or(Value::Text(lua.clone())), _ => Value::Text(text.trim().to_string()), }; let answer = hyprctl(&["eval", &format!("hl.config({{ [{}] = {lua} }})", parse::quoted(&key))]); let message = if answer.trim() == "ok" { match config::set(&format!("hyprland.{key}"), value) { Ok(()) => (format!("{key} = {}", text.trim()), false), Err(why) => (why, true), } } else { // "error: return hl.config(...):1: error setting 'x': why" says only the last part (answer.rsplit(": ").next().unwrap_or(&answer).trim().to_string(), true) }; refresh(name); Hypr::write().message = Some(message); } // back to Hyprland's own value: out of config.toml and amane.lua, then Hyprland reloads its config fn give_back(name: &'static str) { let key = name.replace(':', "."); let message = match config::reset(&format!("hyprland.{key}")) { Ok(()) => { write_lua(); hyprctl(&["reload", "config-only"]); (format!("{key} is back to your Hyprland config's value"), false) } Err(why) => (why, true), }; read_all(); Hypr::write().message = Some(message); } fn refresh(name: &str) { if let Some((_, text, set)) = values_of(&[name]).remove(name) { let mut hypr = Hypr::write(); if let Some(option) = hypr.options.iter_mut().find(|option| option.name == name) { option.current = text; option.in_config = set; } } } fn hypr_dir() -> PathBuf { theme::config_home().join("hypr") } // amane.lua from the [hyprland.*] tables; written only when it would change fn write_lua() { let overrides = config::overrides(); let names: Vec = overrides.iter().map(|(path, _)| path.trim_start_matches("hyprland.").to_string()).collect(); // config.toml names options with dots only; Hyprland's names use colons between sections let known: HashMap = { let hypr = Hypr::read(); if hypr.loaded { hypr.options.iter().map(|option| (option.key(), option.kind)).collect() } else { HashMap::new() } }; let kinds: HashMap = if names.iter().all(|key| known.contains_key(key)) { known } else { let all = parse::json(&hyprctl(&["descriptions", "-j"])); let every: Vec<&str> = match all.first() { Some(Value::List(list)) => list.iter().filter_map(|option| option.get("name")?.text()).collect(), _ => Vec::new(), }; let wanted: Vec<&str> = every.into_iter().filter(|name| names.contains(&name.replace(':', "."))).collect(); values_of(&wanted).into_iter().map(|(name, (kind, _, _))| (name.replace(':', "."), kind)).collect() }; let mut lines = Vec::new(); for ((_, value), key) in overrides.iter().zip(&names) { let Some(kind) = kinds.get(key) else { continue }; if let Ok(lua) = parse::hypr_lua(*kind, &config::shown(value)) { lines.push(format!(" [{}] = {lua},", parse::quoted(key))); } } let lua = format!( "-- Written by the amane shell from the [hyprland] tables in ~/.config/amane/config.toml (its\n\ -- config window, or hand edits); changes made here are overwritten. Load it last in\n\ -- hyprland.lua with require(\"amane\") so these win over the rest of the config.\n\ {}", if lines.is_empty() { String::new() } else { format!("hl.config({{\n{}\n}})\n", lines.join("\n")) } ); let path = hypr_dir().join("amane.lua"); if fs::read_to_string(&path).ok().as_deref() != Some(lua.as_str()) { let _ = fs::write(path, lua); } }