6399844731
- A workspace Hyprland makes for a switch (createworkspacev2) grows into the row over the slide instead of appearing at full size; the window keeps the full room meanwhile. - Every config.toml save restarted the colour Animation, and Animation::new runs its clock even from a colour to itself, so every window redrew for 0.4 s per save. The colours now only fade when they change. - The file watch could read config.toml halfway through the shell's own save and reset settings to their defaults. Saves and rereads share one lock, the file is written to a new file and renamed over, and the shell's own saves are skipped by the watch. - A song change or new player only pops the island when the focused window isn't that player's app (parse::same_app, tested).
890 lines
37 KiB
Rust
890 lines
37 KiB
Rust
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::{LazyLock, Mutex, PoisonError, RwLock};
|
|
|
|
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 {
|
|
super::super::get(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"] = 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) or liquid (clear glass with a light-catching edge). 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"
|
|
}
|
|
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"
|
|
}
|
|
}
|
|
|
|
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"
|
|
vram_height: f32 = 14.0, 4..=60; "Height of the external GPU's memory line"
|
|
row_height: f32 = 22.0, 8..=100; "Height of a program row under the external GPU"
|
|
max_rows: usize = 5, 1..=50; "Programs listed under the external GPU before \"and N more\""
|
|
}
|
|
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"
|
|
}
|
|
}
|
|
}
|
|
|
|
// what config.toml set, by path; a missing or bad setting takes its default
|
|
static VALUES: LazyLock<RwLock<HashMap<String, Value>>> = LazyLock::new(Default::default);
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ponytail: a lock and a hash lookup per call, a few hundred a frame; a snapshot struct if a profile ever shows it
|
|
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)
|
|
}
|
|
|
|
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();
|
|
let mut values = VALUES.write().unwrap_or_else(PoisonError::into_inner);
|
|
|
|
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}")),
|
|
}
|
|
}
|
|
|
|
*VALUES.write().unwrap_or_else(PoisonError::into_inner) = 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();
|
|
|
|
VALUES.write().unwrap_or_else(PoisonError::into_inner).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();
|
|
|
|
VALUES.write().unwrap_or_else(PoisonError::into_inner).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"),
|
|
"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)
|
|
}
|