Files
amane-config/src/hypr.rs
T
server2 4fbfad3ce3 Config window: every setting in config.toml, Hyprland options from Hyprland
All tunable values (sizes, timings, text sizes, colours, polls, equalizer
presets and bands, hotspot, extra output) are settings declared once in
config.rs, read from ~/.config/amane/config.toml, applied live on save.
shell.conf's four values move over on first start.

settings.rs: a normal window (control centre gear, or ipc config) with a
page per part, a page per Hyprland category, search and reset per row.
hypr.rs: Hyprland's options from hyprctl descriptions/getoption, applied
with hl.config and kept in ~/.config/hypr/amane.lua.
2026-10-15 02:18:57 +02:00

411 lines
13 KiB
Rust

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<HyprOption>,
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<Sender<Job>> = 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<Job>) {
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<Job> = 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<Vec<&'static str>> = 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<String, (HyprKind, String, bool)> {
if names.is_empty() {
return HashMap::new();
}
let batch = names.iter().map(|name| format!("j/getoption {name}")).collect::<Vec<_>>().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<HyprOption> = 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<HyprKind> {
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<String> = 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<String, HyprKind> = {
let hypr = Hypr::read();
if hypr.loaded { hypr.options.iter().map(|option| (option.key(), option.kind)).collect() } else { HashMap::new() }
};
let kinds: HashMap<String, HyprKind> = 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);
}
}