Files
amane-config/src/config.rs
T

1084 lines
51 KiB
Rust

use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::cell::RefCell;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex, PoisonError, RwLock};
use std::thread::LocalKey;
use amane::Color;
use crate::parse::{self, Value};
use crate::{hypr, theme};
/*
* every setting the shell has, in ~/.config/amane/config.toml. each is declared once
* below: the macro makes a typed function for the code (config::island::pill::width())
* and an entry in OPTIONS for the config window and the file. the file holds every
* setting with its help above it; saving it applies at once (theme.rs watches it).
* [hyprland.*] tables are Hyprland options changed in the window, see hypr.rs
*/
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Kind {
Bool,
Int,
Float,
Text,
Color,
List,
}
pub struct Opt {
// "module.group.name", also its place in config.toml
pub path: &'static str,
pub help: &'static str,
pub kind: Kind,
pub range: Option<(f64, f64)>,
// the only words a text setting takes, shown as chips; empty: any text
pub choices: &'static [&'static str],
pub default: fn() -> Value,
}
impl Opt {
pub fn module(&self) -> &'static str {
self.path.split('.').next().unwrap_or("")
}
pub fn group(&self) -> &'static str {
self.path.split('.').nth(1).unwrap_or("")
}
pub fn name(&self) -> &'static str {
self.path.rsplit('.').next().unwrap_or("")
}
}
// a type a setting can have: how it reads from and writes to the file
pub trait Setting: Sized {
const KIND: Kind;
fn read(value: &Value) -> Option<Self>;
fn value(&self) -> Value;
}
impl Setting for bool {
const KIND: Kind = Kind::Bool;
fn read(value: &Value) -> Option<Self> {
if let Value::Bool(on) = value { Some(*on) } else { None }
}
fn value(&self) -> Value {
Value::Bool(*self)
}
}
impl Setting for f32 {
const KIND: Kind = Kind::Float;
fn read(value: &Value) -> Option<Self> {
value.number().map(|number| number as f32)
}
// through its own shortest text, so 0.66 is written 0.66 and not 0.6600000262260437
fn value(&self) -> Value {
Value::Float(self.to_string().parse().unwrap_or(f64::from(*self)))
}
}
macro_rules! whole_setting {
($($ty:ty),*) => {$(
impl Setting for $ty {
const KIND: Kind = Kind::Int;
fn read(value: &Value) -> Option<Self> {
if let Value::Int(number) = value { <$ty>::try_from(*number).ok() } else { None }
}
fn value(&self) -> Value {
Value::Int(i64::try_from(*self).unwrap_or(i64::MAX))
}
}
)*};
}
whole_setting!(u32, u64, usize);
impl Setting for String {
const KIND: Kind = Kind::Text;
fn read(value: &Value) -> Option<Self> {
value.text().map(String::from)
}
fn value(&self) -> Value {
Value::Text(self.clone())
}
}
impl Setting for Color {
const KIND: Kind = Kind::Color;
fn read(value: &Value) -> Option<Self> {
color(value.text()?)
}
fn value(&self) -> Value {
Value::Text(self.hex())
}
}
impl Setting for [f32; 10] {
const KIND: Kind = Kind::List;
fn read(value: &Value) -> Option<Self> {
let Value::List(items) = value else { return None };
let numbers: Vec<f32> = items.iter().map(|item| item.number().map(|number| number as f32)).collect::<Option<_>>()?;
numbers.try_into().ok()
}
fn value(&self) -> Value {
Value::List(self.iter().map(Setting::value).collect())
}
}
impl Setting for [u32; 10] {
const KIND: Kind = Kind::List;
fn read(value: &Value) -> Option<Self> {
let Value::List(items) = value else { return None };
let numbers: Vec<u32> = items.iter().map(u32::read).collect::<Option<_>>()?;
numbers.try_into().ok()
}
fn value(&self) -> Value {
Value::List(self.iter().map(Setting::value).collect())
}
}
// "#rrggbb" or "#rrggbbaa", the # optional
pub fn color(text: &str) -> Option<Color> {
let digits = text.trim().trim_start_matches('#');
if !(digits.len() == 6 || digits.len() == 8) || !digits.chars().all(|digit| digit.is_ascii_hexdigit()) {
return None;
}
let channel = |at: usize| u8::from_str_radix(&digits[at..at + 2], 16).ok();
Some(Color::rgba(channel(0)?, channel(2)?, channel(4)?, if digits.len() == 8 { channel(6)? } else { 255 }))
}
macro_rules! settings {
($( $module:ident { $( $group:ident { $( $name:ident : $ty:ty $([ $($choice:literal),+ ])? = $default:expr $(, $min:literal ..= $max:literal)? ; $help:literal )* } )* } )*) => {
$( pub mod $module {
$( pub mod $group {
#[allow(unused_imports)]
use super::super::*;
$(
#[allow(dead_code)]
pub fn $name() -> $ty {
std::thread_local! {
static SLOT: std::cell::RefCell<(u64, Option<$ty>)> = const { std::cell::RefCell::new((0, None)) };
}
super::super::cached(&SLOT, concat!(stringify!($module), ".", stringify!($group), ".", stringify!($name)), || $default)
}
)*
} )*
} )*
pub static OPTIONS: &[Opt] = &[ $( $( $( Opt {
path: concat!(stringify!($module), ".", stringify!($group), ".", stringify!($name)),
help: $help,
kind: <$ty as Setting>::KIND,
range: settings!(@range $($min, $max)?),
choices: &[ $( $($choice),+ )? ],
default: || <$ty as Setting>::value(&$default),
}, )* )* )* ];
};
(@range) => { None };
(@range $min:literal, $max:literal) => { Some(($min as f64, $max as f64)) };
}
settings! {
appearance {
font {
family: String = String::from("Fira Code"); "The font for all text. Takes effect when the shell restarts"
letter_width: f32 = 0.66, 0.3..=1.2; "How wide one letter is, as a share of the text size: chips are sized to their labels with it. Match it to the font"
}
text {
tiny: f32 = 10.0, 6..=40; "The smallest text: badges, the equalizer's band labels, details under map nodes"
caption: f32 = 10.5, 6..=40; "Subtitles under the control centre's tiles"
small: f32 = 11.0, 6..=40; "Secondary text: chips, notes, percentages, the artist line"
label: f32 = 11.5, 6..=40; "Labels in menus and popups"
body: f32 = 12.0, 6..=40; "Most text"
popup: f32 = 13.0, 6..=40; "A popup's title and its main line"
heading: f32 = 14.0, 6..=40; "A menu's title"
section: f32 = 15.0, 6..=40; "The equalizer's heading"
title: f32 = 19.0, 6..=60; "The song title on the open media card"
}
colors {
cover_colors: bool = false; "The open media card and the equalizer take the song cover's colours; off, the whole shell uses the wallpaper's"
transition_ms: u64 = 400, 0..=5000; "How long a colour change cross-fades, in milliseconds; 0 switches at once"
}
motion {
hover_ms: u64 = 220, 0..=2000; "How long hover highlights take to fade in and out"
}
surface {
design: String ["solid", "frosted", "liquid", "glass"] = String::from("solid"); "What the island, the pills, the menus, the control centre and its popups are made of: solid (the wallpaper's gradient with an accent glow), frosted (a thin tint, a hairline rim and a sheen), liquid (clear glass with a light-catching edge) or glass (after Apple's Liquid Glass: a thin tint with a lens-like rim). See-through ones need Hyprland's blur behind the shell: README, Glass"
}
solid {
opacity: f32 = 1.0, 0.05..=1.0; "How opaque the solid design is; below 1 the desktop shows through"
glow: f32 = 0.3, 0.0..=1.0; "Strength of the accent glow around each surface"
hover_glow: f32 = 0.7, 0.0..=1.0; "Strength of the glow while hovered"
}
frosted {
opacity: f32 = 0.62, 0.05..=1.0; "How opaque the frosted tint is"
rim: f32 = 0.19, 0.0..=1.0; "Opacity of the hairline white rim"
sheen: f32 = 0.13, 0.0..=1.0; "Opacity of the white highlight along the top edge"
shadow: f32 = 0.24, 0.0..=1.0; "Strength of the soft shadow under each surface"
}
liquid {
opacity: f32 = 0.4, 0.05..=1.0; "How opaque the glass is in its middle; clearer at the top left, denser at the bottom right"
rim: f32 = 0.33, 0.0..=1.0; "Opacity of the light rim"
rim_width: f32 = 1.5, 0.0..=4.0; "Width of the light rim, in pixels"
light: f32 = 0.235, 0.0..=1.0; "Opacity of the light caught at the top left edge"
glow: f32 = 0.22, 0.0..=1.0; "Strength of the accent glow around each surface"
}
glass {
opacity: f32 = 0.32, 0.05..=1.0; "How strongly the glass is tinted in its middle. Keep it above the ignore_alpha of Hyprland's layer rule, or the desktop behind isn't blurred (README, Glass)"
edge: f32 = 16.0, 1..=60; "Width of the band along the rim that catches the light like the edge of a lens, in pixels"
light: f32 = 0.55, 0.0..=1.0; "Strength of the light caught where it falls in (top left) and where it leaves (bottom right)"
rim: f32 = 0.5, 0.0..=1.0; "Strength of the thin bright line on the rim"
dispersion: f32 = 0.3, 0.0..=1.0; "Strength of the warm and cool fringe along the rim, as glass splits light"
shadow: f32 = 0.18, 0.0..=1.0; "Strength of the soft shadow under each surface"
}
fallback {
accent: Color = Color::rgb(247, 168, 200); "Accent, used until matugen writes ~/.config/amane/theme from the wallpaper"
on_accent: Color = Color::rgb(40, 18, 32); "Text and icons on the accent colour, without a theme file"
accent_soft: Color = Color::rgb(150, 82, 122); "The softer accent, without a theme file"
flame: Color = Color::rgb(255, 228, 240); "Highlights such as the candles' flame tips, without a theme file"
text: Color = Color::rgb(241, 236, 246); "Text, without a theme file"
dim: Color = Color::rgba(205, 192, 218, 160); "Secondary text, without a theme file"
outline: Color = Color::rgba(255, 255, 255, 26); "Outlines, without a theme file"
surface: Color = Color::rgb(24, 18, 30); "The middle of the background gradient, without a theme file"
surface_high: Color = Color::rgb(35, 26, 44); "The light end of the background gradient, without a theme file"
surface_low: Color = Color::rgb(13, 10, 18); "The dark end of the background gradient, without a theme file"
}
overlays {
chip: Color = Color::rgba(255, 255, 255, 18); "Fill of chips and blocks; white with little alpha reads on any dark surface"
chip_open: Color = Color::rgba(255, 255, 255, 34); "A chip whose menu is open"
chip_hover: Color = Color::rgba(255, 255, 255, 46); "A hovered chip or button"
track: Color = Color::rgba(255, 255, 255, 24); "Slider tracks and the time bar"
strike: Color = Color::rgba(205, 205, 212, 170); "The line through the icon of a service that isn't running"
idle_line: Color = Color::rgba(205, 205, 212, 85); "The wobbling line to a map node that isn't connected"
low_battery: Color = Color::rgb(255, 105, 97); "The battery when it runs low"
}
}
island {
animation {
open_ms: u64 = 560, 0..=5000; "How long the island takes to open into the card, and to close"
pill_gone: f32 = 0.18, 0.0..=1.0; "Share of the opening over which the pill fades out"
shape_done: f32 = 0.6, 0.0..=1.0; "Share of the opening by which the empty shape has grown; the card fades in after it"
hover_grow: f32 = 10.0, 0..=100; "How much wider the pill gets when hovered, in pixels"
bold: f32 = 2.0, 0..=8; "How much thicker the play, pause, previous and next icons get when hovered"
}
timing {
seek_shown_ms: u64 = 1500, 0..=10000; "How long the time bar keeps showing a seek while the player catches up"
cover_check_ms: u64 = 250, 50..=5000; "How often the cover art is checked for a change"
}
pill {
width: f32 = 220.0, 100..=800; "Width of the closed island (the pill)"
height: f32 = 38.0, 20..=100; "Height of every pill: the island, workspaces, tray and control centre"
top_gap: f32 = 6.0, 0..=100; "Space between the top of the monitor and the pills"
shadow: f32 = 34.0, 0..=120; "Room around every pill and panel for its glow"
bars: usize = 7, 1..=32; "Sound bars in the pill"
bars_width: f32 = 36.0, 8..=200; "Width of the pill's sound bars"
bars_height: f32 = 26.0, 4..=100; "Height of the pill's sound bars"
bars_boost: f32 = 1.4, 0.2..=5.0; "How much the pill's bars amplify the sound levels"
}
card {
width: f32 = 560.0, 360..=1600; "Width of the open media card"
radius: f32 = 28.0, 0..=100; "Corner radius of the open card"
pad: f32 = 22.0, 0..=100; "Padding inside the card"
gap: f32 = 16.0, 0..=100; "Space between the card's sections"
side_gap: f32 = 20.0, 0..=100; "Space between the cd and the song details"
}
disc {
size: f32 = 204.0, 80..=600; "The cd's box, candles included; also the height of the card's top section"
cover: f32 = 136.0, 40..=600; "Diameter of the cover on the cd"
hole: f32 = 26.0, 0..=200; "Diameter of the cd's hole"
candles: usize = 40, 4..=200; "Candles in the ring around the cd"
candle_reach: f32 = 20.0, 0..=200; "How far the candles reach past the cover"
cover_pixels: u32 = 256, 32..=2048; "Size covers are decoded at, for the pill and the cd"
}
controls {
chip_height: f32 = 24.0, 10..=80; "Height of the output, player and volume chips"
output_chip_max: f32 = 150.0, 40..=600; "The widest the output chip gets"
volume_chip_width: f32 = 60.0, 20..=300; "Width of the volume chip"
bar_height: f32 = 8.0, 1..=40; "Thickness of the time bar"
knob: f32 = 16.0, 2..=60; "Size of the time bar's and equalizer's knobs"
height: f32 = 44.0, 16..=120; "Height of the row with previous, play and next"
}
equalizer {
header_height: f32 = 26.0, 10..=100; "Height of the equalizer's heading row"
slider_height: f32 = 140.0, 40..=600; "Height of the equalizer's sliders"
track_width: f32 = 10.0, 1..=60; "Width of a slider's track"
label_height: f32 = 14.0, 6..=60; "Height of the band labels under the sliders"
curve_steps: usize = 72, 8..=500; "Points on the curve drawn through the bands"
preset_height: f32 = 30.0, 10..=100; "Height of a preset button"
preset_gap: f32 = 8.0, 0..=60; "Space between the preset buttons"
}
menus {
top: f32 = 92.0, 0..=600; "How far below the card's padding the output and player menus open"
row_height: f32 = 30.0, 12..=100; "Height of a row in those menus"
rows: usize = 10, 1..=50; "Rows a menu shows before it scrolls"
row_letters: usize = 30, 4..=200; "Letters of a device name a menu row shows before cutting the middle"
chip_letters: usize = 13, 4..=200; "Letters of a device name the output chip shows before cutting the middle"
}
idle {
players: String = String::from("cider, spotify, tidal-hifi"); "Players the card offers to start while nothing plays: their desktop file names (cider for cider.desktop), comma-separated"
wait_s: u64 = 30, 1..=300; "How long a player started from the card gets to start playing before the card gives up"
check_ms: u64 = 500, 100..=5000; "How often a player started from the card is looked for and asked to play"
}
}
sound {
equalizer {
max_gain: f32 = 12.0, 1..=30; "How far a band can lift or cut, in dB"
bands: [u32; 10] = parse::BANDS, 10..=24000; "The 10 bands' frequencies in Hz. Equalizers started afterwards use them"
}
presets {
flat: [f32; 10] = [0.0; 10], -30..=30; "Gains of the Flat preset, in dB, low band to high"
bass: [f32; 10] = [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], -30..=30; "Gains of the Bass preset"
treble: [f32; 10] = [-2.0, -1.5, -1.0, 0.0, 1.0, 2.0, 3.0, 4.5, 5.5, 6.0], -30..=30; "Gains of the Treble preset"
vocal: [f32; 10] = [-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.5, 2.0, 0.0, -1.0], -30..=30; "Gains of the Vocal preset"
pop: [f32; 10] = [-1.0, 1.0, 2.5, 3.5, 2.5, 0.5, -0.5, -0.5, 1.0, 2.0], -30..=30; "Gains of the Pop preset"
rock: [f32; 10] = [5.0, 4.0, 2.5, -0.5, -1.5, -0.5, 2.0, 3.5, 4.5, 5.0], -30..=30; "Gains of the Rock preset"
jazz: [f32; 10] = [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.5, 2.5, 3.0], -30..=30; "Gains of the Jazz preset"
classic: [f32; 10] = [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0], -30..=30; "Gains of the Classic preset"
}
timing {
poll_ms: u64 = 1000, 100..=10000; "How often players' streams, volumes and outputs are read"
hold_ms: u64 = 2500, 0..=10000; "How long a dragged volume keeps its place while PipeWire catches up"
}
spectrum {
bars: usize = 24, 4..=128; "Bars cava splits the sound into. Takes effect when the capture next starts"
check_ms: u64 = 200, 50..=5000; "How often the shown player is checked, to start or stop capturing its sound"
}
}
workspaces {
pill {
slot: f32 = 28.0, 10..=100; "Size of one workspace's round slot"
slot_gap: f32 = 4.0, 0..=40; "Space between slots"
inset: f32 = 5.0, 0..=40; "Space between the slots and the pill's edge"
left: f32 = 12.0, 0..=400; "Distance from the monitor's left edge"
tray_gap: f32 = 8.0, 0..=100; "Space between the workspaces and the tray"
}
}
tray {
pill {
slot: f32 = 28.0, 10..=100; "Size of one tray icon's slot"
slot_gap: f32 = 4.0, 0..=40; "Space between slots"
inset: f32 = 5.0, 0..=40; "Space between the slots and the pill's edge"
icon: f32 = 18.0, 6..=80; "Size of the icons"
}
menu {
width: f32 = 260.0, 100..=800; "Width of an app's menu"
pad: f32 = 6.0, 0..=40; "Padding inside the menu"
row_height: f32 = 30.0, 12..=100; "Height of a menu row"
separator_height: f32 = 9.0, 1..=40; "Height of a separator"
max_rows: usize = 20, 1..=100; "The most rows a menu shows"
appear_ms: u64 = 180, 0..=2000; "How long the menu takes to appear"
close_ms: u64 = 400, 0..=5000; "How long after the pointer leaves the menu closes"
}
}
control {
services {
hotspot: String = String::new(); "The iwd access point profile the hotspot tile starts (/var/lib/iwd/ap/<name>.ap). Empty: the tile says how to set one up"
extra_output: String = String::new(); "A second Sound slider for the output whose name contains this, like an AV receiver. Empty: none"
}
animation {
open_ms: u64 = 520, 0..=5000; "How long the panel takes to open and close"
menu_ms: u64 = 380, 0..=5000; "How long a menu takes to open from its tile"
hover_grow: f32 = 6.0, 0..=100; "How much wider the pill gets when hovered"
collapse_ms: u64 = 250, 0..=5000; "How long after the pointer leaves the panel closes"
held_ms: u64 = 1200, 0..=10000; "How long a dragged slider shows where it was let go while the system catches up"
busy_s: u64 = 10, 1..=120; "How long a clicked tile shows it's working before giving up"
pending_s: u64 = 20, 1..=300; "How long a network or device shows as connecting after a click"
}
pill {
pad: f32 = 16.0, 0..=100; "Padding on each side of the pill's icons"
gap: f32 = 12.0, 0..=100; "Space between the pill's icons"
right: f32 = 12.0, 0..=400; "Distance of the pill and panel from the monitor's right edge"
}
panel {
width: f32 = 360.0, 240..=1000; "Width of the panel"
radius: f32 = 26.0, 0..=100; "Corner radius of the panel"
pad: f32 = 12.0, 0..=100; "Padding inside the panel"
gap: f32 = 10.0, 0..=100; "Space between the panel's tiles and blocks"
tile_height: f32 = 60.0, 30..=200; "Height of a connection tile"
button: f32 = 34.0, 16..=120; "Size of a tile's round button"
footer_height: f32 = 32.0, 16..=120; "Height of the Settings button at the bottom"
}
modules {
radius: f32 = 18.0, 0..=100; "Corner radius of the blocks under the tiles"
pad: f32 = 12.0, 0..=100; "Padding inside a block"
title_height: f32 = 18.0, 8..=80; "Height of a block's title row"
line_gap: f32 = 8.0, 0..=60; "Space between the lines in a block"
slider_height: f32 = 28.0, 8..=100; "Height of a slider"
keys_height: f32 = 26.0, 8..=100; "Height of the keyboard light's buttons"
profile_height: f32 = 48.0, 16..=200; "Height of the performance profile buttons"
}
menus {
pad: f32 = 12.0, 0..=100; "Padding inside a menu"
header_height: f32 = 28.0, 10..=100; "Height of a menu's title row"
row_height: f32 = 44.0, 16..=150; "Height of a menu row"
rows: usize = 7, 1..=50; "The most VPN rows the VPN menu lists"
note_height: f32 = 18.0, 6..=80; "Height of a note line in a menu"
output_height: f32 = 22.0, 8..=100; "Height of a row in the Sound menu"
outputs: usize = 8, 1..=50; "The most outputs the Sound menu lists"
}
ethernet {
width: f32 = 500.0, 240..=1200; "Width of the Ethernet details"
detail_height: f32 = 28.0, 10..=100; "Height of a detail row"
label_width: f32 = 78.0, 20..=300; "Width of a detail's label"
copy_width: f32 = 58.0, 20..=200; "Width of a Copy button"
}
maps {
height: f32 = 480.0, 200..=1600; "Height of the Wi-Fi and Bluetooth maps"
min_width: f32 = 600.0, 200..=3000; "The narrowest a map gets on a small monitor (where it then runs under the island)"
island_gap: f32 = 16.0, 0..=200; "Space between a map and the media island's pill"
nodes: usize = 8, 1..=40; "The most networks or devices a map shows; the strongest come first"
bubble: f32 = 40.0, 10..=200; "Size of the bubble for this computer in the middle"
node_radius: f32 = 22.0, 6..=100; "Radius of a network's or device's disc"
icon: f32 = 18.0, 6..=80; "Size of the icons on the map"
label_width: f32 = 140.0, 40..=500; "Width of a node's label"
label_height: f32 = 30.0, 10..=100; "Height of a node's label"
drift_x: f32 = 26.0, 0..=200; "How far nodes float sideways"
drift_y: f32 = 16.0, 0..=200; "How far nodes float up and down"
ring_s: f32 = 1.6, 0.1..=10.0; "How long a search ring takes to ripple out, in seconds"
ring_gap_s: f32 = 0.5, 0.0..=10.0; "Time between the three search rings, in seconds"
ring_reach: f32 = 230.0, 20..=2000; "How far the search rings ripple"
}
polling {
links_ms: u64 = 2000, 200..=60000; "How often Wi-Fi, VPN and hotspot state is read"
power_ms: u64 = 2000, 200..=60000; "How often the battery and performance profile are read"
gpu_ms: u64 = 2000, 200..=60000; "How often the external GPU's programs are scanned while the panel is open"
gpu_wait_ms: u64 = 400, 50..=10000; "How often the GPU scan checks whether the panel has opened"
wifi_rate_s: u64 = 60, 1..=3600; "How long a Wi-Fi network's link speed is kept before it's asked again"
battery_estimate_s: u64 = 30, 1..=3600; "How long a battery time estimate is kept"
}
}
popups {
layout {
width: f32 = 360.0, 200..=1000; "Width of a popup (password, PIN, failure)"
pad: f32 = 16.0, 0..=100; "Padding inside a popup"
header_height: f32 = 36.0, 10..=120; "Height of a popup's title row"
field_height: f32 = 36.0, 16..=120; "Height of the text field"
code_height: f32 = 40.0, 16..=120; "Height of a shown PIN or passkey"
line_height: f32 = 18.0, 8..=80; "Height of a line of text"
button_width: f32 = 92.0, 30..=300; "Width of a button"
button_height: f32 = 32.0, 16..=100; "Height of a button"
gap: f32 = 14.0, 0..=100; "Space between a popup's parts"
}
timing {
appear_ms: u64 = 220, 0..=2000; "How long a popup takes to appear"
session_s: u64 = 90, 5..=900; "How long a Bluetooth pairing may take before it's stopped"
}
}
bar {
hiding {
dwell_ms: u64 = 300, 0..=5000; "How long the pointer rests on the top edge of a fullscreen monitor before the bar shows"
hide_ms: u64 = 700, 0..=10000; "How long after the pointer leaves the bar hides again"
slide_ms: u64 = 220, 0..=3000; "How long the bar takes to slide in or out"
}
peeks {
workspaces_ms: u64 = 1600, 0..=20000; "How long the workspaces show on a fullscreen monitor after a workspace change"
island_ms: u64 = 3500, 0..=20000; "How long the island shows on a fullscreen monitor after a song change"
pop_s: f32 = 0.7, 0.0..=5.0; "How long the island's pop lasts on a new song, in seconds"
switch_s: f32 = 0.35, 0.0..=5.0; "How long the workspace highlight takes to slide over, in seconds"
media_check_ms: u64 = 300, 50..=5000; "How often the song is checked for a change, for the island's peek"
}
}
pills {
layout {
gap: f32 = 10.0, 0..=200; "Space between the island's pill and the small pills beside it"
spacing: f32 = 8.0, 0..=100; "Space between the small pills on one side"
icon: f32 = 18.0, 6..=40; "Size of the small pills' icons"
open_ms: u64 = 420, 0..=5000; "How long a small pill takes to open into its card, and to close"
radius: f32 = 28.0, 0..=60; "Corner radius of the opened cards"
pad: f32 = 24.0, 0..=60; "Padding inside the opened cards"
}
colors {
ai: Color = Color::rgb(160, 128, 255); "The AI pill's own colour, mixed into its pill and card"
monitor: Color = Color::rgb(64, 208, 190); "The system monitor pill's own colour"
notifications: Color = Color::rgb(255, 128, 104); "The notification pill's own colour"
home_clear_day: Color = Color::rgb(255, 190, 92); "The home pill's colour under a clear or partly cloudy day sky"
home_clear_night: Color = Color::rgb(104, 118, 230); "The home pill's colour on a clear night"
home_cloudy: Color = Color::rgb(150, 170, 200); "The home pill's colour under clouds (and before the weather is known)"
home_fog: Color = Color::rgb(176, 176, 190); "The home pill's colour in fog"
home_rain: Color = Color::rgb(84, 156, 244); "The home pill's colour in rain"
home_snow: Color = Color::rgb(206, 232, 255); "The home pill's colour in snow"
home_storm: Color = Color::rgb(160, 104, 230); "The home pill's colour in a storm"
pill_mix: f32 = 0.55, 0..=1; "How much of its own colour a small pill takes (0 = the wallpaper's only)"
card_mix: f32 = 0.28, 0..=1; "How much of its pill's colour an open card takes"
}
ai {
width: f32 = 760.0, 280..=1400; "Width of the AI card"
height: f32 = 680.0, 200..=1200; "Height of the AI card"
servers: String = String::from("http://localhost:11434"); "The Ollama servers the AI card talks to, comma-separated, in order: the model list shows all their models, a question goes to the first that has the chosen one"
model: String = String::from("gemma4:e4b"); "The Ollama model to chat with, with its tag (ollama list shows them)"
start_service: bool = true; "Start the local ollama service (doas rc-service ollama start) when it isn't answering"
system: String = String::from("You are a helpful assistant inside a Linux desktop shell. Answer briefly and plainly."); "What the model is told before every chat"
}
monitor {
width: f32 = 620.0, 280..=1400; "Width of the system monitor card"
interval_ms: u64 = 1000, 200..=10000; "How often the system monitor reads the system"
history: usize = 60, 10..=600; "How many readings the graphs show"
graph_height: f32 = 46.0, 10..=200; "Height of each graph"
temperatures: usize = 6, 0..=20; "How many temperature sensors to show, always in the same order: CPU, iGPU, eGPU, NVMe, memory, board, then the rest"
warm: f32 = 65.0, 20..=120; "From this temperature (°C) a sensor shows in the warm colour"
hot: f32 = 85.0, 20..=130; "From this temperature (°C) a sensor shows in the hot colour"
cool_color: Color = Color::rgb(120, 200, 255); "Colour of a sensor below the warm temperature"
warm_color: Color = Color::rgb(255, 196, 92); "Colour of a warm sensor"
hot_color: Color = Color::rgb(255, 105, 97); "Colour of a hot sensor"
programs: usize = 5, 1..=50; "Programs on the external GPU listed before the rest are counted"
terminal: String = String::from("ghostty"); "The terminal that runs the process list"
processes: String = String::from("btm --default_widget_type proc --expanded"); "The process list command, opened on a special workspace"
workspace: String = String::from("processes"); "The special workspace the process list opens on"
}
home {
width: f32 = 880.0, 300..=1600; "Width of the home card. It opens in the middle with the AI, system and island pills on its left, which on a 1920 px wide monitor leaves room for about 900 beside the workspace pill"
clock_size: f32 = 64.0, 20..=200; "Size of the big clock"
clock_font: String = String::new(); "The big clock's font family, any installed one (fc-list : family lists them); empty takes the shell's font"
clock_weight: u64 = 700, 100..=900; "The big clock's weight, 100 (thin) to 900 (black); a font without that weight shows its nearest one"
clock_letter_width: f32 = 0.0, 0..=2; "Width of one of the clock's digits as a share of its size, so the weather beside it makes room; 0 takes the shell font's"
seconds: bool = false; "Show seconds on the big clock"
avatar: String = String::from("~/.face"); "The picture shown as the user's avatar; a drawn person without one"
avatar_size: f32 = 84.0, 24..=200; "Size of the avatar; the greeting row is as tall"
gap: f32 = 12.0, 0..=60; "Space between the home card's parts"
city: String = String::from("Munich"); "The weather's place, as shown"
latitude: f32 = 48.137, -90.0..=90.0; "The weather's latitude"
longitude: f32 = 11.575, -180.0..=180.0; "The weather's longitude"
weather_minutes: u64 = 20, 5..=360; "How often the weather is fetched, in minutes"
weather_height: f32 = 60.0, 30..=200; "Height of the weather now"
weather_icon: f32 = 44.0, 16..=120; "Size of the icon of the weather now"
temperature_size: f32 = 28.0, 10..=100; "Size of the temperature now"
forecast_height: f32 = 64.0, 30..=200; "Height of the coming days' weather"
forecast_icon: f32 = 22.0, 10..=80; "Size of the coming days' weather icons"
photo: bool = true; "Show a photo of the weather now behind the home card"
photos: String = String::from("~/.config/amane/weather"); "Folder of the weather photos: <kind>-<day|night>.jpg for clear, partly-cloudy, cloudy, fog, rain, snow and storm. A missing one is downloaded once from Wikimedia Commons (credits in the repo's weather/CREDITS.md); a file that's there is never replaced, so put your own in its place (the shell shows a swapped file after a restart)"
photo_opacity: f32 = 0.45, 0..=1; "How much the weather photo shows through behind the home card: the card's colour veils the rest, 0 hides it"
}
calendar {
server: String = String::from("https://caldav.icloud.com/"); "The CalDAV server; iCloud's by default"
contacts_server: String = String::from("https://contacts.icloud.com/"); "The CardDAV server the birthdays come from, with the same account; iCloud's by default"
account: String = String::new(); "Your Apple ID (or CalDAV user). Store its app-specific password (account.apple.com > Sign-In and Security > App-Specific Passwords) in the keyring with: secret-tool store --label='Magic Island calendar' service amane-calendar account <Apple ID>"
sync_minutes: u64 = 15, 1..=360; "How often the calendar and the birthdays are synced, in minutes"
holidays: String = String::from("BY"); "The German state whose public holidays the calendar marks: BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH or TH; empty marks none"
birthdays: bool = true; "Show the birthdays of your contacts"
cell: f32 = 52.0, 32..=96; "Size of a day in the month grid; the card grows with it"
disc: f32 = 0.72, 0.3..=1.0; "Size of today's disc and the selected day's ring, as a share of a day"
ring: f32 = 2.0, 0.5..=6.0; "Thickness of the selected day's ring"
dot: f32 = 5.0, 2..=16; "Size of the dots under a day with events, one per calendar"
dots: usize = 3, 1..=6; "Most dots under one day"
mark: f32 = 13.0, 6..=30; "Size of the cake on a day with a birthday"
list_gap: f32 = 24.0, 0..=100; "Space between the month grid and the selected day's list"
item_gap: f32 = 8.0, 0..=40; "Space between the selected day's items"
field_height: f32 = 32.0, 20..=60; "Height of the event form's text fields"
length_minutes: u64 = 60, 5..=1440; "How long a new event lasts until you change its end"
holiday_color: Color = Color::rgb(255, 122, 104); "Colour of public holidays: the day's number, its tint and flag"
holiday_tint: f32 = 0.2, 0..=1; "How strongly a public holiday is tinted in its colour"
birthday_color: Color = Color::rgb(255, 138, 196); "Colour of birthdays: the cake on the day and in its list"
selected_color: Color = Color::rgba(255, 255, 255, 220); "Colour of the selected day's ring"
}
}
notifications {
popups {
enabled: bool = true; "Show notifications as popups; off is do not disturb (they still go to the history)"
timeout_ms: u64 = 6000, 1000..=60000; "How long a normal notification's popup stays"
low_timeout_ms: u64 = 4000, 1000..=60000; "How long a low-urgency popup stays"
critical_stays: bool = true; "Critical notifications stay until dismissed"
width: f32 = 380.0, 240..=900; "Width of the popups"
count: usize = 4, 1..=10; "How many popups show at once"
icon: f32 = 40.0, 16..=128; "Size of a notification's icon or picture"
}
apps {
muted: String = String::new(); "Apps whose notifications don't pop up but stay in the history, comma-separated (Mute app on a notification adds it)"
blocked: String = String::new(); "Apps whose notifications are dropped at once, comma-separated (Block app on a notification adds it)"
}
history {
width: f32 = 620.0, 280..=1400; "Width of the notification card"
height: f32 = 700.0, 200..=1400; "Height of the notification card"
}
}
}
// what config.toml set, by path; a missing or bad setting takes its default. written only through change()
static VALUES: LazyLock<RwLock<HashMap<String, Value>>> = LazyLock::new(Default::default);
// how many times VALUES changed; an accessor's cached answer from an older count is read again
static GENERATION: AtomicU64 = AtomicU64::new(1);
// what was wrong in the file when it was last read, for the config window
static ERRORS: Mutex<Vec<String>> = Mutex::new(Vec::new());
/*
* config.toml as the shell last wrote or read it. every save and every reread holds
* this lock, so the file watch can't read a save halfway or undo a change it raced
* with (it once reset settings to their defaults that way), and skips the shell's own saves
*/
static WRITTEN: Mutex<String> = Mutex::new(String::new());
fn written() -> std::sync::MutexGuard<'static, String> {
WRITTEN.lock().unwrap_or_else(PoisonError::into_inner)
}
fn get<T: Setting>(path: &str, default: impl FnOnce() -> T) -> T {
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).and_then(T::read).unwrap_or_else(default)
}
// an accessor's last answer on this thread, with the GENERATION it was read at
type Slot<T> = LocalKey<RefCell<(u64, Option<T>)>>;
/*
* what every accessor (settings!) returns: its last answer while VALUES hasn't changed
* since, else a fresh get(). a few hundred reads a frame with a card open, each a lock,
* a hash of the path and a conversion (a colour parsed from its text) at 160 to 570 ns
* unoptimised; a cached one is an atomic load and a copy, 13 ns. per thread, so it takes
* no lock. a read racing a change may return the old value once, as get() could
*/
fn cached<T: Setting + Clone>(slot: &'static Slot<T>, path: &str, default: impl FnOnce() -> T) -> T {
let now = GENERATION.load(Ordering::Acquire);
if let Some(value) = slot.with_borrow(|(seen, value)| value.clone().filter(|_| *seen == now)) {
return value;
}
let value = get(path, default);
slot.set((now, Some(value.clone())));
value
}
// every change to VALUES: made, then counted (the write lock is gone by then), so the accessors read again
fn change<R>(edit: impl FnOnce(&mut HashMap<String, Value>) -> R) -> R {
let done = edit(&mut VALUES.write().unwrap_or_else(PoisonError::into_inner));
GENERATION.fetch_add(1, Ordering::Release);
done
}
pub fn file() -> PathBuf {
theme::config_file("config.toml")
}
pub fn option(path: &str) -> Option<&'static Opt> {
OPTIONS.iter().find(|option| option.path == path)
}
// at startup: config.toml, or the first time, one made from the old shell.conf
pub fn load() {
let mut written = written();
let text = fs::read_to_string(file()).unwrap_or_else(|_| {
migrate();
let text = render();
let _ = write_file(&text);
text
});
apply_text(&text);
*written = text;
}
// config.toml changed on disk: a hand edit applies, the shell's own saves are skipped. whether it applied
pub fn reread() -> bool {
let mut written = written();
let text = fs::read_to_string(file()).unwrap_or_default();
if text == *written {
return false;
}
apply_text(&text);
*written = text;
true
}
// shell.conf's four settings, moved to their places in config.toml
fn migrate() {
let old = fs::read_to_string(theme::config_file("shell.conf")).unwrap_or_default();
change(|values| {
for (key, text) in parse::settings(&old) {
let path = match key.as_str() {
"cover_colors" => "appearance.colors.cover_colors",
"transition_ms" => "appearance.colors.transition_ms",
"hotspot" => "control.services.hotspot",
"extra_output" => "control.services.extra_output",
_ => continue,
};
if let Some(option) = option(path)
&& let Ok(value) = from_text(option, &text)
{
values.insert(path.to_string(), value);
}
}
});
}
// the file's text, read anew: what it sets replaces everything set before
pub fn apply_text(text: &str) {
let (pairs, mut errors) = parse::toml(text);
let mut values = HashMap::new();
for (path, value) in pairs {
match check(&path, &value) {
Ok(()) => {
values.insert(path, value);
}
Err(why) => errors.push(format!("{path}: {why}")),
}
}
change(|old| *old = values);
*ERRORS.lock().unwrap_or_else(PoisonError::into_inner) = errors;
hypr::sync_soon();
}
// whether a value fits its setting: kind, range, a list's length. Hyprland's are checked by Hyprland
fn check(path: &str, value: &Value) -> Result<(), String> {
if path.starts_with("hyprland.") {
return if matches!(value, Value::Bool(_) | Value::Int(_) | Value::Float(_) | Value::Text(_)) { Ok(()) } else { Err(String::from("needs a value")) };
}
let option = option(path).ok_or("there's no such setting")?;
let fits = match option.kind {
Kind::Bool => matches!(value, Value::Bool(_)),
Kind::Int => matches!(value, Value::Int(number) if *number >= 0),
Kind::Float => value.number().is_some(),
Kind::Text => value.text().is_some(),
Kind::Color => value.text().and_then(color).is_some(),
Kind::List => match (value, (option.default)()) {
(Value::List(items), Value::List(default)) => items.len() == default.len() && items.iter().all(|item| item.number().is_some()),
_ => false,
},
};
if !fits {
return Err(format!("needs {}", kind_words(option)));
}
if !option.choices.is_empty() && !value.text().is_some_and(|word| option.choices.contains(&word)) {
return Err(format!("must be one of {}", option.choices.join(", ")));
}
let numbers: Vec<f64> = match value {
Value::List(items) => items.iter().filter_map(Value::number).collect(),
_ => value.number().into_iter().collect(),
};
if let Some((min, max)) = option.range
&& numbers.iter().any(|number| *number < min || *number > max)
{
return Err(format!("must be from {min} to {max}"));
}
Ok(())
}
fn kind_words(option: &Opt) -> String {
match option.kind {
Kind::Bool => String::from("true or false"),
Kind::Int => String::from("a whole number"),
Kind::Float => String::from("a number"),
Kind::Text => String::from("text in quotes"),
Kind::Color => String::from("a colour like \"#f7a8c8\" or \"#ffffff12\""),
Kind::List => match (option.default)() {
Value::List(items) => format!("a list of {} numbers", items.len()),
_ => String::from("a list"),
},
}
}
pub fn errors() -> Vec<String> {
ERRORS.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
// a setting's value, its default where config.toml doesn't set it
pub fn current(option: &Opt) -> Value {
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(option.path).cloned().unwrap_or_else(option.default)
}
pub fn is_default(option: &Opt) -> bool {
current(option) == (option.default)()
}
// the Hyprland options set here, as (hyprland.decoration.blur.size, value), sorted
pub fn overrides() -> Vec<(String, Value)> {
let mut found: Vec<(String, Value)> = VALUES
.read()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.filter(|(path, _)| path.starts_with("hyprland."))
.map(|(path, value)| (path.clone(), value.clone()))
.collect();
found.sort_by(|a, b| a.0.cmp(&b.0));
found
}
pub fn override_of(path: &str) -> Option<Value> {
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).cloned()
}
// sets one setting and saves the file; the shell redraws with it at once
pub fn set(path: &str, value: Value) -> Result<(), String> {
check(path, &value)?;
let mut written = written();
change(|values| values.insert(path.to_string(), value));
save(&mut written)
}
// back to the default (a Hyprland option: back to Hyprland's own config)
pub fn reset(path: &str) -> Result<(), String> {
let mut written = written();
change(|values| values.remove(path));
save(&mut written)
}
fn save(written: &mut String) -> Result<(), String> {
let text = render();
write_file(&text).map_err(|error| format!("couldn't write {}: {error}", file().display()))?;
*written = text;
theme::touch();
Ok(())
}
// whole or not at all: a new file renamed over the old, so no reader sees half of it
fn write_file(text: &str) -> std::io::Result<()> {
let fresh = file().with_extension("toml.new");
fs::write(&fresh, text)?;
fs::rename(&fresh, file())
}
// config.toml as the shell writes it: every setting with its help, then the Hyprland options set here
pub fn render() -> String {
let mut out = String::from(
"# Magic Island settings. The config window (amane ipc call config toggle, or Settings in the\n\
# control centre) writes this file; hand edits apply as soon as it's saved. A setting left out\n\
# takes its default. Saving from the window rewrites the file, comments included.\n",
);
let mut table = "";
for option in OPTIONS {
let at = &option.path[..option.path.len() - option.name().len() - 1];
if at != table {
out.push_str(&format!("\n[{at}]\n"));
table = at;
}
for line in wrapped(option.help, 96) {
out.push_str(&format!("# {line}\n"));
}
out.push_str(&format!("{} = {}\n", option.name(), parse::toml_text(&current(option))));
}
let overrides = overrides();
if !overrides.is_empty() {
out.push_str(
"\n# Hyprland options changed in the config window. The shell writes them to ~/.config/hypr/amane.lua;\n\
# load that last in hyprland.lua with require(\"amane\") so they win over the rest of your config.\n",
);
let mut table = String::new();
for (path, value) in overrides {
let (at, name) = path.rsplit_once('.').unwrap_or(("hyprland", &path));
if at != table {
out.push_str(&format!("\n[{at}]\n"));
table = at.to_string();
}
out.push_str(&format!("{name} = {}\n", parse::toml_text(&value)));
}
}
out
}
// help text as comment lines of at most `width` letters
fn wrapped(text: &str, width: usize) -> Vec<String> {
let mut lines: Vec<String> = vec![String::new()];
for word in text.split_whitespace() {
let line = lines.last_mut().expect("starts with one line");
if !line.is_empty() && line.len() + 1 + word.len() > width {
lines.push(word.to_string());
} else {
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
}
lines
}
// a value as the config window shows it
pub fn shown(value: &Value) -> String {
match value {
Value::Bool(on) => on.to_string(),
Value::Int(number) => number.to_string(),
Value::Float(number) => parse::number_text(*number),
Value::Text(text) => text.clone(),
Value::List(items) => items.iter().map(shown).collect::<Vec<_>>().join(" "),
Value::Null | Value::Table(_) => String::new(),
}
}
// what was typed for a setting, as its value; checked when it's set
pub fn from_text(option: &Opt, text: &str) -> Result<Value, String> {
let text = text.trim();
let wrong = || format!("{} needs {}", option.name(), kind_words(option));
match option.kind {
Kind::Bool => match text {
"true" | "yes" | "on" | "1" => Ok(Value::Bool(true)),
"false" | "no" | "off" | "0" => Ok(Value::Bool(false)),
_ => Err(wrong()),
},
Kind::Int => text.parse::<i64>().map(Value::Int).map_err(|_| wrong()),
Kind::Float => text.parse::<f64>().map(Value::Float).map_err(|_| wrong()),
Kind::Text => Ok(Value::Text(text.to_string())),
Kind::Color => color(text).map(|color| Value::Text(color.hex())).ok_or_else(wrong),
Kind::List => {
let whole = matches!((option.default)(), Value::List(items) if items.first().is_some_and(|item| matches!(item, Value::Int(_))));
text.split(|letter: char| letter.is_whitespace() || letter == ',')
.filter(|word| !word.is_empty())
.map(|word| if whole { word.parse().map(Value::Int).ok() } else { word.parse().map(Value::Float).ok() })
.collect::<Option<Vec<_>>>()
.map(Value::List)
.ok_or_else(wrong)
}
}
}
// the shell's pages in the window, in the order they're declared
pub fn modules() -> Vec<&'static str> {
let mut found: Vec<&'static str> = Vec::new();
for option in OPTIONS {
if !found.contains(&option.module()) {
found.push(option.module());
}
}
found
}
// a page's or group's name as the window shows it
pub fn title(id: &str) -> String {
match id {
"control" => String::from("Control centre"),
"pills" => String::from("Side pills"),
"bar" => String::from("Bar hiding"),
"input-capture" => String::from("Input capture"),
"opengl" => String::from("OpenGL"),
"xwayland" => String::from("XWayland"),
_ => {
let words = id.replace(['_', '-'], " ");
let mut letters = words.chars();
letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
}
}
}
// a setting's name as a label: "peek_ms" reads "Peek (ms)"
pub fn label(name: &str) -> String {
for (unit, words) in [("_ms", " (ms)"), ("_s", " (s)")] {
if let Some(stem) = name.strip_suffix(unit) {
return format!("{}{words}", title(stem));
}
}
title(name)
}
#[cfg(test)]
mod tests {
use super::*;
// the cached accessors follow every change, on this thread and on others
#[test]
fn accessors_follow_changes() {
let (width, family, accent) = (island::pill::width(), appearance::font::family(), appearance::fallback::accent());
change(|values| {
values.insert("island.pill.width".into(), Value::Float(321.0));
values.insert("appearance.font.family".into(), Value::Text("Iosevka".into()));
values.insert("appearance.fallback.accent".into(), Value::Text("#102030".into()));
});
assert_eq!(island::pill::width(), 321.0);
assert_eq!(appearance::font::family(), "Iosevka");
assert_eq!(appearance::fallback::accent(), Color::rgb(16, 32, 48));
assert_eq!(std::thread::spawn(island::pill::width).join().unwrap(), 321.0);
change(|values| values.clear());
assert_eq!((island::pill::width(), appearance::font::family(), appearance::fallback::accent()), (width, family, accent));
}
}