4fbfad3ce3
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.
411 lines
13 KiB
Rust
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);
|
|
}
|
|
}
|