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.
This commit is contained in:
server2
2026-10-15 02:18:57 +02:00
parent dfe3867529
commit 4fbfad3ce3
20 changed files with 3172 additions and 781 deletions
+699
View File
@@ -0,0 +1,699 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::process::Command;
use amane::{
Center, Color, Column, Cursor, End, Full, Padding, Rectangle, Row, ScrollArea, Service, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget, Window, children, close_window, open_window, window_size,
};
use crate::config::{self, Kind, Opt};
use crate::hypr::{self, Hypr, HyprOption};
use crate::parse::{HyprKind, Value};
use crate::theme::{self, Colors};
/*
* the config window: every shell setting (config.rs) and every Hyprland option (hypr.rs)
* on pages in a sidebar, with a search over all of them. a normal window, opened from the
* control centre's Settings button or `amane ipc call config open|toggle`
*/
const NAME: &str = "magic-island-settings";
const TITLE: &str = "Magic Island settings";
// the window's own layout; its text sizes come from appearance.text like the rest
const SIDEBAR_W: f32 = 240.0;
const PAD: f32 = 20.0;
const CONTROL_W: f32 = 320.0;
const FIELD_H: f32 = 30.0;
const SLIDER_W: f32 = 200.0;
const NUMBER_W: f32 = 104.0;
const RESET_W: f32 = 56.0;
const SEARCH: &str = "settings-search";
pub struct Ui {
// a shell page ("island") or a Hyprland one ("hyprland:decoration")
page: String,
query: String,
message: Option<(String, bool)>,
hover: Option<String>,
}
impl Service for Ui {
fn new() -> Self {
Self { page: String::from("appearance"), query: String::new(), message: None, hover: None }
}
fn listen() {}
}
pub fn open() {
open_window(NAME, view);
hypr::load();
}
// whether the window is open: amane can't say, Hyprland lists it by title
fn is_open() -> bool {
Command::new("hyprctl").arg("clients").output().is_ok_and(|done| String::from_utf8_lossy(&done.stdout).contains(&format!("title: {TITLE}\n")))
}
/*
* `amane ipc call config ...`: open, close, toggle, page <id>, search <words>,
* get <path>, set <path> <value>, reset <path>, status. paths as in config.toml;
* hyprland.<option> ones go to Hyprland
*/
pub fn command(arguments: &[String]) -> String {
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
match words.as_slice() {
["open"] => open(),
["close"] => close_window(NAME),
["toggle"] => {
if is_open() {
close_window(NAME);
} else {
open();
}
}
["page", page] => {
go_to(page);
open();
}
["search", rest @ ..] => {
Ui::write().query = rest.join(" ");
open();
}
["get", path] => {
return match config::option(path) {
Some(option) => config::shown(&config::current(option)),
None => match path.strip_prefix("hyprland.") {
Some(key) => Hypr::read().options.iter().find(|option| option.key() == key).map_or_else(|| String::from("not loaded; open the window first"), |option| option.current.clone()),
None => format!("no setting {path}"),
},
};
}
["set", path, value @ ..] => return set_text(path, &value.join(" ")).map_or_else(|why| why, |()| String::from("ok")),
["reset", path] => {
return match path.strip_prefix("hyprland.") {
Some(key) => {
hypr::reset(key);
String::from("ok")
}
None => config::reset(path).map_or_else(|why| why, |()| String::from("ok")),
};
}
["status"] => {
let ui = Ui::read();
return format!("open={} page={} errors={} search={}", is_open(), ui.page, config::errors().len(), ui.query);
}
_ => return String::from("usage: config open|close|toggle|page <id>|search <words>|get <path>|set <path> <value>|reset <path>|status"),
}
String::from("ok")
}
fn go_to(page: &str) {
let mut ui = Ui::write();
ui.page = page.to_string();
ui.query.clear();
ui.message = None;
if page.starts_with("hyprland:") {
hypr::load();
}
}
// a typed value for a setting; Hyprland's are checked here when the list is known, then applied by hypr.rs
fn set_text(path: &str, text: &str) -> Result<(), String> {
if let Some(key) = path.strip_prefix("hyprland.") {
let kind = Hypr::read().options.iter().find(|option| option.key() == key).map(|option| option.kind);
if let Some(kind) = kind {
crate::parse::hypr_lua(kind, text).map_err(|why| format!("{key} {why}"))?;
}
hypr::set(key, text);
return Ok(());
}
let option = config::option(path).ok_or(format!("no setting {path}"))?;
config::from_text(option, text).and_then(|value| config::set(path, value))
}
fn say(result: Result<(), String>, done: String) {
Ui::write().message = Some(match result {
Ok(()) => (done, false),
Err(why) => (why, true),
});
}
pub fn view() -> Window {
theme::begin();
let colors = theme::now();
let ui = Ui::read();
let hypr = Hypr::read();
let (width, height) = match window_size() {
(0.0, _) | (_, 0.0) => (1100.0, 760.0),
size => size,
};
let main_w = (width - SIDEBAR_W).max(400.0);
let row_w = main_w - 2.0 * PAD - 12.0;
let query = ui.query.trim().to_lowercase();
// the rows to show: a search goes through every page
let mut sections: Vec<Box<dyn Widget>> = Vec::new();
let shell_page = !ui.page.starts_with("hyprland:");
let category = ui.page.strip_prefix("hyprland:").unwrap_or("");
let title = if !query.is_empty() {
format!("Search: {}", ui.query.trim())
} else if shell_page {
config::title(&ui.page)
} else {
format!("Hyprland · {}", config::title(category))
};
let mut notes: Vec<(String, bool)> = Vec::new();
if shell_page || !query.is_empty() {
notes.extend(config::errors().into_iter().map(|error| (format!("config.toml {error}"), true)));
}
if (!shell_page || !query.is_empty()) && hypr.loaded && !hypr.required && !config::overrides().is_empty() {
notes.push((String::from("Hyprland options set here apply now, but a Hyprland reload drops them: add require(\"amane\") as the last line of ~/.config/hypr/hyprland.lua"), false));
}
for note in notes {
sections.push(Box::new(banner(&note.0, note.1, row_w)));
}
// shell settings, by group
let mut group = "";
let mut page = "";
for option in config::OPTIONS {
let shown = if query.is_empty() { shell_page && option.module() == ui.page } else { option.path.contains(&query) || option.help.to_lowercase().contains(&query) };
if !shown {
continue;
}
if option.group() != group || option.module() != page {
let heading = if query.is_empty() { config::title(option.group()) } else { format!("{} › {}", config::title(option.module()), config::title(option.group())) };
sections.push(Box::new(heading_row(&heading, row_w)));
group = option.group();
page = option.module();
}
sections.push(Box::new(shell_row(option, row_w, &colors)));
}
// Hyprland's, by group
if !shell_page || !query.is_empty() {
if !hypr.loaded {
sections.push(Box::new(Text::new("Reading Hyprland's options…").size(config::appearance::text::body()).color(colors.dim)));
}
let mut group: Option<(&str, &str)> = None;
for option in &hypr.options {
let shown = if query.is_empty() { option.category() == category } else { option.name.contains(&query) || option.help.to_lowercase().contains(&query) };
if !shown {
continue;
}
let here = (option.category(), option.group());
if group != Some(here) {
let named = if option.group().is_empty() { String::new() } else { config::title(option.group()) };
let heading = match (query.is_empty(), named.is_empty()) {
(true, true) => String::from("General"),
(true, false) => named,
(false, true) => format!("Hyprland › {}", config::title(option.category())),
(false, false) => format!("Hyprland › {} › {named}", config::title(option.category())),
};
sections.push(Box::new(heading_row(&heading, row_w)));
group = Some(here);
}
sections.push(Box::new(hypr_row(option, row_w, &colors)));
}
}
if sections.is_empty() {
sections.push(Box::new(Text::new("Nothing matches.").size(config::appearance::text::body()).color(colors.dim)));
}
let message = if shell_page { ui.message.clone() } else { hypr.message.clone().or(ui.message.clone()) };
let (words, wrong) = message.unwrap_or_else(|| (format!("Saved to {} as you change things; Enter applies a typed value", config::file().display()), false));
let footer = Text::new(words).size(config::appearance::text::small()).color(if wrong { config::appearance::overlays::low_battery() } else { colors.dim }).elide();
let main = Rectangle::new()
.width(main_w)
.height(height)
.padding(Padding { top: PAD, right: PAD, bottom: 12.0, left: PAD })
.child(Column::new(children![
Text::new(title).size(config::appearance::text::title()).weight(Weight::Bold).color(colors.text),
ScrollArea::new("settings-rows", Column::new(sections).gap(6.0)).width(main_w - 2.0 * PAD).height(height - PAD - 12.0 - 40.0 - 24.0),
Rectangle::new().width(main_w - 2.0 * PAD).height(20.0).align_child(Start, Center).child(footer),
]).gap(10.0));
Window::new()
.title(TITLE)
.size(1100.0, 760.0)
.resizable(true)
.child(
Rectangle::new()
.width(width)
.height(height)
.fill(colors.surface_low)
.child(Row::new(children![sidebar(&ui, &hypr, height, &colors), main])),
)
}
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> Rectangle {
let mut entries: Vec<Box<dyn Widget>> = vec![Box::new(caption("SHELL", colors))];
for module in config::modules() {
entries.push(Box::new(entry(module.to_string(), config::title(module), ui, colors)));
}
entries.push(Box::new(caption("HYPRLAND", colors)));
let mut categories: Vec<&str> = Vec::new();
for option in &hypr.options {
if !categories.contains(&option.category()) {
categories.push(option.category());
}
}
if categories.is_empty() {
entries.push(Box::new(Text::new(if hypr.loaded { "Hyprland didn't answer" } else { "Reading…" }).size(config::appearance::text::small()).color(colors.dim)));
}
for category in categories {
entries.push(Box::new(entry(format!("hyprland:{category}"), config::title(category), ui, colors)));
}
synced(SEARCH, &ui.query);
let search = Rectangle::new()
.width(SIDEBAR_W - 24.0)
.height(FIELD_H)
.radius(10.0)
.fill(config::appearance::overlays::chip())
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Cursor::Text)
.child(TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| {
SYNCED.with_borrow_mut(|synced| synced.insert(SEARCH, text.clone()));
Ui::write().query = text;
}));
Rectangle::new()
.width(SIDEBAR_W)
.height(height)
.fill(colors.surface)
.padding(12.0)
.child(Column::new(children![search, ScrollArea::new("settings-pages", Column::new(entries).gap(2.0)).width(SIDEBAR_W - 24.0).height(height - 24.0 - FIELD_H - 12.0)]).gap(12.0))
}
fn caption(text: &str, colors: &Colors) -> Rectangle {
Rectangle::new()
.width(SIDEBAR_W - 24.0)
.height(30.0)
.padding(Padding { top: 12.0, right: 0.0, bottom: 0.0, left: 10.0 })
.child(Text::new(text).size(config::appearance::text::tiny()).weight(Weight::Bold).color(colors.dim))
}
fn entry(id: String, title: String, ui: &Ui, colors: &Colors) -> Rectangle {
let chosen = ui.query.is_empty() && ui.page == id;
let lit = ui.hover.as_deref() == Some(id.as_str());
let fill = if chosen { colors.accent } else if lit { config::appearance::overlays::chip_hover() } else { Color::TRANSPARENT };
let (hovered, clicked) = (id.clone(), id);
Rectangle::new()
.width(SIDEBAR_W - 24.0)
.height(30.0)
.radius(8.0)
.fill(fill)
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Cursor::Pointer)
.on_hover(move |inside| {
let mut ui = Ui::write();
if inside {
ui.hover = Some(hovered.clone());
} else if ui.hover.as_deref() == Some(hovered.as_str()) {
ui.hover = None;
}
})
.on_click(move |_| {
SYNCED.with_borrow_mut(|synced| synced.remove(SEARCH));
go_to(&clicked);
})
.child(Text::new(title).size(config::appearance::text::body()).color(if chosen { colors.on_accent } else { colors.text }).elide())
}
fn heading_row(text: &str, width: f32) -> Rectangle {
Rectangle::new()
.width(width)
.height(34.0)
.padding(Padding { top: 14.0, right: 0.0, bottom: 0.0, left: 4.0 })
.child(Text::new(text.to_uppercase()).size(config::appearance::text::small()).weight(Weight::Bold).color(theme::now().accent))
}
fn banner(text: &str, wrong: bool, width: f32) -> Rectangle {
let colors = theme::now();
let words = Text::new(text).size(config::appearance::text::small()).color(colors.text).wrap();
let height = words.height_in(width - 24.0) + 16.0;
Rectangle::new()
.width(width)
.height(height)
.radius(10.0)
.fill(if wrong { faded(config::appearance::overlays::low_battery(), 60) } else { config::appearance::overlays::chip() })
.padding(Padding { top: 8.0, right: 12.0, bottom: 8.0, left: 12.0 })
.child(words)
}
fn faded(color: Color, alpha: u8) -> Color {
Color::rgba(color.red(), color.green(), color.blue(), alpha)
}
/*
* one setting: its label and key, the help under them, the control on the right, and
* Reset while it differs from its default
*/
fn row(label: String, key: String, help: String, control: Box<dyn Widget>, reset: Option<Box<dyn Fn()>>, width: f32) -> Rectangle {
let colors = theme::now();
let text_w = width - CONTROL_W - RESET_W - 2.0 * 14.0 - 24.0;
let help = Text::new(help).size(config::appearance::text::small()).color(colors.dim).wrap();
let help_h = help.height_in(text_w);
let label_h = config::appearance::text::body() * 1.5;
let height = (label_h + 2.0 + help_h).max(FIELD_H) + 20.0;
let reset: Box<dyn Widget> = match reset {
Some(reset) => Box::new(
Rectangle::new()
.width(RESET_W)
.height(24.0)
.radius(Full)
.fill(config::appearance::overlays::chip())
.align_child(Center, Center)
.cursor(Cursor::Pointer)
.on_click(move |_| reset())
.child(Text::new("Reset").size(config::appearance::text::tiny()).color(colors.text)),
),
None => Box::new(Rectangle::new().width(RESET_W).height(24.0)),
};
Rectangle::new()
.width(width)
.height(height)
.radius(12.0)
.fill(config::appearance::overlays::chip())
.padding(Padding { top: 10.0, right: 14.0, bottom: 10.0, left: 14.0 })
.child(
Row::new(children![
Column::new(children![
Row::new(children![
Text::new(label).size(config::appearance::text::body()).weight(Weight::Bold).color(colors.text),
Text::new(key).size(config::appearance::text::tiny()).color(colors.dim).elide(),
])
.gap(10.0)
.align(Center)
.width(text_w)
.height(label_h),
Rectangle::new().width(text_w).height(help_h).child(help),
])
.gap(2.0),
Row::new(vec![control, reset]).gap(12.0).align(Center).justify(End).width(CONTROL_W + RESET_W + 12.0),
])
.justify(SpaceBetween)
.align(Center)
.width(width - 28.0),
)
}
fn shell_row(option: &'static Opt, width: f32, colors: &Colors) -> Rectangle {
let value = config::current(option);
let control: Box<dyn Widget> = match (option.kind, &value) {
(Kind::Bool, Value::Bool(on)) => {
let on = *on;
Box::new(switch(on, move || say(config::set(option.path, Value::Bool(!on)), format!("{} = {}", option.path, !on))))
}
(Kind::Int | Kind::Float, _) if option.range.is_some() => {
let (min, max) = option.range.unwrap_or((0.0, 1.0));
let whole = option.kind == Kind::Int;
let number = value.number().unwrap_or(min);
Box::new(
Row::new(children![
slider((number - min) / (max - min), move |fraction| {
let picked = stepped(min + fraction * (max - min), min, max, whole);
let value = if whole { Value::Int(picked as i64) } else { Value::Float(picked) };
say(config::set(option.path, value), format!("{} = {}", option.path, config::shown(&if whole { Value::Int(picked as i64) } else { Value::Float(picked) })));
}),
field(option.path, &config::shown(&value), NUMBER_W, None, move |text| say(set_text(option.path, &text), format!("{} = {text}", option.path))),
])
.gap(12.0)
.align(Center),
)
}
_ => {
let swatch = (option.kind == Kind::Color).then(|| value.text().and_then(config::color)).flatten();
Box::new(field(option.path, &config::shown(&value), CONTROL_W, swatch, move |text| say(set_text(option.path, &text), format!("{} = {text}", option.path))))
}
};
let reset: Option<Box<dyn Fn()>> = (!config::is_default(option)).then(|| Box::new(move || say(config::reset(option.path), format!("{} is back to its default", option.path))) as Box<dyn Fn()>);
let mut help = option.help.to_string();
if !config::is_default(option) {
help.push_str(&format!(" · default {}", config::shown(&(option.default)())));
}
let _ = colors;
row(config::label(option.name()), option.path.to_string(), help, control, reset, width)
}
fn hypr_row(option: &HyprOption, width: f32, colors: &Colors) -> Rectangle {
let name = option.name;
let path = option.path();
let overridden = config::override_of(&path).is_some();
let control: Box<dyn Widget> = match option.kind {
HyprKind::Bool => {
let on = option.current == "true";
Box::new(switch(on, move || hypr::set(name, if on { "false" } else { "true" })))
}
HyprKind::Int if !option.choices.is_empty() => Box::new(choices(option, colors)),
HyprKind::Int | HyprKind::Float if option.range.is_some() => {
let (min, max) = option.range.unwrap_or((0.0, 1.0));
let whole = option.kind == HyprKind::Int;
let number: f64 = option.current.parse().unwrap_or(min);
Box::new(
Row::new(children![
slider((number - min) / (max - min), move |fraction| {
let picked = stepped(min + fraction * (max - min), min, max, whole);
hypr::set(name, &if whole { (picked as i64).to_string() } else { picked.to_string() });
}),
field(name, &option.current, NUMBER_W, None, move |text| hypr::set(name, &text)),
])
.gap(12.0)
.align(Center),
)
}
_ => {
let swatch = (option.kind == HyprKind::Gradient).then(|| first_color(&option.current)).flatten();
Box::new(field(name, &option.current, CONTROL_W, swatch, move |text| hypr::set(name, &text)))
}
};
let reset: Option<Box<dyn Fn()>> = overridden.then(|| Box::new(move || hypr::reset(name)) as Box<dyn Fn()>);
let mut help = option.help.clone();
if !option.default.is_empty() {
help.push_str(&format!(" · default {}", option.default));
}
if overridden {
help.push_str(" · set here");
} else if option.in_config {
help.push_str(" · set in your Hyprland config");
}
let label = option.label().replace("col.", "colour_").replace('.', "_");
row(config::label(&label), path, help, control, reset, width)
}
// "rgba(80d9ffff) ..." as its first colour, for the swatch
fn first_color(gradient: &str) -> Option<Color> {
let word = gradient.split_whitespace().next()?;
config::color(word.trim_start_matches("rgba(").trim_end_matches(')'))
}
// a value from a slider: whole numbers, or steps of about a hundredth of the range
fn stepped(value: f64, min: f64, max: f64, whole: bool) -> f64 {
let value = value.clamp(min, max);
if whole {
return value.round();
}
let step = 10f64.powf(((max - min).log10()).floor() - 2.0);
((value / step).round() * step * 1e6).round() / 1e6
}
fn switch(on: bool, toggle: impl Fn() + 'static) -> Rectangle {
let colors = theme::now();
Rectangle::new()
.width(46.0)
.height(26.0)
.radius(Full)
.fill(if on { colors.accent } else { config::appearance::overlays::track() })
.padding(4.0)
.align_child(Start, Center)
.cursor(Cursor::Pointer)
.on_click(move |_| toggle())
.child(Rectangle::new().width(18.0).height(18.0).radius(Full).fill(if on { colors.on_accent } else { colors.dim }).translate(if on { 20.0 } else { 0.0 }, 0.0))
}
fn slider(fraction: f64, pick: impl Fn(f64) + Clone + 'static) -> Rectangle {
let colors = theme::now();
let fraction = fraction.clamp(0.0, 1.0) as f32;
let knob = 16.0;
let pick_drag = pick.clone();
Rectangle::new()
.width(SLIDER_W)
.height(FIELD_H)
.align_child(Start, Center)
.cursor(Cursor::Pointer)
.on_click(|_| {})
.on_drag(move |point| pick_drag(f64::from(((point.x - knob / 2.0) / (SLIDER_W - knob)).clamp(0.0, 1.0))))
.on_scroll(move |scroll| pick(f64::from((fraction + if scroll.y < 0.0 { 0.01 } else { -0.01 }).clamp(0.0, 1.0))))
.child(Stack::new(children![
Rectangle::new().width(SLIDER_W).height(6.0).radius(Full).fill(config::appearance::overlays::track()).translate(0.0, (knob - 6.0) / 2.0),
Rectangle::new().width(knob / 2.0 + fraction * (SLIDER_W - knob)).height(6.0).radius(Full).fill(colors.accent).translate(0.0, (knob - 6.0) / 2.0),
Rectangle::new().width(knob).height(knob).radius(Full).fill(colors.flame).translate(fraction * (SLIDER_W - knob), 0.0),
]).width(SLIDER_W).height(knob))
}
// a Hyprland number with named values: one chip per name
fn choices(option: &HyprOption, colors: &Colors) -> Row {
let name = option.name;
let chips: Vec<Box<dyn Widget>> = option
.choices
.iter()
.map(|(label, value)| {
let chosen = option.current == value.to_string();
let value = value.to_string();
let letters = label.chars().count() as f32;
Box::new(
Rectangle::new()
.width(letters * config::appearance::text::small() * config::appearance::font::letter_width() + 18.0)
.height(26.0)
.radius(Full)
.fill(if chosen { colors.accent } else { config::appearance::overlays::chip() })
.align_child(Center, Center)
.cursor(Cursor::Pointer)
.on_click(move |_| hypr::set(name, &value))
.child(Text::new(label.replace('_', " ")).size(config::appearance::text::small()).color(if chosen { colors.on_accent } else { colors.text })),
) as Box<dyn Widget>
})
.collect();
Row::new(chips).gap(6.0).align(Center).justify(End).width(CONTROL_W)
}
// the text inputs' texts as last set from their values, so typing isn't overwritten until a value changes
thread_local! {
static SYNCED: RefCell<HashMap<&'static str, String>> = RefCell::new(HashMap::new());
}
fn synced(id: &'static str, text: &str) {
SYNCED.with_borrow_mut(|synced| {
if synced.get(id).map(String::as_str) != Some(text) {
TextInput::set_text(id, text);
synced.insert(id, text.to_string());
}
});
}
fn field(id: &'static str, text: &str, width: f32, swatch: Option<Color>, submit: impl Fn(String) + 'static) -> Row {
let colors = theme::now();
synced(id, text);
let swatch_w = if swatch.is_some() { FIELD_H + 8.0 } else { 0.0 };
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
if let Some(color) = swatch {
parts.push(Box::new(Rectangle::new().width(FIELD_H).height(FIELD_H).radius(8.0).fill(color).border(1.0, colors.outline)));
}
parts.push(Box::new(
Rectangle::new()
.width(width - swatch_w)
.height(FIELD_H)
.radius(8.0)
.fill(config::appearance::overlays::chip())
.border(1.0, colors.outline)
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Cursor::Text)
.child(TextInput::new(id).size(config::appearance::text::body()).color(colors.text).on_submit(submit)),
));
Row::new(parts).gap(8.0).align(Center)
}