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; fn value(&self) -> Value; } impl Setting for bool { const KIND: Kind = Kind::Bool; fn read(value: &Value) -> Option { 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 { 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 { 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 { 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 { 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 { let Value::List(items) = value else { return None }; let numbers: Vec = items.iter().map(|item| item.number().map(|number| number as f32)).collect::>()?; 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 { let Value::List(items) = value else { return None }; let numbers: Vec = items.iter().map(u32::read).collect::>()?; 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 { 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/.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>> = LazyLock::new(Default::default); // what was wrong in the file when it was last read, for the config window static ERRORS: Mutex> = 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 = 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(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 = 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 { 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 { 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 { let mut lines: Vec = 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::>().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 { 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::().map(Value::Int).map_err(|_| wrong()), Kind::Float => text.parse::().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::>>() .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) }