1084 lines
51 KiB
Rust
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(¤t(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));
|
|
}
|
|
}
|