From 4fbfad3ce33b2c7d814f78ca955128ec80d9e0a0 Mon Sep 17 00:00:00 2001 From: server2 Date: Thu, 15 Oct 2026 02:18:57 +0200 Subject: [PATCH] Config window: every setting in config.toml, Hyprland options from Hyprland All tunable values (sizes, timings, text sizes, colours, polls, equalizer presets and bands, hotspot, extra output) are settings declared once in config.rs, read from ~/.config/amane/config.toml, applied live on save. shell.conf's four values move over on first start. settings.rs: a normal window (control centre gear, or ipc config) with a page per part, a page per Hyprland category, search and reset per row. hypr.rs: Hyprland's options from hyprctl descriptions/getoption, applied with hl.config and kept in ~/.config/hypr/amane.lua. --- README.md | 45 ++- shell.conf | 16 - src/art.rs | 3 +- src/config.rs | 814 ++++++++++++++++++++++++++++++++++++++++++++++ src/control.rs | 463 +++++++++++++------------- src/edge.rs | 43 +-- src/glyph.rs | 14 + src/hypr.rs | 410 +++++++++++++++++++++++ src/island.rs | 376 ++++++++++----------- src/main.rs | 12 +- src/map.rs | 86 ++--- src/parse.rs | 549 ++++++++++++++++++++++++++++++- src/prompt.rs | 85 +++-- src/settings.rs | 699 +++++++++++++++++++++++++++++++++++++++ src/sound.rs | 42 +-- src/spectrum.rs | 11 +- src/system.rs | 18 +- src/theme.rs | 108 +++--- src/tray.rs | 94 +++--- src/workspaces.rs | 65 ++-- 20 files changed, 3172 insertions(+), 781 deletions(-) delete mode 100644 shell.conf create mode 100644 src/config.rs create mode 100644 src/hypr.rs create mode 100644 src/settings.rs diff --git a/README.md b/README.md index 3be60cd..0242c68 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The panel has: - *Ethernet*: every real wired card in `/sys/class/net` (a device behind it, not wireless): connected or no cable, and the link speed it negotiated (`speed`, as 1 Gb/s). A click turns the panel into its details (it morphs like into any menu; the back button returns): the interface and driver, status (speed, duplex), every IPv4 and IPv6 address, gateway, DNS servers, MAC, MTU and traffic since boot (`ip -o addr`, `ip -o route`, `/etc/resolv.conf`, sysfs). Addresses, gateway, DNS and MAC have a Copy chip; a click anywhere on such a row copies it (addresses without their prefix). - **Popups:** questions (a password, PIN or passkey), a code to confirm, a saved password or why something failed, in a small card over the menu. Only a popup with a field takes the keyboard (Enter answers, Escape cancels); the others leave keys to other windows until clicked. - **Menus:** one shape the whole way: the panel fades its content out and takes the menu's size (the Wi-Fi and Bluetooth maps reach from the monitor's right edge to the media island's pill, leaving a 16 px gap, room for the pill's pop: 822 × 480 px on a 1920 px monitor; at most 8 networks or devices, the strongest first, so their labels never meet), then the menu's content fades in over it from a window of its own (so the maps' animation redraws only that window). The back button returns to the panel. Moving the pointer off both closes the menu where it is: its content fades out and it shrinks straight into the pill, without the panel growing back first. -- **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. With `extra_output` in `shell.conf` a second slider follows for the output whose name contains it (here `NR515`: the `[TX-NR515]` receiver on HDMI 1). Clicking the block elsewhere opens the Sound menu: every output with its own slider and mute; click a name to make it the default output. +- **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. With `control.services.extra_output` set (see [Settings](#settings)) a second slider follows for the output whose name contains it (here `NR515`: the `[TX-NR515]` receiver on HDMI 1). Clicking the block elsewhere opens the Sound menu: every output with its own slider and mute; click a name to make it the default output. - **Display:** the laptop screen's brightness and the keyboard light (`tpacpi::kbd_backlight`: Off, Low, High), written straight to their sysfs files (a udev rule makes them writable by everyone on this laptop), else through logind, which refuses a shell without a seat (one started over ssh). For external monitors see [External monitor brightness](#external-monitor-brightness). - **Performance:** the firmware profiles in `/sys/firmware/acpi/platform_profile_choices` (Low power, Balanced, Performance), and the battery's state and time left. Picking one sets the platform profile and the CPU governor and energy preference with it: Low power = `powersave` + `power`, Balanced = `powersave` + `balance_performance`, Performance = the `performance` governor. - **External GPU:** the GPU the machine didn't boot with (`boot_vga = 0`, here the Radeon RX 6600 in the Thunderbolt enclosure): load, temperature, power draw, video memory, and every program that has it open with its video memory (and its share of the GPU while the driver reports engine time). Found through `/proc//fdinfo` (`drm-pdev`), only while the panel is open. Root's processes can't be read and don't show; the shell itself shows up, because Vulkan opens every GPU. @@ -74,7 +74,7 @@ iwd turns the Wi-Fi device into an access point, so the laptop isn't a Wi-Fi cli DNSList= ``` -3. `hotspot = ` in `shell.conf` (applies live). +3. `hotspot = ""` under `[control.services]` in `config.toml`, or Control centre › Services in the config window (applies live). 4. To share `eth0`'s internet with the hotspot's devices, forwarding and NAT (iwd doesn't do this), e.g. with nftables: ```sh @@ -109,16 +109,25 @@ Register the template in Skwd-wall as a matugen integration: The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`). -`shell.conf` (read live, no restart): +Whether the open card takes the song cover's colours instead (`appearance.colors.cover_colors`), the fade time, and the colours used until there is a theme file are [settings](#settings). -``` -cover_colors = false # true: the open media card and equalizer take the song cover's colours as background -transition_ms = 400 # how long a colour change cross-fades; 0 switches instantly -hotspot = MyHotspot # the iwd access point profile the control centre's hotspot starts -extra_output = NR515 # a second Sound slider for the output whose name contains this +## Settings + +Every setting lives in `~/.config/amane/config.toml`: sizes, timings, text sizes, colours, poll intervals, equalizer presets and bands, the hotspot profile, the extra Sound output. The shell writes the file with every setting and its explanation the first time it starts (moving `cover_colors`, `transition_ms`, `hotspot` and `extra_output` over from an old `shell.conf`, which it no longer reads). Saving the file applies it at once; a line left out takes its default, and a line the shell can't use is listed at the top of the config window. The font and the cava bar count take effect on restart. + +The config window edits the same file: Settings at the foot of the control centre, or `amane ipc call config toggle`. It is a normal window with a page per part of the shell, a page per Hyprland category, and a search over everything. Numbers with a range get a slider and a field, switches toggle, colours show a swatch; a typed value applies on Enter. Reset brings back the default. + +### Hyprland options + +The Hyprland pages list every option Hyprland itself describes (`hyprctl descriptions`), with its current value and default, and whether your Hyprland config sets it. A change applies at once (`hl.config`) and is kept in `config.toml` under `[hyprland.*]`; the shell writes those to `~/.config/hypr/amane.lua`. Load that file last in `hyprland.lua`, so the window's values survive a Hyprland reload and win over the rest of your config: + +```lua +require("amane") ``` -Keep comments on lines of their own in the real file: a `#` after a value becomes part of it. +Reset on a Hyprland option removes it from both files and reloads Hyprland's config, so your own value comes back. + +Gradients are typed `rgba(80d9ffff) rgba(b18cffff) 45deg` (or `#80d9ff`), points `0 0`, gaps `6` or `6 10` (css-style). ## Layout @@ -137,10 +146,12 @@ src/tray.rs the system tray: StatusNotifierWatcher and host, icon lookup, src/png.rs a stdlib png writer, for tray icons sent as pixels src/system.rs what the control centre shows: iwd, wired cards, openvpn, power, the external GPU src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one -src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade +src/theme.rs the shell's colours: the matugen theme file, the cross-fade; watches config.toml +src/config.rs every setting, declared once (typed accessors + the window's list), config.toml read and written +src/hypr.rs Hyprland's options for the config window: hyprctl descriptions/getoption, hl.config, amane.lua +src/settings.rs the config window, `config` IPC src/parse.rs pure text parsing with unit tests, no amane dependency matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme -shell.conf settings: cover_colors, transition_ms, hotspot, extra_output island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq) tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), see AGENTS.md ``` @@ -157,7 +168,7 @@ On the desktop that runs the shell: ## Install and run -The shell lives in `~/.config/amane/`. Clone this repo there (or copy its `src/`, `matugen/` and `shell.conf`), then build and start: +The shell lives in `~/.config/amane/`. Clone this repo there (or copy its `src/` and `matugen/`), then build and start: ```sh amane compile @@ -176,6 +187,14 @@ amane ipc call island menu output # open a dropdown: output | player | clos amane ipc call island preset Rock # apply an equalizer preset to the shown player amane ipc call island output # move the shown player to a sink (pactl sink name) amane ipc call island status # player= expanded= menu= +amane ipc call config toggle # the config window; also: open, close +amane ipc call config page island # open it on a page: appearance, island, ..., hyprland:decoration +amane ipc call config search blur # open it searching +amane ipc call config get island.pill.width +amane ipc call config set island.pill.width 260 # any setting by its config.toml path +amane ipc call config set hyprland.decoration.blur.size 3 +amane ipc call config reset island.pill.width +amane ipc call config status # open= page= errors= search= ``` Example Hyprland keybind: @@ -186,6 +205,8 @@ bind = SUPER, M, exec, amane ipc call island toggle ## Runtime files +- `~/.config/amane/config.toml`: every setting (see [Settings](#settings)); rewritten by the config window. +- `~/.config/hypr/amane.lua`: the Hyprland options set in the config window, as `hl.config`; rewritten by the shell. - `~/.config/amane/island-eq`: one line per player identity, `\t\t<10 gains in dB>`. - `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-.conf`. On tmpfs, gone at logout. - Equalizer nodes are named `magic_island_eq.`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`). diff --git a/shell.conf b/shell.conf deleted file mode 100644 index 90ff814..0000000 --- a/shell.conf +++ /dev/null @@ -1,16 +0,0 @@ -# amane shell settings, picked up the moment this file is saved: no restart needed. -# the colours themselves come from the wallpaper (matugen/amane.colors, written to ./theme) - -# true: the open media card and equalizer take the song cover's colours as their background -# false: the whole shell uses the wallpaper's colours -cover_colors = false - -# how long a colour change cross-fades, in milliseconds; 0 switches instantly -transition_ms = 400 - -# the control centre's hotspot: the name of an iwd access point profile, /var/lib/iwd/ap/.ap -# (see README "Control centre"); leave it out and the hotspot tile says so -# hotspot = MyHotspot - -# a second slider under Sound in the control centre, for the output whose name contains this -extra_output = NR515 diff --git a/src/art.rs b/src/art.rs index 9aea641..62a1c49 100644 --- a/src/art.rs +++ b/src/art.rs @@ -7,6 +7,7 @@ use std::{env, fs, thread}; use amane::{Media, Service}; use crate::island::{self, Island}; +use crate::config::island::timing; use crate::parse; /* @@ -42,7 +43,7 @@ impl Service for Art { } // 4 checks a second: after next/previous the new cover follows the title quickly - thread::sleep(Duration::from_millis(250)); + thread::sleep(Duration::from_millis(timing::cover_check_ms())); } } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c29cc22 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,814 @@ +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)>, + 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 = $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)?), + 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" + } + 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()); + +// 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 text = fs::read_to_string(file()).unwrap_or_else(|_| { + migrate(); + + let text = render(); + + let _ = fs::write(file(), &text); + + text + }); + + apply_text(&text); +} + +// 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))); + } + + 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)?; + + VALUES.write().unwrap_or_else(PoisonError::into_inner).insert(path.to_string(), value); + + save() +} + +// back to the default (a Hyprland option: back to Hyprland's own config) +pub fn reset(path: &str) -> Result<(), String> { + VALUES.write().unwrap_or_else(PoisonError::into_inner).remove(path); + + save() +} + +fn save() -> Result<(), String> { + fs::write(file(), render()).map_err(|error| format!("couldn't write {}: {error}", file().display()))?; + + theme::touch(); + + Ok(()) +} + +// 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) +} diff --git a/src/control.rs b/src/control.rs index 3fbb140..9e21bc2 100644 --- a/src/control.rs +++ b/src/control.rs @@ -15,86 +15,44 @@ use amane::{ Shape, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Zone, children, shapes, }; -use crate::edge::{self, Edge, HIDE_H}; +use crate::config::appearance::{font, motion, overlays, text}; +use crate::config::control as c; +use crate::config::island::{animation as island_animation, pill as island_pill}; +use crate::edge::{self, Edge}; use crate::glyph::{Glyph, icon}; -use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, CLEAR, COMPACT_H, HOVER_MS, PILL_GONE, SHADOW, SHAPE_DONE, TOP_GAP, TRACK, backdrop, faded, mix, phases, veiled}; -use crate::map::{self, AREA_H, Centre, Link, NODES, Node}; +use crate::island::{self, CLEAR, backdrop, faded, mix, phases, veiled}; +use crate::map::{self, Centre, Link, Node}; use crate::parse; use crate::prompt; +use crate::settings; use crate::sound::Sound; use crate::system::{self, Charge, Gpu, Links, Power, VpnState}; -use crate::theme::{self, Theme}; +use crate::theme; -const OPEN_MS: u64 = 520; +// every size and timing is a setting (config.rs: control.*); these follow from them +fn menu_w() -> f32 { + c::panel::width() - 2.0 * c::menus::pad() +} -// a menu grows out of the tile it belongs to, on its own window over the panel -const MENU_MS: u64 = 380; -const MENU_PAD: f32 = 12.0; -const MENU_W: f32 = PANEL_W - 2.0 * MENU_PAD; -const MENU_HEADER_H: f32 = 28.0; -const MENU_ROW_H: f32 = 44.0; +fn inner_w() -> f32 { + c::panel::width() - 2.0 * c::panel::pad() +} -// the Ethernet menu's detail rows -const ETHERNET_W: f32 = 500.0; -const DETAIL_H: f32 = 28.0; -const DETAIL_LABEL_W: f32 = 78.0; -const COPY_W: f32 = 58.0; -const MENU_ROWS: usize = 7; -const NOTE_H: f32 = 18.0; -const OUTPUT_H: f32 = 22.0; -const OUTPUTS: usize = 8; +// the four connection tiles, two by two, each with a round button +fn tile_w() -> f32 { + (inner_w() - c::panel::gap()) / 2.0 +} -// the pill and the panel sit this far from the monitor's right edge -pub const RIGHT: f32 = 12.0; - -// between a map menu and the media island's pill: room for the pill's pop (15 px a side) and a little air -const ISLAND_GAP: f32 = 16.0; - -// the narrowest a map gets, on a small monitor (where it then runs under the island) -const MIN_MAP_W: f32 = 600.0; +// the rounded blocks below the tiles +fn module_w() -> f32 { + inner_w() - 2.0 * c::modules::pad() +} // how wide the Wi-Fi and Bluetooth maps are on a monitor: from its right edge to the island's pill, less the gap pub fn map_width(monitor_w: f32) -> f32 { - (monitor_w / 2.0 - island::COMPACT_W / 2.0 - ISLAND_GAP - RIGHT).max(MIN_MAP_W) + (monitor_w / 2.0 - island_pill::width() / 2.0 - c::maps::island_gap() - c::pill::right()).max(c::maps::min_width()) } -// the pill is as wide as its icons, PILL_PAD on each side, PILL_GAP apart -const PILL_PAD: f32 = 16.0; -const PILL_GAP: f32 = 12.0; -const PILL_HOVER_GROW: f32 = 6.0; - -const PANEL_W: f32 = 360.0; -const PANEL_RADIUS: f32 = 26.0; -const PAD: f32 = 12.0; -const INNER_W: f32 = PANEL_W - 2.0 * PAD; -const GAP: f32 = 10.0; - -// the four connection tiles, two by two, each with a round button -const TILE_W: f32 = (INNER_W - GAP) / 2.0; -const TILE_H: f32 = 60.0; -const ROUND: f32 = 34.0; - -// the rounded blocks below the tiles, and what's inside them -const MODULE_RADIUS: f32 = 18.0; -const MODULE_PAD: f32 = 12.0; -const MODULE_W: f32 = INNER_W - 2.0 * MODULE_PAD; -const TITLE_H: f32 = 18.0; -const LINE_GAP: f32 = 8.0; -const SLIDER_H: f32 = 28.0; -const KEYS_H: f32 = 26.0; -const PROFILE_H: f32 = 48.0; -const VRAM_H: f32 = 14.0; -const ROW_H: f32 = 22.0; -const MAX_ROWS: usize = 5; - -// Fira Code is about 0.64 em per letter, as in the island -const LETTER: f32 = 0.66; - -const LOW_BATTERY: Color = Color::rgb(255, 105, 97); - -// a dragged slider shows where it was let go until the system has caught up -const HELD_MS: u64 = 1200; - #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub enum Tile { Wifi, @@ -137,6 +95,8 @@ enum Spot { Switch, Row(usize), Footer, + // the panel's Settings button, which opens the config window + Settings, // this desktop's bubble in a map Bubble, OutputMute(u32), @@ -219,18 +179,18 @@ impl Control { fn slider(&self, which: Slider, actual: f32) -> f32 { match self.held.get(&which) { - Some((value, at)) if at.elapsed() < Duration::from_millis(HELD_MS) => *value, + Some((value, at)) if at.elapsed() < Duration::from_millis(c::animation::held_ms()) => *value, _ => actual, } } fn busy(&self, tile: Tile, subtitle: &str) -> bool { - self.busy.get(&tile).is_some_and(|(at, before)| before == subtitle && at.elapsed() < Duration::from_secs(10)) + self.busy.get(&tile).is_some_and(|(at, before)| before == subtitle && at.elapsed() < Duration::from_secs(c::animation::busy_s())) } // asked to connect less than 20 s ago fn pending(&self, id: &str) -> bool { - self.pending.as_ref().is_some_and(|(pending, at)| pending == id && at.elapsed() < Duration::from_secs(20)) + self.pending.as_ref().is_some_and(|(pending, at)| pending == id && at.elapsed() < Duration::from_secs(c::animation::pending_s())) } /* @@ -242,7 +202,7 @@ impl Control { let clicked = self.scan_at.map(|at| at.elapsed().as_secs_f32()); match clicked { - Some(since) if since < map::RIPPLE_S || daemon => Some(since), + Some(since) if since < map::ripple_s() || daemon => Some(since), _ => daemon.then(map::clock), } } @@ -291,7 +251,7 @@ fn hovering(spot: Spot) -> impl Fn(bool) + 'static { control .hover .entry(spot) - .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS))) + .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms()))) .to(if inside { 1.0 } else { 0.0 }); } } @@ -310,35 +270,38 @@ struct Layout { } impl Layout { - const LARGEST: Layout = Layout { ethernet: true, extra_output: true, screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(MAX_ROWS) }; + fn largest() -> Layout { + Layout { ethernet: true, extra_output: true, screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(c::modules::max_rows()) } + } fn sound(&self) -> f32 { - let extra: &[f32] = if self.extra_output { &[TITLE_H, SLIDER_H] } else { &[] }; + let extra: &[f32] = if self.extra_output { &[c::modules::title_height(), c::modules::slider_height()] } else { &[] }; - module_height(&[&[TITLE_H, SLIDER_H], extra].concat()).unwrap_or(0.0) + module_height(&[&[c::modules::title_height(), c::modules::slider_height()], extra].concat()).unwrap_or(0.0) } fn display(&self) -> Option { - let parts: Vec = [self.screen.then_some(TITLE_H), self.screen.then_some(SLIDER_H), self.keyboard.then_some(KEYS_H)].into_iter().flatten().collect(); + let parts: Vec = [self.screen.then_some(c::modules::title_height()), self.screen.then_some(c::modules::slider_height()), self.keyboard.then_some(c::modules::keys_height())].into_iter().flatten().collect(); module_height(&parts) } fn performance(&self) -> Option { - let parts: Vec = [(self.profiles || self.battery).then_some(TITLE_H), self.profiles.then_some(PROFILE_H)].into_iter().flatten().collect(); + let parts: Vec = [(self.profiles || self.battery).then_some(c::modules::title_height()), self.profiles.then_some(c::modules::profile_height())].into_iter().flatten().collect(); module_height(&parts) } fn gpu(&self) -> Option { - self.gpu_rows.map(|rows| 2.0 * MODULE_PAD + TITLE_H + VRAM_H + rows.max(1) as f32 * ROW_H + 2.0 * LINE_GAP) + self.gpu_rows.map(|rows| 2.0 * c::modules::pad() + c::modules::title_height() + c::modules::vram_height() + rows.max(1) as f32 * c::modules::row_height() + 2.0 * c::modules::line_gap()) } fn height(&self) -> f32 { - let modules: f32 = [Some(self.sound()), self.display(), self.performance(), self.gpu()].into_iter().flatten().map(|height| GAP + height).sum(); + let modules: f32 = [Some(self.sound()), self.display(), self.performance(), self.gpu()].into_iter().flatten().map(|height| c::panel::gap() + height).sum(); let tile_rows = if self.ethernet { 3.0 } else { 2.0 }; - 2.0 * PAD + tile_rows * TILE_H + (tile_rows - 1.0) * GAP + modules + // the Settings button closes the panel + 2.0 * c::panel::pad() + tile_rows * c::panel::tile_height() + (tile_rows - 1.0) * c::panel::gap() + modules + c::panel::gap() + c::panel::footer_height() } } @@ -347,7 +310,7 @@ fn module_height(parts: &[f32]) -> Option { return None; } - Some(2.0 * MODULE_PAD + parts.iter().sum::() + LINE_GAP * (parts.len() - 1) as f32) + Some(2.0 * c::modules::pad() + parts.iter().sum::() + c::modules::line_gap() * (parts.len() - 1) as f32) } // everything one frame of the open panel reads @@ -376,16 +339,16 @@ pub fn view(monitor: &Monitor) -> LayerWindow { // hidden until the pointer rests on the top edge (edge.rs); an open panel keeps it in let open = if opened_here { control.open.value() } else { 0.0 }; - let slide = edge::hidden_by(Edge::read().shown(here), open, HIDE_H); + let slide = edge::hidden_by(Edge::read().shown(here), open, edge::hide_h()); // as wide as the widest thing it can become, a map menu; the shape grows leftwards from the corner // both windows are as wide as a map menu, the widest the panel becomes let map_w = map_width(monitor.width as f32); - let window_w = RIGHT + map_w + SHADOW; + let window_w = c::pill::right() + map_w + island_pill::shadow(); let window = LayerWindow::new() .width(window_w) - .height(TOP_GAP + Layout::LARGEST.height() + SHADOW) + .height(island_pill::top_gap() + Layout::largest().height() + island_pill::shadow()) .anchor_vertical(Vertical::Top) .anchor_horizontal(Horizontal::Right) // Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them @@ -394,7 +357,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { .namespace("magic-control"); // out of sight: empty and click-through, and nothing else read, so status changes don't redraw it - if slide <= -HIDE_H { + if slide <= -edge::hide_h() { return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); } @@ -413,11 +376,11 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let audio = open.then(Audio::read); let screen = open.then(Brightness::read); let gpu = open.then(Gpu::read); - let theme_settings = open.then(Theme::read); + let settings = open.then(|| (c::services::hotspot(), c::services::extra_output())); let sound = open.then(Sound::read); - let extra_output = match (&sound, &theme_settings) { - (Some(sound), Some(settings)) => sound.output_named(&settings.extra_output), + let extra_output = match (&sound, &settings) { + (Some(sound), Some((_, extra))) => sound.output_named(extra), _ => None, }; @@ -428,13 +391,13 @@ pub fn view(monitor: &Monitor) -> LayerWindow { keyboard: power.keyboard.is_some(), profiles: !power.profiles.is_empty(), battery: power.battery.is_some(), - gpu_rows: gpu.as_deref().filter(|gpu| !gpu.name.is_empty()).map(|gpu| gpu.programs.len().min(MAX_ROWS)), + gpu_rows: gpu.as_deref().filter(|gpu| !gpu.name.is_empty()).map(|gpu| gpu.programs.len().min(c::modules::max_rows())), }; /* * a menu over the panel (its own window, menu_view): on the menu's timeline the - * panel's content fades out first (0 to PILL_GONE), then the empty panel takes the - * menu's height (to SHAPE_DONE) while the menu grows out of its tile; settled, the + * panel's content fades out first (0 to island_animation::pill_gone()), then the empty panel takes the + * menu's height (to island_animation::shape_done()) while the menu grows out of its tile; settled, the * menu lies exactly on the panel */ let menu_t = if opened_here { control.menu_open.value() } else { 0.0 }; @@ -448,24 +411,24 @@ pub fn view(monitor: &Monitor) -> LayerWindow { _ => None, }; - let fade = if menu_size.is_some() { 1.0 - (menu_t / PILL_GONE).clamp(0.0, 1.0) } else { 1.0 }; + let fade = if menu_size.is_some() { 1.0 - (menu_t / island_animation::pill_gone()).clamp(0.0, 1.0) } else { 1.0 }; - let morph = ((menu_t - PILL_GONE) / (SHAPE_DONE - PILL_GONE)).clamp(0.0, 1.0); + let morph = ((menu_t - island_animation::pill_gone()) / (island_animation::shape_done() - island_animation::pill_gone())).clamp(0.0, 1.0); let morph = morph * morph * (3.0 - 2.0 * morph); - let (full_w, full_h) = menu_size.map_or((PANEL_W, layout.height()), |(menu_w, menu_h)| (mix(PANEL_W, menu_w, morph), mix(layout.height(), menu_h, morph))); + let (full_w, full_h) = menu_size.map_or((c::panel::width(), layout.height()), |(menu_w, menu_h)| (mix(c::panel::width(), menu_w, morph), mix(layout.height(), menu_h, morph))); let (icons, pill_w) = pill_items(&links, &bluetooth, &power); - let width = mix(pill_w + PILL_HOVER_GROW * lift, full_w, grow); - let height = mix(COMPACT_H, full_h, grow); - let radius = mix(COMPACT_H / 2.0, PANEL_RADIUS, grow); + let width = mix(pill_w + c::animation::hover_grow() * lift, full_w, grow); + let height = mix(island_pill::height(), full_h, grow); + let radius = mix(island_pill::height() / 2.0, c::panel::radius(), grow); let colors = theme::now().surfaces(); // at most one face at a time, each faded by a veil instead of a gpu layer - let face = match (&audio, &screen, &gpu, &theme_settings) { - (Some(audio), Some(screen), Some(gpu), Some(settings)) if reveal > 0.0 && fade > 0.0 => { + let face = match (&audio, &screen, &gpu, &settings) { + (Some(audio), Some(screen), Some(gpu), Some((hotspot, _))) if reveal > 0.0 && fade > 0.0 => { let scene = Scene { control: &control, links: &links, @@ -474,7 +437,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { audio, screen, gpu, - hotspot: &settings.hotspot, + hotspot, extra_output, layout, }; @@ -525,8 +488,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow { // clicks beside the shape reach the windows underneath let region = InputArea { - x: (window_w - RIGHT - width).floor() as i32, - y: TOP_GAP as i32, + x: (window_w - c::pill::right() - width).floor() as i32, + y: island_pill::top_gap() as i32, width: width.ceil() as i32, height: height.ceil() as i32, }; @@ -535,7 +498,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { Rectangle::new() .width(Parent) .height(Parent) - .padding(Padding { top: TOP_GAP, right: RIGHT, bottom: 0.0, left: 0.0 }) + .padding(Padding { top: island_pill::top_gap(), right: c::pill::right(), bottom: 0.0, left: 0.0 }) .align_child(End, Start) .translate(0.0, slide) .child(shape), @@ -543,7 +506,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { } fn opening() -> Animation { - Animation::new(0.0).duration(Duration::from_millis(OPEN_MS)).easing(Easing::Linear) + Animation::new(0.0).duration(Duration::from_millis(c::animation::open_ms())).easing(Easing::Linear) } fn tap(monitor: &str) { @@ -610,7 +573,7 @@ fn collapse() { */ fn collapse_soon() { thread::spawn(|| { - thread::sleep(Duration::from_millis(250)); + thread::sleep(Duration::from_millis(c::animation::collapse_ms())); let gone = { let control = Control::read(); @@ -631,7 +594,7 @@ pub fn clear_pending() { } fn menu_opening() -> Animation { - Animation::new(0.0).duration(Duration::from_millis(MENU_MS)).easing(Easing::Linear) + Animation::new(0.0).duration(Duration::from_millis(c::animation::menu_ms())).easing(Easing::Linear) } // grows the menu out of its tile, and asks for fresh lists while it's open @@ -861,13 +824,13 @@ fn pill_items(links: &Links, bluetooth: &Bluetooth, power: &Power) -> (Vec() + PILL_GAP * (items.len() - 1) as f32 + 2.0 * PILL_PAD; + let width = items.iter().map(|(_, width)| width).sum::() + c::pill::gap() * (items.len() - 1) as f32 + 2.0 * c::pill::pad(); (items.into_iter().map(|(item, _)| item).collect(), width) } fn compact(items: Vec>, width: f32) -> Rectangle { - Rectangle::new().width(width).height(COMPACT_H).align_child(Center, Center).child(Row::new(items).gap(PILL_GAP).align(Center)) + Rectangle::new().width(width).height(island_pill::height()).align_child(Center, Center).child(Row::new(items).gap(c::pill::gap()).align(Center)) } // as wide as battery() draws: the bolt, the body and its nub, the percentage @@ -882,7 +845,7 @@ fn battery(charge: &Charge) -> Row { let theme = theme::now(); let low = charge.percent <= 15 && charge.status == "Discharging"; - let color = if low { LOW_BATTERY } else { theme.text }; + let color = if low { overlays::low_battery() } else { theme.text }; let level = f32::from(charge.percent) / 100.0; @@ -904,7 +867,7 @@ fn battery(charge: &Charge) -> Row { } parts.push(Box::new(Row::new(children![body, nub]).gap(1.0).align(Center))); - parts.push(Box::new(Text::new(format!("{}%", charge.percent)).size(11.0).color(theme.text))); + parts.push(Box::new(Text::new(format!("{}%", charge.percent)).size(text::small()).color(theme.text))); Row::new(parts).gap(5.0).align(Center) } @@ -926,25 +889,48 @@ fn panel(scene: &Scene) -> Rectangle { blocks.push(Box::new(gpu(scene, height))); } + blocks.push(Box::new(settings_button(scene.control))); + let height = scene.layout.height(); - Rectangle::new().width(PANEL_W).height(height).padding(PAD).child(Column::new(blocks).gap(GAP)) + Rectangle::new().width(c::panel::width()).height(height).padding(c::panel::pad()).child(Column::new(blocks).gap(c::panel::gap())) +} + +// across the panel's foot: opens the config window and closes the panel +fn settings_button(control: &Control) -> Rectangle { + let theme = theme::now(); + let lit = control.lit(Spot::Settings); + + Rectangle::new() + .width(inner_w()) + .height(c::panel::footer_height()) + .radius(c::modules::radius()) + .fill(Color::blend(overlays::chip(), overlays::chip_hover(), lit)) + .align_child(Center, Center) + .cursor(Pointer) + .on_hover(hovering(Spot::Settings)) + .on_click(|_| { + settings::open(); + + collapse(); + }) + .child(Row::new(children![icon(Glyph::Gear, 15.0, Color::blend(theme.dim, theme.text, lit)), Text::new("Settings").size(text::body()).color(theme.text)]).gap(8.0).align(Center)) } fn module(height: f32, content: Column) -> Rectangle { - Rectangle::new().width(INNER_W).height(height).radius(MODULE_RADIUS).fill(CHIP).padding(MODULE_PAD).child(content) + Rectangle::new().width(inner_w()).height(height).radius(c::modules::radius()).fill(overlays::chip()).padding(c::modules::pad()).child(content) } fn header(title: &str, right: Box) -> Row { - Row::new(vec![Box::new(Text::new(title).size(12.0).weight(Weight::Bold).color(theme::now().text)), right]) + Row::new(vec![Box::new(Text::new(title).size(text::body()).weight(Weight::Bold).color(theme::now().text)), right]) .justify(SpaceBetween) .align(Center) - .width(MODULE_W) - .height(TITLE_H) + .width(module_w()) + .height(c::modules::title_height()) } fn note(text: impl Into) -> Text { - Text::new(text.into()).size(11.0).color(theme::now().dim) + Text::new(text.into()).size(text::small()).color(theme::now().dim) } // the connection tiles @@ -959,8 +945,7 @@ enum Look { Stopped, } -// the strike over a stopped service's icon, neutral grey whatever the theme -const STRIKE: Color = Color::rgba(205, 205, 212, 170); +// the strike over a stopped service's icon is overlays::strike(), neutral grey whatever the theme #[derive(Clone)] enum Action { @@ -981,25 +966,25 @@ struct State { } fn tiles(scene: &Scene) -> Column { - let row = |left: Rectangle, right: Rectangle| Row::new(children![left, right]).gap(GAP); + let row = |left: Rectangle, right: Rectangle| Row::new(children![left, right]).gap(c::panel::gap()); let control = scene.control; // Wi-Fi, Bluetooth and VPN open their menu; the hotspot just switches; a wired link only shows, across the panel let mut rows: Vec> = children![ row( - tile(control, Tile::Wifi, "Wi-Fi", wifi_state(scene.links), Some(Menu::Wifi), TILE_W), - tile(control, Tile::Bluetooth, "Bluetooth", bluetooth_state(scene.bluetooth, scene.links), Some(Menu::Bluetooth), TILE_W), + tile(control, Tile::Wifi, "Wi-Fi", wifi_state(scene.links), Some(Menu::Wifi), tile_w()), + tile(control, Tile::Bluetooth, "Bluetooth", bluetooth_state(scene.bluetooth, scene.links), Some(Menu::Bluetooth), tile_w()), ), - row(tile(control, Tile::Vpn, "VPN", vpn_state(scene.links), Some(Menu::Vpn), TILE_W), tile(control, Tile::Hotspot, "Hotspot", hotspot_state(scene), None, TILE_W)), + row(tile(control, Tile::Vpn, "VPN", vpn_state(scene.links), Some(Menu::Vpn), tile_w()), tile(control, Tile::Hotspot, "Hotspot", hotspot_state(scene), None, tile_w())), ]; if scene.layout.ethernet { // a click turns the panel into what there is to know about the card - rows.push(Box::new(tile(control, Tile::Ethernet, "Ethernet", ethernet_state(scene.links), None, INNER_W).cursor(Pointer).on_click(|_| open_ethernet()))); + rows.push(Box::new(tile(control, Tile::Ethernet, "Ethernet", ethernet_state(scene.links), None, inner_w()).cursor(Pointer).on_click(|_| open_ethernet()))); } - Column::new(rows).gap(GAP) + Column::new(rows).gap(c::panel::gap()) } fn wifi_state(links: &Links) -> State { @@ -1091,7 +1076,7 @@ fn hotspot_state(scene: &Scene) -> State { } else if wifi.access_point { (Look::On, String::from("Starting…"), Some(Action::Hotspot)) } else if scene.hotspot.is_empty() { - // `hotspot = ` in shell.conf, see README "Hotspot setup" + // control.services.hotspot in config.toml, see README "Hotspot setup" (Look::Grey, String::from("Not set up"), None) } else { (Look::Off, scene.hotspot.to_string(), Some(Action::Hotspot)) @@ -1108,47 +1093,47 @@ fn tile(control: &Control, which: Tile, title: &'static str, state: State, menu: let (round, mark) = match state.look { Look::On => (theme.accent, theme.on_accent), - Look::Off => (CHIP_OPEN, theme.text), - Look::Grey | Look::Stopped => (CHIP, theme.dim), + Look::Off => (overlays::chip_open(), theme.text), + Look::Grey | Look::Stopped => (overlays::chip(), theme.dim), }; let subtitle = if busy { String::from("One moment…") } else { state.subtitle.clone() }; - let words_w = width - 12.0 - 10.0 - ROUND - 10.0; + let words_w = width - 12.0 - 10.0 - c::panel::button() - 10.0; - let mark = Rectangle::new().width(ROUND).height(ROUND).align_child(Center, Center).child(icon(state.glyph, 16.0, mark)); + let mark = Rectangle::new().width(c::panel::button()).height(c::panel::button()).align_child(Center, Center).child(icon(state.glyph, 16.0, mark)); // a stopped service's icon is struck through, corner to corner, like a slashed sign let mark = if state.look == Look::Stopped { - let strike = Canvas::new().width(ROUND).height(ROUND).shapes(shapes![Line::new().from(9.0, 9.0).to(ROUND - 9.0, ROUND - 9.0).stroke(1.8, STRIKE).cap(Cap::Round)]); + let strike = Canvas::new().width(c::panel::button()).height(c::panel::button()).shapes(shapes![Line::new().from(9.0, 9.0).to(c::panel::button() - 9.0, c::panel::button() - 9.0).stroke(1.8, overlays::strike()).cap(Cap::Round)]); - Rectangle::new().width(ROUND).height(ROUND).child(Stack::new(children![mark, strike]).width(ROUND).height(ROUND)) + Rectangle::new().width(c::panel::button()).height(c::panel::button()).child(Stack::new(children![mark, strike]).width(c::panel::button()).height(c::panel::button())) } else { mark }; // the title, with a link speed at its right when there is one let title_color = if matches!(state.look, Look::Grey | Look::Stopped) { theme.dim } else { theme.text }; - let title = Text::new(title).size(12.0).weight(Weight::Bold).color(title_color).elide(); + let title = Text::new(title).size(text::body()).weight(Weight::Bold).color(title_color).elide(); let title: Box = match &state.badge { - Some(badge) => Box::new(Row::new(children![title, Text::new(badge.as_str()).size(10.0).color(theme.accent)]).justify(SpaceBetween).align(Center).width(words_w)), + Some(badge) => Box::new(Row::new(children![title, Text::new(badge.as_str()).size(text::tiny()).color(theme.accent)]).justify(SpaceBetween).align(Center).width(words_w)), None => Box::new(title), }; let tile = Rectangle::new() .width(width) - .height(TILE_H) - .radius(MODULE_RADIUS) - .fill(Color::blend(CHIP, CHIP_HOVER, lit)) + .height(c::panel::tile_height()) + .radius(c::modules::radius()) + .fill(Color::blend(overlays::chip(), overlays::chip_hover(), lit)) .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 12.0 }) .align_child(Start, Center) .scale(1.0 + 0.03 * lit) .on_hover(hovering(Spot::Tile(which))) .child( Row::new(children![ - Rectangle::new().width(ROUND).height(ROUND).radius(Full).fill(Color::blend(round, theme.flame, 0.25 * lit)).child(mark), - Column::new(vec![title, Box::new(Text::new(subtitle).size(10.5).color(theme.dim).elide())]).gap(3.0).width(words_w), + Rectangle::new().width(c::panel::button()).height(c::panel::button()).radius(Full).fill(Color::blend(round, theme.flame, 0.25 * lit)).child(mark), + Column::new(vec![title, Box::new(Text::new(subtitle).size(text::caption()).color(theme.dim).elide())]).gap(3.0).width(words_w), ]) .gap(10.0) .align(Center), @@ -1216,15 +1201,15 @@ fn sound(scene: &Scene) -> Rectangle { let mute = Rectangle::new() .width(label_width(&label, 11.0, 30.0)) - .height(TITLE_H + 2.0) + .height(c::modules::title_height() + 2.0) .radius(6.0) - .fill(Color::blend(if muted { CHIP_OPEN } else { island::CLEAR }, CHIP_HOVER, lit)) + .fill(Color::blend(if muted { overlays::chip_open() } else { island::CLEAR }, overlays::chip_hover(), lit)) .align_child(Center, Center) .cursor(Pointer) .on_hover(hovering(Spot::Mute)) .on_click(|_| Audio::toggle_mute()) .child( - Row::new(children![icon(if muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, theme.dim), Text::new(label).size(11.0).color(theme.text)]) + Row::new(children![icon(if muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, theme.dim), Text::new(label).size(text::small()).color(theme.text)]) .gap(5.0) .align(Center), ); @@ -1233,20 +1218,20 @@ fn sound(scene: &Scene) -> Rectangle { let mut rows: Vec> = vec![ Box::new(header("Sound", Box::new(mute))), - Box::new(slider(scene.control, Slider::Volume, if muted { 0.0 } else { value }, glyph, MODULE_W, set_volume)), + Box::new(slider(scene.control, Slider::Volume, if muted { 0.0 } else { value }, glyph, module_w(), set_volume)), ]; - // a second output with its own slider (`extra_output` in shell.conf), e.g. the AV receiver + // a second output with its own slider (control.services.extra_output), e.g. the AV receiver if let Some(sink) = scene.extra_output { - rows.push(Box::new(output_label(scene.control, sink, MODULE_W))); - rows.push(Box::new(output_slider(scene.control, sink, MODULE_W))); + rows.push(Box::new(output_label(scene.control, sink, module_w()))); + rows.push(Box::new(output_slider(scene.control, sink, module_w()))); } // the whole block opens the Sound menu; its slider and chips take their own clicks first let lit = scene.control.lit(Spot::Sound); - module(scene.layout.sound(), Column::new(rows).gap(LINE_GAP)) - .fill(Color::blend(CHIP, CHIP_HOVER, 0.5 * lit)) + module(scene.layout.sound(), Column::new(rows).gap(c::modules::line_gap())) + .fill(Color::blend(overlays::chip(), overlays::chip_hover(), 0.5 * lit)) .cursor(Pointer) .on_hover(hovering(Spot::Sound)) .on_click(|_| open_menu(Menu::Sound)) @@ -1258,7 +1243,7 @@ fn output_label(control: &Control, sink: &parse::Sink, width: f32) -> Row { let title = parse::middle_ellipsis(parse::short_sink_name(&sink.description), 34); - Row::new(children![Text::new(title).size(11.0).color(theme.dim).elide(), output_mute(control, sink)]).justify(SpaceBetween).align(Center).width(width).height(TITLE_H) + Row::new(children![Text::new(title).size(text::small()).color(theme.dim).elide(), output_mute(control, sink)]).justify(SpaceBetween).align(Center).width(width).height(c::modules::title_height()) } fn output_mute(control: &Control, sink: &parse::Sink) -> Rectangle { @@ -1270,14 +1255,14 @@ fn output_mute(control: &Control, sink: &parse::Sink) -> Rectangle { Rectangle::new() .width(label_width(&label, 11.0, 14.0)) - .height(TITLE_H) + .height(c::modules::title_height()) .radius(6.0) - .fill(Color::blend(if sink.muted { CHIP_OPEN } else { CLEAR }, CHIP_HOVER, lit)) + .fill(Color::blend(if sink.muted { overlays::chip_open() } else { CLEAR }, overlays::chip_hover(), lit)) .align_child(Center, Center) .cursor(Pointer) .on_hover(hovering(Spot::OutputMute(sink.index))) .on_click(move |_| Sound::toggle_output_mute(&name)) - .child(Text::new(label).size(11.0).color(theme::now().text)) + .child(Text::new(label).size(text::small()).color(theme::now().text)) } fn output_slider(control: &Control, sink: &parse::Sink, width: f32) -> Rectangle { @@ -1300,7 +1285,7 @@ fn display(scene: &Scene, height: f32) -> Rectangle { let value = scene.control.slider(Slider::Screen, f32::from(scene.screen.percent().min(100)) / 100.0); rows.push(Box::new(header("Display", Box::new(note(format!("{}%", (value * 100.0).round())))))); - rows.push(Box::new(slider(scene.control, Slider::Screen, value, Glyph::Sun, MODULE_W, set_screen))); + rows.push(Box::new(slider(scene.control, Slider::Screen, value, Glyph::Sun, module_w(), set_screen))); } if let Some(light) = &scene.power.keyboard { @@ -1323,7 +1308,7 @@ fn display(scene: &Scene, height: f32) -> Rectangle { let lit = scene.control.lit(Spot::Light(level)); keys.push(Box::new( - segment(Row::new(children![label(name, 11.0, chosen)]), chosen, lit, keys_w, KEYS_H) + segment(Row::new(children![label(name, 11.0, chosen)]), chosen, lit, keys_w, c::modules::keys_height()) .on_hover(hovering(Spot::Light(level))) .on_click(move |_| { thread::spawn(move || system::set_keyboard(level)); @@ -1333,17 +1318,17 @@ fn display(scene: &Scene, height: f32) -> Rectangle { rows.push(Box::new( Row::new(children![ - Row::new(children![icon(Glyph::Keyboard, 15.0, theme.dim), Text::new("Keyboard").size(12.0).color(theme.text)]).gap(8.0).align(Center), + Row::new(children![icon(Glyph::Keyboard, 15.0, theme.dim), Text::new("Keyboard").size(text::body()).color(theme.text)]).gap(8.0).align(Center), Row::new(keys).gap(6.0), ]) .justify(SpaceBetween) .align(Center) - .width(MODULE_W) - .height(KEYS_H), + .width(module_w()) + .height(c::modules::keys_height()), )); } - module(height, Column::new(rows).gap(LINE_GAP)) + module(height, Column::new(rows).gap(c::modules::line_gap())) } /* @@ -1357,27 +1342,27 @@ fn slider(control: &Control, which: Slider, value: f32, glyph: Glyph, width: f32 let lit = control.lit(Spot::Slider(which)); - let fill_w = SLIDER_H + (width - SLIDER_H) * value.clamp(0.0, 1.0); + let fill_w = c::modules::slider_height() + (width - c::modules::slider_height()) * value.clamp(0.0, 1.0); let dragged = set.clone(); Rectangle::new() .width(width) - .height(SLIDER_H) + .height(c::modules::slider_height()) .radius(Full) - .fill(Color::blend(TRACK, CHIP_HOVER, lit)) + .fill(Color::blend(overlays::track(), overlays::chip_hover(), lit)) .cursor(Pointer) .on_hover(hovering(Spot::Slider(which))) .on_click(|_| {}) - .on_drag(move |point| slide(which, (point.x - SLIDER_H / 2.0) / (width - SLIDER_H), &dragged)) + .on_drag(move |point| slide(which, (point.x - c::modules::slider_height() / 2.0) / (width - c::modules::slider_height()), &dragged)) .on_scroll(move |scroll| slide(which, value + if scroll.y < 0.0 { 0.05 } else { -0.05 }, &set)) .child( Stack::new(children![ - Rectangle::new().width(fill_w).height(SLIDER_H).radius(Full).fill(Color::blend(theme.accent, theme.flame, 0.35 * lit)), - Rectangle::new().width(SLIDER_H).height(SLIDER_H).align_child(Center, Center).child(icon(glyph, 14.0, theme.on_accent)), + Rectangle::new().width(fill_w).height(c::modules::slider_height()).radius(Full).fill(Color::blend(theme.accent, theme.flame, 0.35 * lit)), + Rectangle::new().width(c::modules::slider_height()).height(c::modules::slider_height()).align_child(Center, Center).child(icon(glyph, 14.0, theme.on_accent)), ]) .width(width) - .height(SLIDER_H), + .height(c::modules::slider_height()), ) } @@ -1387,7 +1372,7 @@ fn slide(which: Slider, value: f32, set: &impl Fn(f32)) { let unchanged = { let mut control = Control::write(); - let unchanged = control.held.get(&which).is_some_and(|(held, at)| (held * 100.0).round() == (value * 100.0).round() && at.elapsed() < Duration::from_millis(HELD_MS)); + let unchanged = control.held.get(&which).is_some_and(|(held, at)| (held * 100.0).round() == (value * 100.0).round() && at.elapsed() < Duration::from_millis(c::animation::held_ms())); control.held.insert(which, (value, Instant::now())); @@ -1418,7 +1403,7 @@ fn set_screen(value: f32) { fn segment(content: Row, chosen: bool, lit: f32, width: f32, height: f32) -> Rectangle { let theme = theme::now(); - let fill = if chosen { Color::blend(theme.accent, theme.flame, 0.5 * lit) } else { Color::blend(CHIP, CHIP_HOVER, lit) }; + let fill = if chosen { Color::blend(theme.accent, theme.flame, 0.5 * lit) } else { Color::blend(overlays::chip(), overlays::chip_hover(), lit) }; Rectangle::new().width(width).height(height).radius(10.0).fill(fill).align_child(Center, Center).cursor(Pointer).scale(1.0 + 0.04 * lit).child(content) } @@ -1430,7 +1415,7 @@ fn label(text: &str, size: f32, chosen: bool) -> Text { } fn label_width(text: &str, size: f32, extra: f32) -> f32 { - text.chars().count() as f32 * size * LETTER + extra + text.chars().count() as f32 * size * font::letter_width() + extra } // performance mode and battery @@ -1448,7 +1433,7 @@ fn performance(scene: &Scene, height: f32) -> Rectangle { if !power.profiles.is_empty() { let count = power.profiles.len() as f32; - let width = (MODULE_W - 8.0 * (count - 1.0)) / count; + let width = (module_w() - 8.0 * (count - 1.0)) / count; let mut choices: Vec> = Vec::new(); @@ -1474,7 +1459,7 @@ fn performance(scene: &Scene, height: f32) -> Rectangle { let picked = profile.clone(); choices.push(Box::new( - segment(content, chosen, lit, width, PROFILE_H).on_hover(hovering(Spot::Profile(index))).on_click(move |_| { + segment(content, chosen, lit, width, c::modules::profile_height()).on_hover(hovering(Spot::Profile(index))).on_click(move |_| { let picked = picked.clone(); thread::spawn(move || system::set_profile(&picked)); @@ -1485,7 +1470,7 @@ fn performance(scene: &Scene, height: f32) -> Rectangle { rows.push(Box::new(Row::new(choices).gap(8.0))); } - module(height, Column::new(rows).gap(LINE_GAP)) + module(height, Column::new(rows).gap(c::modules::line_gap())) } fn battery_words(charge: &Charge) -> String { @@ -1516,28 +1501,28 @@ fn gpu(scene: &Scene, height: f32) -> Rectangle { facts.push(format!("{watts:.0} W")); } - let title = Row::new(children![icon(Glyph::Chip, 15.0, theme.accent), Text::new(gpu.name.as_str()).size(12.0).weight(Weight::Bold).color(theme.text).elide()]).gap(8.0).align(Center); + let title = Row::new(children![icon(Glyph::Chip, 15.0, theme.accent), Text::new(gpu.name.as_str()).size(text::body()).weight(Weight::Bold).color(theme.text).elide()]).gap(8.0).align(Center); - let top = Row::new(children![title, note(facts.join(" · "))]).justify(SpaceBetween).align(Center).width(MODULE_W).height(TITLE_H); + let top = Row::new(children![title, note(facts.join(" · "))]).justify(SpaceBetween).align(Center).width(module_w()).height(c::modules::title_height()); let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 }; let memory_label = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024)); let memory_w = label_width(&memory_label, 10.5, 4.0); - let bar_w = MODULE_W - memory_w - 10.0; + let bar_w = module_w() - memory_w - 10.0; let memory = Row::new(children![ - Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(TRACK).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)), - Text::new(memory_label).size(10.5).color(theme.dim), + Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)), + Text::new(memory_label).size(text::caption()).color(theme.dim), ]) .gap(10.0) .align(Center) - .width(MODULE_W) - .height(VRAM_H); + .width(module_w()) + .height(c::modules::vram_height()); let mut rows: Vec> = Vec::new(); - let shown = if gpu.programs.len() > MAX_ROWS { MAX_ROWS - 1 } else { MAX_ROWS }; + let shown = if gpu.programs.len() > c::modules::max_rows() { c::modules::max_rows() - 1 } else { c::modules::max_rows() }; for program in gpu.programs.iter().take(shown) { let mut amount = size(program.vram_kib); @@ -1548,25 +1533,25 @@ fn gpu(scene: &Scene, height: f32) -> Rectangle { rows.push(Box::new( Row::new(children![ - Text::new(program.name.as_str()).size(12.0).color(theme.text).elide(), + Text::new(program.name.as_str()).size(text::body()).color(theme.text).elide(), note(amount), ]) .justify(SpaceBetween) .align(Center) - .width(MODULE_W) - .height(ROW_H), + .width(module_w()) + .height(c::modules::row_height()), )); } - if gpu.programs.len() > MAX_ROWS { - rows.push(Box::new(Rectangle::new().width(MODULE_W).height(ROW_H).align_child(Start, Center).child(note(format!("and {} more", gpu.programs.len() - shown))))); + if gpu.programs.len() > c::modules::max_rows() { + rows.push(Box::new(Rectangle::new().width(module_w()).height(c::modules::row_height()).align_child(Start, Center).child(note(format!("and {} more", gpu.programs.len() - shown))))); } if gpu.programs.is_empty() { - rows.push(Box::new(Rectangle::new().width(MODULE_W).height(ROW_H).align_child(Start, Center).child(note("No programs on it")))); + rows.push(Box::new(Rectangle::new().width(module_w()).height(c::modules::row_height()).align_child(Start, Center).child(note("No programs on it")))); } - module(height, Column::new(children![top, memory, Column::new(rows)]).gap(LINE_GAP)) + module(height, Column::new(children![top, memory, Column::new(rows)]).gap(c::modules::line_gap())) } // video memory in KiB, the way a person reads it @@ -1623,7 +1608,7 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow { let menu_w = content.width; let menu_h = menu_height(&content); - let window = window(RIGHT + menu_w + SHADOW, TOP_GAP + menu_h + SHADOW); + let window = window(c::pill::right() + menu_w + island_pill::shadow(), island_pill::top_gap() + menu_h + island_pill::shadow()); /* * one shape the whole way, the panel's: it fades its own content and takes the menu's @@ -1648,8 +1633,8 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow { let page = Rectangle::new() .width(menu_w) .height(menu_h) - .padding(MENU_PAD) - .child(Column::new(children![menu_header(&control, content.title, content.right, menu_w - 2.0 * MENU_PAD), content.body]).gap(GAP)); + .padding(c::menus::pad()) + .child(Column::new(children![menu_header(&control, content.title, content.right, menu_w - 2.0 * c::menus::pad()), content.body]).gap(c::panel::gap())); // the veil is the panel's own backdrop, so the fade shows nothing but the content let shape = Rectangle::new() @@ -1662,15 +1647,15 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow { collapse_soon(); } }) - .child(veiled(page, menu_w, menu_h, PANEL_RADIUS, reveal, &colors)); + .child(veiled(page, menu_w, menu_h, c::panel::radius(), reveal, &colors)); - let region = InputArea { x: 0, y: TOP_GAP as i32, width: menu_w.ceil() as i32, height: menu_h.ceil() as i32 }; + let region = InputArea { x: 0, y: island_pill::top_gap() as i32, width: menu_w.ceil() as i32, height: menu_h.ceil() as i32 }; window.input_region(vec![region]).child( Rectangle::new() .width(Parent) .height(Parent) - .padding(Padding { top: TOP_GAP, right: RIGHT, bottom: 0.0, left: 0.0 }) + .padding(Padding { top: island_pill::top_gap(), right: c::pill::right(), bottom: 0.0, left: 0.0 }) .align_child(End, Start) .child(shape), ) @@ -1699,7 +1684,7 @@ fn menu_content(menu: Menu, map_w: f32, control: &Control, links: &Links, blueto // as tall as its content, at most as tall as the windows fn menu_height(content: &MenuContent) -> f32 { - (2.0 * MENU_PAD + MENU_HEADER_H + GAP + content.height).min(Layout::LARGEST.height()) + (2.0 * c::menus::pad() + c::menus::header_height() + c::panel::gap() + content.height).min(Layout::largest().height()) } // what a menu shows under its header, and how wide the menu is (the panel's width, or a map's) @@ -1733,7 +1718,7 @@ impl Body { } fn note(&mut self, text: &str) { - self.add(Rectangle::new().width(MENU_W).height(NOTE_H).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 4.0 }).align_child(Start, Center).child(note(text)), NOTE_H); + self.add(Rectangle::new().width(menu_w()).height(c::menus::note_height()).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 4.0 }).align_child(Start, Center).child(note(text)), c::menus::note_height()); } fn into_content(self, title: &'static str, right: Option>, width: f32) -> MenuContent { @@ -1749,10 +1734,10 @@ fn menu_header(control: &Control, title: &str, right: Option>, w let lit = control.lit(Spot::Back); let back = Rectangle::new() - .width(MENU_HEADER_H) - .height(MENU_HEADER_H) + .width(c::menus::header_height()) + .height(c::menus::header_height()) .radius(Full) - .fill(Color::blend(CHIP, CHIP_HOVER, lit)) + .fill(Color::blend(overlays::chip(), overlays::chip_hover(), lit)) .align_child(Center, Center) .cursor(Pointer) .scale(1.0 + 0.06 * lit) @@ -1760,13 +1745,13 @@ fn menu_header(control: &Control, title: &str, right: Option>, w .on_click(|_| menu_back()) .child(icon(Glyph::Back, 13.0, theme.text)); - let mut parts: Vec> = children![Row::new(children![back, Text::new(title).size(14.0).weight(Weight::Bold).color(theme.text)]).gap(10.0).align(Center)]; + let mut parts: Vec> = children![Row::new(children![back, Text::new(title).size(text::heading()).weight(Weight::Bold).color(theme.text)]).gap(10.0).align(Center)]; if let Some(right) = right { parts.push(right); } - Row::new(parts).justify(SpaceBetween).align(Center).width(width).height(MENU_HEADER_H) + Row::new(parts).justify(SpaceBetween).align(Center).width(width).height(c::menus::header_height()) } // an on/off switch; grey: the service behind it isn't running, and switching on starts it @@ -1778,9 +1763,9 @@ fn switch(control: &Control, spot: Spot, on: bool, grey: bool) -> Rectangle { let track = if on { Color::blend(theme.accent, theme.flame, 0.3 * lit) } else if grey { - Color::blend(CHIP, CHIP_HOVER, lit) + Color::blend(overlays::chip(), overlays::chip_hover(), lit) } else { - Color::blend(CHIP_OPEN, CHIP_HOVER, lit) + Color::blend(overlays::chip_open(), overlays::chip_hover(), lit) }; let knob = if grey { faded(Color::WHITE, 140) } else { Color::WHITE }; @@ -1806,18 +1791,18 @@ fn menu_row(control: &Control, index: usize, glyph: Glyph, active: bool, title: .width(30.0) .height(30.0) .radius(Full) - .fill(if active { theme.accent } else { CHIP_OPEN }) + .fill(if active { theme.accent } else { overlays::chip_open() }) .align_child(Center, Center) .child(icon(glyph, 15.0, if active { theme.on_accent } else { theme.text })); let right_w = if right.is_some() { 50.0 } else { 0.0 }; let words = Column::new(children![ - Text::new(title).size(12.0).weight(if active { Weight::Bold } else { Weight::Regular }).color(theme.text).elide(), - Text::new(subtitle).size(10.5).color(theme.dim).elide(), + Text::new(title).size(text::body()).weight(if active { Weight::Bold } else { Weight::Regular }).color(theme.text).elide(), + Text::new(subtitle).size(text::caption()).color(theme.dim).elide(), ]) .gap(3.0) - .width(MENU_W - 20.0 - 30.0 - 10.0 - right_w); + .width(menu_w() - 20.0 - 30.0 - 10.0 - right_w); let mut parts: Vec> = children![Row::new(children![round, words]).gap(10.0).align(Center)]; @@ -1826,15 +1811,15 @@ fn menu_row(control: &Control, index: usize, glyph: Glyph, active: bool, title: } Rectangle::new() - .width(MENU_W) - .height(MENU_ROW_H) + .width(menu_w()) + .height(c::menus::row_height()) .radius(12.0) - .fill(Color::blend(CLEAR, CHIP_HOVER, lit)) + .fill(Color::blend(CLEAR, overlays::chip_hover(), lit)) .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }) .align_child(Start, Center) .translate(3.0 * lit, 0.0) .on_hover(hovering(Spot::Row(index))) - .child(Row::new(parts).justify(SpaceBetween).align(Center).width(MENU_W - 20.0).height(MENU_ROW_H)) + .child(Row::new(parts).justify(SpaceBetween).align(Center).width(menu_w() - 20.0).height(c::menus::row_height())) } // a quiet text button at the bottom of a menu @@ -1842,15 +1827,15 @@ fn footer(control: &Control, text: &str) -> Rectangle { let lit = control.lit(Spot::Footer); Rectangle::new() - .width(MENU_W) + .width(menu_w()) .height(30.0) .radius(10.0) - .fill(Color::blend(CLEAR, CHIP_HOVER, lit)) + .fill(Color::blend(CLEAR, overlays::chip_hover(), lit)) .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 10.0 }) .align_child(Start, Center) .cursor(Pointer) .on_hover(hovering(Spot::Footer)) - .child(Text::new(text).size(11.5).color(Color::blend(theme::now().dim, theme::now().text, lit))) + .child(Text::new(text).size(text::label()).color(Color::blend(theme::now().dim, theme::now().text, lit))) } fn wifi_menu(control: &Control, links: &Links, map_w: f32) -> MenuContent { @@ -1881,7 +1866,7 @@ fn wifi_menu(control: &Control, links: &Links, map_w: f32) -> MenuContent { }; // by name, so each keeps its place around the bubble while signals change - let mut networks: Vec<_> = if note.is_none() { wifi.networks.iter().take(NODES).collect() } else { Vec::new() }; + let mut networks: Vec<_> = if note.is_none() { wifi.networks.iter().take(c::maps::nodes()).collect() } else { Vec::new() }; networks.sort_by(|one, other| one.name.cmp(&other.name)); @@ -1949,7 +1934,7 @@ fn wifi_menu(control: &Control, links: &Links, map_w: f32) -> MenuContent { }) .collect(); - let mut body = Body::new(GAP); + let mut body = Body::new(c::panel::gap()); let centre = Centre { host: &links.host, @@ -1960,7 +1945,7 @@ fn wifi_menu(control: &Control, links: &Links, map_w: f32) -> MenuContent { on_click: Box::new(|| search_again(Menu::Wifi)), }; - body.add(map::map(map_w - 2.0 * MENU_PAD, AREA_H, centre, nodes), AREA_H); + body.add(map::map(map_w - 2.0 * c::menus::pad(), c::maps::height(), centre, nodes), c::maps::height()); if wifi.service { body.add(footer(control, "Stop the Wi-Fi service").on_click(|_| later(system::stop_wifi_service, None)), 30.0); @@ -1992,7 +1977,7 @@ fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links, map_w }; // by address, so each keeps its place around the bubble - let mut devices: Vec<_> = if note.is_none() { bluetooth.devices().iter().take(NODES).collect() } else { Vec::new() }; + let mut devices: Vec<_> = if note.is_none() { bluetooth.devices().iter().take(c::maps::nodes()).collect() } else { Vec::new() }; devices.sort_by(|one, other| one.address().cmp(other.address())); @@ -2058,7 +2043,7 @@ fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links, map_w }) .collect(); - let mut body = Body::new(GAP); + let mut body = Body::new(c::panel::gap()); let centre = Centre { host: &links.host, @@ -2069,7 +2054,7 @@ fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links, map_w on_click: Box::new(|| search_again(Menu::Bluetooth)), }; - body.add(map::map(map_w - 2.0 * MENU_PAD, AREA_H, centre, nodes), AREA_H); + body.add(map::map(map_w - 2.0 * c::menus::pad(), c::maps::height(), centre, nodes), c::maps::height()); body.into_content("Bluetooth", right, map_w) } @@ -2083,7 +2068,7 @@ fn vpn_menu(control: &Control, links: &Links) -> MenuContent { body.note("No openvpn service: /etc/init.d/openvpn.."); } - for (index, vpn) in links.vpns.iter().enumerate().take(MENU_ROWS) { + for (index, vpn) in links.vpns.iter().enumerate().take(c::menus::rows()) { let (on, subtitle) = match vpn.state { VpnState::Connected => (true, "Connected"), VpnState::Connecting => (true, "Connecting…"), @@ -2099,10 +2084,10 @@ fn vpn_menu(control: &Control, links: &Links) -> MenuContent { .cursor(Pointer) .on_click(move |_| act(Tile::Vpn, before.clone(), Action::Vpn(service.clone()))); - body.add(row, MENU_ROW_H); + body.add(row, c::menus::row_height()); } - body.into_content("VPN", None, PANEL_W) + body.into_content("VPN", None, c::panel::width()) } /* @@ -2114,8 +2099,8 @@ fn ethernet_menu(control: &Control) -> MenuContent { let theme = theme::now(); let (card, details) = &control.ethernet; - let row_w = ETHERNET_W - 2.0 * MENU_PAD; - let value_w = row_w - DETAIL_LABEL_W - COPY_W - 22.0; + let row_w = c::ethernet::width() - 2.0 * c::menus::pad(); + let value_w = row_w - c::ethernet::label_width() - c::ethernet::copy_width() - 22.0; let mut body = Body::new(0.0); @@ -2126,22 +2111,22 @@ fn ethernet_menu(control: &Control) -> MenuContent { let chip: Box = if copy_text.is_some() { let ink = if copied { theme.accent } else { Color::blend(theme.dim, theme.text, lit) }; - Box::new(Rectangle::new().width(COPY_W).height(20.0).radius(6.0).fill(Color::blend(CHIP, CHIP_HOVER, lit)).align_child(Center, Center).child(Text::new(if copied { "Copied" } else { "Copy" }).size(10.0).color(ink))) + Box::new(Rectangle::new().width(c::ethernet::copy_width()).height(20.0).radius(6.0).fill(Color::blend(overlays::chip(), overlays::chip_hover(), lit)).align_child(Center, Center).child(Text::new(if copied { "Copied" } else { "Copy" }).size(text::tiny()).color(ink))) } else { - Box::new(Rectangle::new().width(COPY_W).height(20.0)) + Box::new(Rectangle::new().width(c::ethernet::copy_width()).height(20.0)) }; let line = Rectangle::new() .width(row_w) - .height(DETAIL_H) + .height(c::ethernet::detail_height()) .radius(8.0) - .fill(Color::blend(CLEAR, CHIP_HOVER, 0.6 * lit)) + .fill(Color::blend(CLEAR, overlays::chip_hover(), 0.6 * lit)) .padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 8.0 }) .align_child(Start, Center) .child( Row::new(vec![ - Box::new(Rectangle::new().width(DETAIL_LABEL_W).height(DETAIL_H).align_child(Start, Center).child(Text::new(label.as_str()).size(10.5).color(theme.dim))) as Box, - Box::new(Rectangle::new().width(value_w).height(DETAIL_H).align_child(Start, Center).child(Text::new(value.as_str()).size(11.5).color(theme.text).elide())), + Box::new(Rectangle::new().width(c::ethernet::label_width()).height(c::ethernet::detail_height()).align_child(Start, Center).child(Text::new(label.as_str()).size(text::caption()).color(theme.dim))) as Box, + Box::new(Rectangle::new().width(value_w).height(c::ethernet::detail_height()).align_child(Start, Center).child(Text::new(value.as_str()).size(text::label()).color(theme.text).elide())), chip, ]) .gap(6.0) @@ -2149,14 +2134,14 @@ fn ethernet_menu(control: &Control) -> MenuContent { ); match copy_text.clone() { - Some(text) => body.add(line.cursor(Pointer).on_hover(hovering(Spot::Row(index))).on_click(move |_| prompt::copy(&text, move || Control::write().copied = Some(index))), DETAIL_H), - None => body.add(line, DETAIL_H), + Some(text) => body.add(line.cursor(Pointer).on_hover(hovering(Spot::Row(index))).on_click(move |_| prompt::copy(&text, move || Control::write().copied = Some(index))), c::ethernet::detail_height()), + None => body.add(line, c::ethernet::detail_height()), } } - let right: Box = Box::new(Text::new(card.as_str()).size(11.0).color(theme.dim)); + let right: Box = Box::new(Text::new(card.as_str()).size(text::small()).color(theme.dim)); - body.into_content("Ethernet", Some(right), ETHERNET_W) + body.into_content("Ethernet", Some(right), c::ethernet::width()) } // every output with its own volume; a click on its name makes it the default @@ -2169,7 +2154,7 @@ fn sound_menu(control: &Control, sound: &Sound) -> MenuContent { body.note("No outputs."); } - for (index, sink) in sound.outputs().iter().take(OUTPUTS).enumerate() { + for (index, sink) in sound.outputs().iter().take(c::menus::outputs()).enumerate() { let default = sink.name == sound.default_output(); let lit = control.lit(Spot::Row(index)); @@ -2179,10 +2164,10 @@ fn sound_menu(control: &Control, sound: &Sound) -> MenuContent { let title = parse::middle_ellipsis(parse::short_sink_name(&sink.description), 38); let name = Rectangle::new() - .width(MENU_W - 70.0) - .height(OUTPUT_H) + .width(menu_w() - 70.0) + .height(c::menus::output_height()) .radius(8.0) - .fill(Color::blend(CLEAR, CHIP_HOVER, lit)) + .fill(Color::blend(CLEAR, overlays::chip_hover(), lit)) .padding(Padding { top: 0.0, right: 6.0, bottom: 0.0, left: 6.0 }) .align_child(Start, Center) .cursor(Pointer) @@ -2193,15 +2178,15 @@ fn sound_menu(control: &Control, sound: &Sound) -> MenuContent { move |_| Sound::set_default_output(&name) }) .child( - Row::new(children![radio, Text::new(title).size(11.5).weight(if default { Weight::Bold } else { Weight::Regular }).color(theme.text).elide()]) + Row::new(children![radio, Text::new(title).size(text::label()).weight(if default { Weight::Bold } else { Weight::Regular }).color(theme.text).elide()]) .gap(8.0) .align(Center), ); - let line = Row::new(children![name, output_mute(control, sink)]).justify(SpaceBetween).align(Center).width(MENU_W).height(OUTPUT_H); + let line = Row::new(children![name, output_mute(control, sink)]).justify(SpaceBetween).align(Center).width(menu_w()).height(c::menus::output_height()); - body.add(Column::new(children![line, output_slider(control, sink, MENU_W)]).gap(6.0), OUTPUT_H + 6.0 + SLIDER_H); + body.add(Column::new(children![line, output_slider(control, sink, menu_w())]).gap(6.0), c::menus::output_height() + 6.0 + c::modules::slider_height()); } - body.into_content("Sound", None, PANEL_W) + body.into_content("Sound", None, c::panel::width()) } diff --git a/src/edge.rs b/src/edge.rs index 6bea89a..8cb8192 100644 --- a/src/edge.rs +++ b/src/edge.rs @@ -1,7 +1,7 @@ /* * when the bar shows. on a monitor whose workspace shows a fullscreen window, the island, * the workspaces and the control centre's pill stay out of sight until the pointer rests - * on that monitor's top edge for DWELL_MS; everywhere else they're always there. some + * on that monitor's top edge for hiding::dwell_ms(); everywhere else they're always there. some * events bring one part in for a moment even then (a peek): the workspaces when the * workspace changes, the island when the song changes or a player appears; never the * control centre. a workspace change also slides the workspaces' highlight across, and a @@ -22,23 +22,14 @@ use std::{env, thread}; use amane::{Animation, Full, Layer, LayerWindow, Media, Monitor, Parent, Rectangle, Service, Vertical, Zone}; -use crate::island::{COMPACT_H, SHADOW, TOP_GAP}; +use crate::config::bar::{hiding, peeks}; +use crate::config::island::pill; use crate::parse; -const DWELL_MS: u64 = 300; -const HIDE_MS: u64 = 700; -const SLIDE_MS: u64 = 220; - -// how long a peek stays -const PEEK_WORKSPACES_MS: u64 = 1600; -const PEEK_ISLAND_MS: u64 = 3500; - -// the island's pop on a new song, and the workspaces' highlight sliding over, in seconds -const POP_S: f32 = 0.7; -const SWITCH_S: f32 = 0.35; - // how far a pill slides up to be out of sight, glow and all -pub const HIDE_H: f32 = TOP_GAP + COMPACT_H + SHADOW; +pub fn hide_h() -> f32 { + pill::top_gap() + pill::height() + pill::shadow() +} // the parts an event may bring in alone #[derive(Clone, Copy, PartialEq, Eq, Hash)] @@ -120,13 +111,13 @@ impl Edge { self.shown(monitor).max(peek) } - // 0 to 1 and back over POP_S after a new song; the view asks for frames while it's above 0 + // 0 to 1 and back over peeks::pop_s() after a new song; the view asks for frames while it's above 0 pub fn pop(&self, part: Part, monitor: &str) -> f32 { let Some(at) = self.pops.get(&(part, monitor.to_string())) else { return 0.0; }; - let t = at.elapsed().as_secs_f32() / POP_S; + let t = at.elapsed().as_secs_f32() / peeks::pop_s().max(0.01); if t < 1.0 { (t * PI).sin() * (1.0 - t * 0.5) } else { 0.0 } } @@ -134,7 +125,7 @@ impl Edge { // a workspace change on this monitor still sliding: the workspace it came from, and how far along, 0 to 1 pub fn switched(&self, monitor: &str) -> Option<(i64, f32)> { let (from, at) = self.switched.get(monitor)?; - let t = at.elapsed().as_secs_f32() / SWITCH_S; + let t = at.elapsed().as_secs_f32() / peeks::switch_s().max(0.01); (t < 1.0).then_some((*from, t)) } @@ -251,8 +242,8 @@ fn peek(edge: &mut Edge, part: Part, monitor: &str) { entry.1 = began; let hold = match part { - Part::Island => PEEK_ISLAND_MS, - Part::Workspaces => PEEK_WORKSPACES_MS, + Part::Island => peeks::island_ms(), + Part::Workspaces => peeks::workspaces_ms(), }; thread::spawn(move || { @@ -282,7 +273,7 @@ fn watch_media() { let mut last: Option<(Vec, String)> = None; loop { - thread::sleep(Duration::from_millis(300)); + thread::sleep(Duration::from_millis(peeks::media_check_ms())); let (players, song) = { let media = Media::read(); @@ -362,7 +353,7 @@ fn at_edge(monitor: String) -> impl Fn(bool) + 'static { // still resting there after the dwell: this monitor's bar comes back thread::spawn(move || { - thread::sleep(Duration::from_millis(DWELL_MS)); + thread::sleep(Duration::from_millis(hiding::dwell_ms())); let mut edge = Edge::write(); @@ -375,10 +366,10 @@ fn at_edge(monitor: String) -> impl Fn(bool) + 'static { } } -// gone again after HIDE_MS off the bar and the edge, unless the pointer came back meanwhile; peeks with it +// gone again after hiding::hide_ms() off the bar and the edge, unless the pointer came back meanwhile; peeks with it fn hide_soon(moves: u32) { thread::spawn(move || { - thread::sleep(Duration::from_millis(HIDE_MS)); + thread::sleep(Duration::from_millis(hiding::hide_ms())); let mut edge = Edge::write(); @@ -395,7 +386,7 @@ fn hide_soon(moves: u32) { } fn sliding(at: f32) -> Animation { - Animation::new(at).duration(Duration::from_millis(SLIDE_MS)) + Animation::new(at).duration(Duration::from_millis(hiding::slide_ms())) } /* @@ -428,7 +419,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { pub fn spacer(_: &Monitor) -> LayerWindow { LayerWindow::new() .width(Full) - .height(TOP_GAP + COMPACT_H) + .height(pill::top_gap() + pill::height()) .anchor_vertical(Vertical::Top) .layer(Layer::Background) .space(Zone::Reserve) diff --git a/src/glyph.rs b/src/glyph.rs index f848fda..4d8a916 100644 --- a/src/glyph.rs +++ b/src/glyph.rs @@ -30,6 +30,8 @@ pub enum Glyph { Laptop, // a network socket, for a wired link Ethernet, + // a cog, for the config window + Gear, } pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas { @@ -220,5 +222,17 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin: line(0.5, 0.46, 0.5, 0.62), line(0.64, 0.46, 0.64, 0.62), ], + // a ring with eight teeth around a hole + Glyph::Gear => { + let mut parts: Vec> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.27 * s).stroke(thin * 1.6, color)]; + + for tooth in 0..8 { + let (sin, cos) = (tooth as f32 * std::f32::consts::FRAC_PI_4).sin_cos(); + + parts.push(Box::new(Line::new().from(x(0.5 + 0.3 * sin), y(0.5 - 0.3 * cos)).to(x(0.5 + 0.44 * sin), y(0.5 - 0.44 * cos)).stroke(thin * 1.6, color).cap(Cap::Round))); + } + + parts + } } } diff --git a/src/hypr.rs b/src/hypr.rs new file mode 100644 index 0000000..443ec76 --- /dev/null +++ b/src/hypr.rs @@ -0,0 +1,410 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::Duration; +use std::{fs, thread}; + +use amane::Service; + +use crate::config; +use crate::parse::{self, HyprKind, Value}; +use crate::theme; + +/* + * Hyprland's own options in the config window. the list and its help come from Hyprland + * (`hyprctl descriptions`, `getoption`), so nothing here names an option. a change is + * kept in config.toml under [hyprland.*], applied at once with `hl.config` and written + * to ~/.config/hypr/amane.lua, which hyprland.lua loads last with require("amane"), so + * Hyprland's config keeps owning them + */ + +pub struct HyprOption { + // "decoration:blur:size"; &'static because text inputs are named by it + pub name: &'static str, + pub help: String, + pub kind: HyprKind, + pub default: String, + pub range: Option<(f64, f64)>, + // named values for a number, like resize_corner's top_left = 1 + pub choices: Vec<(String, i64)>, + pub current: String, + // Hyprland's own config sets it + pub in_config: bool, +} + +impl HyprOption { + // "decoration.blur.size", as hl.config names it + pub fn key(&self) -> String { + self.name.replace(':', ".") + } + + pub fn path(&self) -> String { + format!("hyprland.{}", self.key()) + } + + pub fn category(&self) -> &'static str { + self.name.split(':').next().unwrap_or("") + } + + // "blur" for decoration:blur:size; options right under a category have none + pub fn group(&self) -> &'static str { + let parts: Vec<&'static str> = self.name.split(':').collect(); + + if parts.len() > 2 { parts[1] } else { "" } + } + + pub fn label(&self) -> &'static str { + self.name.rsplit(':').next().unwrap_or(self.name) + } +} + +#[derive(Default)] +pub struct Hypr { + pub options: Vec, + pub loaded: bool, + // the last change's result: (what happened, whether it went wrong) + pub message: Option<(String, bool)>, + // hyprland.lua loads amane.lua, so changes outlive a reload + pub required: bool, +} + +impl Service for Hypr { + fn new() -> Self { + Self::default() + } + + fn listen() {} +} + +enum Job { + Load, + // an option as Hyprland names it (decoration:blur:size) or as config.toml does (decoration.blur.size) + Set(String, String), + Reset(String), + Sync, +} + +// one worker does every hyprctl call, in order; it starts with the first job +fn send(job: Job) { + static JOBS: OnceLock> = OnceLock::new(); + + let jobs = JOBS.get_or_init(|| { + let (sender, receiver) = mpsc::channel(); + + thread::spawn(move || work(&receiver)); + + sender + }); + + let _ = jobs.send(job); +} + +// reads every option and its value, for the window +pub fn load() { + send(Job::Load); +} + +pub fn set(name: &str, text: &str) { + send(Job::Set(name.to_string(), text.to_string())); +} + +pub fn reset(name: &str) { + send(Job::Reset(name.to_string())); +} + +// config.toml was read: write amane.lua to match, once things settle +pub fn sync_soon() { + send(Job::Sync); +} + +/* + * a slider sends many values a second: only the newest per option is applied, and + * amane.lua is written once nothing has changed for half a second, since Hyprland may + * reload its whole config whenever a file it loaded changes + */ +fn work(jobs: &Receiver) { + let mut unwritten = false; + + loop { + let first = match jobs.recv_timeout(Duration::from_millis(500)) { + Ok(job) => job, + Err(RecvTimeoutError::Timeout) => { + if unwritten { + write_lua(); + + unwritten = false; + } + + continue; + } + Err(RecvTimeoutError::Disconnected) => return, + }; + + let mut batch: Vec = vec![first]; + + batch.extend(jobs.try_iter()); + + for (index, job) in batch.iter().enumerate() { + let newer = |name: &str| batch[index + 1..].iter().any(|later| matches!(later, Job::Set(other, _) | Job::Reset(other) if other == name)); + + match job { + Job::Load => read_all(), + Job::Set(name, text) if !newer(name) => { + if let Some(name) = resolve(name) { + apply(name, text); + } + + unwritten = true; + } + Job::Reset(name) if !newer(name) => { + if let Some(name) = resolve(name) { + give_back(name); + } + + unwritten = false; + } + Job::Sync => unwritten = true, + _ => {} + } + } + } +} + +// Hyprland's name for an option given either way; reads the list first if it hasn't been +fn resolve(given: &str) -> Option<&'static str> { + if !Hypr::read().loaded { + read_all(); + } + + let key = given.replace(':', "."); + let found = Hypr::read().options.iter().find(|option| option.key() == key).map(|option| option.name); + + if found.is_none() { + Hypr::write().message = Some((format!("Hyprland has no option {given}"), true)); + } + + found +} + +fn hyprctl(arguments: &[&str]) -> String { + Command::new("hyprctl").args(arguments).output().map(|done| String::from_utf8_lossy(&done.stdout).into_owned()).unwrap_or_default() +} + +// option names live as long as the shell: each is leaked once, however often the list is read +fn interned(name: &str) -> &'static str { + static NAMES: Mutex> = Mutex::new(Vec::new()); + + let mut names = NAMES.lock().unwrap_or_else(PoisonError::into_inner); + + if let Some(known) = names.iter().find(|known| **known == name) { + return known; + } + + let leaked: &'static str = Box::leak(name.to_string().into_boxed_str()); + + names.push(leaked); + + leaked +} + +// (kind, value as text, set by Hyprland's own config) of these options, in one hyprctl call +fn values_of(names: &[&str]) -> HashMap { + if names.is_empty() { + return HashMap::new(); + } + + let batch = names.iter().map(|name| format!("j/getoption {name}")).collect::>().join("; "); + + parse::json(&hyprctl(&["--batch", &batch])).iter().filter_map(parse::hypr_option).map(|(name, kind, text, set)| (name, (kind, text, set))).collect() +} + +fn read_all() { + let described = parse::json(&hyprctl(&["descriptions", "-j"])); + + let Some(Value::List(list)) = described.first() else { + Hypr::write().message = Some((String::from("Hyprland didn't answer `hyprctl descriptions`"), true)); + + return; + }; + + let names: Vec<&str> = list.iter().filter_map(|option| option.get("name")?.text()).collect(); + let values = values_of(&names); + + let options: Vec = list + .iter() + .filter_map(|option| { + let name = option.get("name")?.text()?; + let (kind, current, in_config) = values.get(name)?.clone(); + + let number = |key: &str| option.get(key).and_then(Value::number); + + let choices = match option.get("map") { + Some(Value::List(pairs)) => pairs + .iter() + .filter_map(|pair| match pair { + Value::Table(entries) => entries.first().and_then(|(label, value)| Some((label.clone(), value.number()? as i64))), + _ => None, + }) + .collect(), + _ => Vec::new(), + }; + + Some(HyprOption { + name: interned(name), + help: option.get("description").and_then(Value::text).unwrap_or("").to_string(), + kind, + default: option.get("default").and_then(|value| parse::hypr_text(kind, value)).unwrap_or_default(), + range: number("min").zip(number("max")), + choices, + current, + in_config, + }) + }) + .collect(); + + let required = fs::read_to_string(hypr_dir().join("hyprland.lua")).is_ok_and(|lua| ["require(\"amane\")", "require('amane')", "require \"amane\""].iter().any(|line| lua.contains(line))); + + let mut hypr = Hypr::write(); + + hypr.options = options; + hypr.loaded = true; + hypr.required = required; +} + +fn kind_of(name: &str) -> Option { + if let Some(option) = Hypr::read().options.iter().find(|option| option.name == name) { + return Some(option.kind); + } + + values_of(&[name]).get(name).map(|(kind, _, _)| *kind) +} + +// a change from the window: checked, kept in config.toml, applied at once +fn apply(name: &'static str, text: &str) { + let Some(kind) = kind_of(name) else { + Hypr::write().message = Some((format!("Hyprland has no option {name}"), true)); + + return; + }; + + let lua = match parse::hypr_lua(kind, text) { + Ok(lua) => lua, + Err(why) => { + Hypr::write().message = Some((format!("{name} {why}"), true)); + + return; + } + }; + + let key = name.replace(':', "."); + + let value = match kind { + HyprKind::Bool => Value::Bool(lua == "true"), + HyprKind::Int => lua.parse().map(Value::Int).unwrap_or(Value::Text(lua.clone())), + HyprKind::Float => lua.parse().map(Value::Float).unwrap_or(Value::Text(lua.clone())), + _ => Value::Text(text.trim().to_string()), + }; + + let answer = hyprctl(&["eval", &format!("hl.config({{ [{}] = {lua} }})", parse::quoted(&key))]); + + let message = if answer.trim() == "ok" { + match config::set(&format!("hyprland.{key}"), value) { + Ok(()) => (format!("{key} = {}", text.trim()), false), + Err(why) => (why, true), + } + } else { + // "error: return hl.config(...):1: error setting 'x': why" says only the last part + (answer.rsplit(": ").next().unwrap_or(&answer).trim().to_string(), true) + }; + + refresh(name); + + Hypr::write().message = Some(message); +} + +// back to Hyprland's own value: out of config.toml and amane.lua, then Hyprland reloads its config +fn give_back(name: &'static str) { + let key = name.replace(':', "."); + + let message = match config::reset(&format!("hyprland.{key}")) { + Ok(()) => { + write_lua(); + + hyprctl(&["reload", "config-only"]); + + (format!("{key} is back to your Hyprland config's value"), false) + } + Err(why) => (why, true), + }; + + read_all(); + + Hypr::write().message = Some(message); +} + +fn refresh(name: &str) { + if let Some((_, text, set)) = values_of(&[name]).remove(name) { + let mut hypr = Hypr::write(); + + if let Some(option) = hypr.options.iter_mut().find(|option| option.name == name) { + option.current = text; + option.in_config = set; + } + } +} + +fn hypr_dir() -> PathBuf { + theme::config_home().join("hypr") +} + +// amane.lua from the [hyprland.*] tables; written only when it would change +fn write_lua() { + let overrides = config::overrides(); + let names: Vec = overrides.iter().map(|(path, _)| path.trim_start_matches("hyprland.").to_string()).collect(); + + // config.toml names options with dots only; Hyprland's names use colons between sections + let known: HashMap = { + let hypr = Hypr::read(); + + if hypr.loaded { hypr.options.iter().map(|option| (option.key(), option.kind)).collect() } else { HashMap::new() } + }; + + let kinds: HashMap = if names.iter().all(|key| known.contains_key(key)) { + known + } else { + let all = parse::json(&hyprctl(&["descriptions", "-j"])); + let every: Vec<&str> = match all.first() { + Some(Value::List(list)) => list.iter().filter_map(|option| option.get("name")?.text()).collect(), + _ => Vec::new(), + }; + let wanted: Vec<&str> = every.into_iter().filter(|name| names.contains(&name.replace(':', "."))).collect(); + + values_of(&wanted).into_iter().map(|(name, (kind, _, _))| (name.replace(':', "."), kind)).collect() + }; + + let mut lines = Vec::new(); + + for ((_, value), key) in overrides.iter().zip(&names) { + let Some(kind) = kinds.get(key) else { continue }; + + if let Ok(lua) = parse::hypr_lua(*kind, &config::shown(value)) { + lines.push(format!(" [{}] = {lua},", parse::quoted(key))); + } + } + + let lua = format!( + "-- Written by the amane shell from the [hyprland] tables in ~/.config/amane/config.toml (its\n\ + -- config window, or hand edits); changes made here are overwritten. Load it last in\n\ + -- hyprland.lua with require(\"amane\") so these win over the rest of the config.\n\ + {}", + if lines.is_empty() { String::new() } else { format!("hl.config({{\n{}\n}})\n", lines.join("\n")) } + ); + + let path = hypr_dir().join("amane.lua"); + + if fs::read_to_string(&path).ok().as_deref() != Some(lua.as_str()) { + let _ = fs::write(path, lua); + } +} diff --git a/src/island.rs b/src/island.rs index 468171a..7b7dcb7 100644 --- a/src/island.rs +++ b/src/island.rs @@ -11,95 +11,59 @@ use amane::{ }; use crate::art::Art; -use crate::edge::{self, Edge, HIDE_H, Part}; +use crate::edge::{self, Edge, Part}; use crate::glyph::{Glyph, bold_icon, icon}; -use crate::parse::{self, BANDS, Sink}; -use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound}; +use crate::parse::{self, Sink}; +use crate::sound::{self, Sound}; use crate::spectrum::Spectrum; use crate::theme; /* * opening is one linear timeline in two phases, so the shape never resizes with the * whole card in it: the pill fades out, the empty shape grows, then the card fades in - * on the settled shape. closing runs the same timeline backwards + * on the settled shape. closing runs the same timeline backwards. every size and timing + * is a setting (config.rs: island.*), read as it's drawn */ -const OPEN_MS: u64 = 560; -pub const PILL_GONE: f32 = 0.18; -pub const SHAPE_DONE: f32 = 0.6; +use crate::config::appearance::{font, motion, overlays, text}; +use crate::config::island as c; -// hover highlights ease in and out over this long; the pill widens by PILL_HOVER_GROW -pub const HOVER_MS: u64 = 220; -const PILL_HOVER_GROW: f32 = 10.0; +// the card's inner width, and what's derived from its settings +fn inner_w() -> f32 { + c::card::width() - 2.0 * c::card::pad() +} -// how much thicker, in px, the transport icons get when hovered -const BOLD: f32 = 2.0; +fn side_w() -> f32 { + inner_w() - c::disc::size() - c::card::side_gap() +} -// sizes, in pixels -pub const COMPACT_W: f32 = 220.0; -pub const COMPACT_H: f32 = 38.0; -const WIDE_W: f32 = 560.0; -const WIDE_RADIUS: f32 = 28.0; -const PAD: f32 = 22.0; -const INNER_W: f32 = WIDE_W - 2.0 * PAD; -const GAP: f32 = 16.0; -pub const TOP_GAP: f32 = 6.0; -pub const SHADOW: f32 = 34.0; +fn eq_h() -> f32 { + c::equalizer::slider_height() + 8.0 + c::equalizer::label_height() +} -// the cd: its box, the cover, the hole, and how far the candles reach past it -const DISC_BOX: f32 = 204.0; -const DISC: f32 = 136.0; -const HOLE: f32 = 26.0; -const CANDLES: usize = 40; -const CANDLE_REACH: f32 = 20.0; +fn preset_w() -> f32 { + (inner_w() - 3.0 * c::equalizer::preset_gap()) / 4.0 +} -const SIDE_GAP: f32 = 20.0; -const SIDE_W: f32 = INNER_W - DISC_BOX - SIDE_GAP; -const HEADER_H: f32 = DISC_BOX; -const CHIP_H: f32 = 24.0; -const OUTPUT_CHIP_MAX: f32 = 150.0; -const VOLUME_CHIP_W: f32 = 60.0; -const BAR_H: f32 = 8.0; -const KNOB: f32 = 16.0; -const CONTROLS_H: f32 = 44.0; - -const EQ_HEAD_H: f32 = 26.0; -const SLIDER_H: f32 = 140.0; -const TRACK_W: f32 = 10.0; -const LABEL_H: f32 = 14.0; -const EQ_H: f32 = SLIDER_H + 8.0 + LABEL_H; -const CURVE_STEPS: usize = 72; -const PRESET_H: f32 = 30.0; -const PRESET_GAP: f32 = 8.0; -const PRESET_W: f32 = (INNER_W - 3.0 * PRESET_GAP) / 4.0; -const PRESETS_H: f32 = 2.0 * PRESET_H + PRESET_GAP; +fn presets_h() -> f32 { + 2.0 * c::equalizer::preset_height() + c::equalizer::preset_gap() +} // every section has a fixed height, so the window is sized up front -const WIDE_H: f32 = 2.0 * PAD + HEADER_H + 1.0 + EQ_HEAD_H + EQ_H + PRESETS_H + 4.0 * GAP; +fn wide_h() -> f32 { + 2.0 * c::card::pad() + c::disc::size() + 1.0 + c::equalizer::header_height() + eq_h() + presets_h() + 4.0 * c::card::gap() +} // the dropdowns open under the chips -const MENU_X: f32 = PAD + DISC_BOX + SIDE_GAP; -const MENU_Y: f32 = PAD + 92.0; -const MENU_W: f32 = SIDE_W; -const MENU_ROW_H: f32 = 30.0; -const MENU_ROWS: usize = 10; +fn menu_x() -> f32 { + c::card::pad() + c::disc::size() + c::card::side_gap() +} -// letters that fit a dropdown row and the output chip, for cutting long device names in the middle -const MENU_LETTERS: usize = 30; -const CHIP_LETTERS: usize = 13; +fn menu_y() -> f32 { + c::card::pad() + c::menus::top() +} -// covers are decoded once at this size, for both the pill and the cd -const THUMB: u32 = 256; - -// Fira Code renders about 0.64 em per letter here, so a chip can be sized to its label (plus slack) -const LETTER: f32 = 0.66; - -// the white overlays below stay: they read on any dark surface. the theme colours are in theme.rs -pub const CHIP: Color = Color::rgba(255, 255, 255, 18); -pub const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34); -// a hovered chip or button; CLEAR is its starting point where there is no fill, white so the fade stays grey-free -pub const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46); +// where there's no fill, a fade starts from this: white, so it stays grey-free pub const CLEAR: Color = Color::rgba(255, 255, 255, 0); -pub const TRACK: Color = Color::rgba(255, 255, 255, 24); #[derive(Clone, Copy, PartialEq)] pub enum Menu { @@ -206,7 +170,7 @@ fn hovering(spot: Spot) -> impl Fn(bool) + 'static { island .hover .entry(spot) - .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS))) + .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms()))) .to(if inside { 1.0 } else { 0.0 }); } } @@ -248,13 +212,13 @@ pub fn view(monitor: &Monitor) -> LayerWindow { }; let open = if opened_here { island.open.value() } else { 0.0 }; - let slide = edge::hidden_by(edge_in, open, HIDE_H); + let slide = edge::hidden_by(edge_in, open, edge::hide_h()); - let window_w = WIDE_W + 2.0 * SHADOW; + let window_w = c::card::width() + 2.0 * c::pill::shadow(); let window = LayerWindow::new() .width(window_w) - .height(TOP_GAP + WIDE_H + SHADOW) + .height(c::pill::top_gap() + wide_h() + c::pill::shadow()) .anchor_vertical(Vertical::Top) // Overlay: on a desktop of fullscreen windows, Top is hidden behind every one of them .layer(Layer::Overlay) @@ -262,7 +226,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { .namespace("magic-island"); // out of sight: empty and click-through (never hidden, see below), and nothing else read, so a playing player doesn't redraw it - if slide <= -HIDE_H { + if slide <= -edge::hide_h() { return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); } @@ -292,7 +256,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { Rectangle::new() .width(Parent) .height(Parent) - .padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 }) + .padding(Padding { top: c::pill::top_gap(), right: 0.0, bottom: 0.0, left: 0.0 }) .align_child(Center, Start) .translate(0.0, slide) .child(idle(&theme::now().surfaces())), @@ -315,9 +279,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow { amane::request_frame(); } - let width = mix(COMPACT_W + PILL_HOVER_GROW * (lift + 2.0 * pop), WIDE_W, grow); - let height = mix(COMPACT_H, WIDE_H, grow); - let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow); + let width = mix(c::pill::width() + c::animation::hover_grow() * (lift + 2.0 * pop), c::card::width(), grow); + let height = mix(c::pill::height(), wide_h(), grow); + let radius = mix(c::pill::height() / 2.0, c::card::radius(), grow); /* * the wallpaper's surfaces as a gradient for the pill and the card; with cover_colors @@ -363,7 +327,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { /* * one faint pink glow around the island, pill and card alike, stronger while the * pill is hovered. blur 22 fades out within ~28 px (amane blurs with a deviation - * of blur / 2), inside the window's SHADOW margin. no clip anywhere: neither face + * of blur / 2), inside the window's c::pill::shadow() margin. no clip anywhere: neither face * ever overflows the shape */ let shape = shape.shadow(Shadow::drop(theme::now().accent).opacity(mix(0.3, 0.7, lift)).blur(22.0)); @@ -396,7 +360,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { // clicks outside the island reach the windows underneath let region = InputArea { x: ((window_w - width) / 2.0).floor() as i32, - y: TOP_GAP as i32, + y: c::pill::top_gap() as i32, width: width.ceil() as i32, height: height.ceil() as i32, }; @@ -405,7 +369,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { Rectangle::new() .width(Parent) .height(Parent) - .padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 }) + .padding(Padding { top: c::pill::top_gap(), right: 0.0, bottom: 0.0, left: 0.0 }) .align_child(Center, Start) .translate(0.0, slide) .child(shape), @@ -441,7 +405,7 @@ fn open_on(island: &mut Island, monitor: Option) { // the open timeline, at rest on the pill fn opening() -> Animation { - Animation::new(0.0).duration(Duration::from_millis(OPEN_MS)).easing(Easing::Linear) + Animation::new(0.0).duration(Duration::from_millis(c::animation::open_ms())).easing(Easing::Linear) } // the monitor of the focused workspace, which on hyprland follows the mouse; none without a workspace list @@ -563,9 +527,9 @@ pub fn mix(from: f32, to: f32, amount: f32) -> f32 { // one point on the open timeline as (shape grown, pill shown, card shown), each 0 to 1 pub fn phases(timeline: f32) -> (f32, f32, f32) { - let grow = (timeline / SHAPE_DONE).clamp(0.0, 1.0); - let pill = 1.0 - (timeline / PILL_GONE).clamp(0.0, 1.0); - let reveal = ((timeline - SHAPE_DONE) / (1.0 - SHAPE_DONE)).clamp(0.0, 1.0); + let grow = (timeline / c::animation::shape_done()).clamp(0.0, 1.0); + let pill = 1.0 - (timeline / c::animation::pill_gone()).clamp(0.0, 1.0); + let reveal = ((timeline - c::animation::shape_done()) / (1.0 - c::animation::shape_done())).clamp(0.0, 1.0); // the shape eases out (cubic), the card in and out (smoothstep) (1.0 - (1.0 - grow).powi(3), pill, reveal * reveal * (3.0 - 2.0 * reveal)) @@ -629,30 +593,30 @@ pub fn faded(color: Color, alpha: u8) -> Color { // the island while no player is open: a music note and "Nothing playing", glowing like the pill fn idle(colors: &[Color; 3]) -> Rectangle { Rectangle::new() - .width(COMPACT_W) - .height(COMPACT_H) + .width(c::pill::width()) + .height(c::pill::height()) .radius(Full) .fill(backdrop(colors, 255)) .border(1.0, theme::now().outline) .shadow(Shadow::drop(theme::now().accent).opacity(0.3).blur(22.0)) .align_child(Center, Center) - .child(Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(12.0).color(theme::now().dim)]).gap(8.0).align(Center)) + .child(Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(text::body()).color(theme::now().dim)]).gap(8.0).align(Center)) } // the pill: cover, title, the player's live levels fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle { Rectangle::new() - .width(COMPACT_W) - .height(COMPACT_H) + .width(c::pill::width()) + .height(c::pill::height()) .padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 }) .child( Row::new(children![ cover(art, 26.0, 1.5, false), Rectangle::new() .width(Parent) - .height(COMPACT_H) + .height(c::pill::height()) .align_child(Start, Center) - .child(Text::new(title(player)).size(12.0).color(theme::now().text).elide()), + .child(Text::new(title(player)).size(text::body()).color(theme::now().text).elide()), pill_levels(playing, levels), ]) .gap(10.0) @@ -667,32 +631,32 @@ fn card(scene: &Scene) -> Rectangle { layers.push(Box::new(menu_panel(scene, menu))); } - Rectangle::new().width(WIDE_W).height(WIDE_H).child(Stack::new(layers).width(WIDE_W).height(WIDE_H)) + Rectangle::new().width(c::card::width()).height(wide_h()).child(Stack::new(layers).width(c::card::width()).height(wide_h())) } fn content(scene: &Scene) -> Rectangle { - Rectangle::new().width(WIDE_W).height(WIDE_H).padding(PAD).child( + Rectangle::new().width(c::card::width()).height(wide_h()).padding(c::card::pad()).child( Column::new(children![ - Row::new(children![disc(scene), side(scene)]).gap(SIDE_GAP).align(Center).width(INNER_W).height(HEADER_H), - Rectangle::new().width(INNER_W).height(1.0).fill(theme::now().outline), + Row::new(children![disc(scene), side(scene)]).gap(c::card::side_gap()).align(Center).width(inner_w()).height(c::disc::size()), + Rectangle::new().width(inner_w()).height(1.0).fill(theme::now().outline), eq_header(scene), equalizer(scene), presets(scene), ]) - .gap(GAP), + .gap(c::card::gap()), ) } // the cd: a still cover with a hole, inside a ring of candles that move with this player's sound fn disc(scene: &Scene) -> Stack { - let offset = (DISC_BOX - DISC) / 2.0; + let offset = (c::disc::size() - c::disc::cover()) / 2.0; let player = scene.player.name().to_string(); let volume = scene.sound.channel(scene.player.name()).map_or(100, |channel| channel.volume); let cd = Rectangle::new() - .width(DISC) - .height(DISC) + .width(c::disc::cover()) + .height(c::disc::cover()) .radius(Full) .fill(theme::now().accent) .shadow(Shadow::drop(theme::now().accent).opacity(0.35).blur(18.0)) @@ -700,9 +664,9 @@ fn disc(scene: &Scene) -> Stack { .translate(offset, offset) // scroll over the cd for this player's volume .on_scroll(move |scroll| Sound::set_volume(&player, stepped(volume, scroll))) - .child(cover(scene.art, DISC - 6.0, 0.0, true)); + .child(cover(scene.art, c::disc::cover() - 6.0, 0.0, true)); - Stack::new(children![ring(scene.levels), cd]).width(DISC_BOX).height(DISC_BOX) + Stack::new(children![ring(scene.levels), cd]).width(c::disc::size()).height(c::disc::size()) } fn stepped(volume: u8, scroll: Scroll) -> u8 { @@ -716,20 +680,20 @@ fn stepped(volume: u8, scroll: Scroll) -> u8 { * shape each: 120 shapes cost this iGPU a third of its frame rate, 3 cost nothing */ fn ring(levels: &[f32]) -> Canvas { - let center = DISC_BOX / 2.0; - let inner = DISC / 2.0 + 7.0; + let center = c::disc::size() / 2.0; + let inner = c::disc::cover() / 2.0 + 7.0; let mut glow = Path::new(); let mut body = Path::new(); let mut flames = Path::new(); - for index in 0..CANDLES { + for index in 0..c::disc::candles() { // a gentle curve lifts the quiet bands, so the ring isn't all bass let level = mirrored(levels, index).powf(0.75); - let (sin, cos) = (index as f32 / CANDLES as f32 * TAU).sin_cos(); + let (sin, cos) = (index as f32 / c::disc::candles() as f32 * TAU).sin_cos(); - let reach = inner + 3.0 + level * CANDLE_REACH; + let reach = inner + 3.0 + level * c::disc::candle_reach(); let (x0, y0) = (center + sin * inner, center - cos * inner); let (x1, y1) = (center + sin * reach, center - cos * reach); @@ -746,7 +710,7 @@ fn ring(levels: &[f32]) -> Canvas { } } - Canvas::new().width(DISC_BOX).height(DISC_BOX).shapes(shapes![ + Canvas::new().width(c::disc::size()).height(c::disc::size()).shapes(shapes![ glow.stroke(9.0, faded(theme::now().accent, 50)).cap(Cap::Round), body.stroke(4.5, faded(theme::now().accent, 215)).cap(Cap::Round), flames.stroke(4.0, theme::now().flame).cap(Cap::Round), @@ -759,8 +723,8 @@ fn mirrored(levels: &[f32], index: usize) -> f32 { return 0.0; } - let half = CANDLES / 2; - let step = if index < half { index } else { CANDLES - 1 - index }; + let half = c::disc::candles() / 2; + let step = if index < half { index } else { c::disc::candles() - 1 - index }; levels[(step * levels.len() / half).min(levels.len() - 1)] } @@ -772,18 +736,18 @@ fn side(scene: &Scene) -> Column { Column::new(children![ Column::new(children![ - Text::new(title(player)).size(19.0).weight(Weight::Bold).color(theme::now().text).elide(), - Text::new(format!("BY {artist}")).size(12.0).color(theme::now().dim).elide(), + Text::new(title(player)).size(text::title()).weight(Weight::Bold).color(theme::now().text).elide(), + Text::new(format!("BY {artist}")).size(text::body()).color(theme::now().dim).elide(), chips(scene), ]) .gap(8.0) - .width(SIDE_W), + .width(side_w()), timeline(scene), controls(player, scene.island), ]) .justify(SpaceBetween) - .width(SIDE_W) - .height(HEADER_H) + .width(side_w()) + .height(c::disc::size()) } // output device and player, each opening its dropdown @@ -791,11 +755,11 @@ fn chips(scene: &Scene) -> Row { let channel = scene.sound.channel(scene.player.name()); let device = channel.and_then(|channel| channel.output.as_ref()); - let output_label = device.map_or(String::from("Output"), |device| parse::middle_ellipsis(sink_label(device), CHIP_LETTERS)); - let output_w = label_width(&output_label, 11.0, 50.0).clamp(90.0, OUTPUT_CHIP_MAX); + let output_label = device.map_or(String::from("Output"), |device| parse::middle_ellipsis(sink_label(device), c::menus::chip_letters())); + let output_w = label_width(&output_label, 11.0, 50.0).clamp(90.0, c::controls::output_chip_max()); let player_label = format!("VIA {}", scene.player.identity()); - let player_w = label_width(&player_label, 11.0, 22.0).clamp(50.0, SIDE_W - output_w - 8.0); + let player_w = label_width(&player_label, 11.0, 22.0).clamp(50.0, side_w() - output_w - 8.0); let menu = scene.island.menu; @@ -804,9 +768,9 @@ fn chips(scene: &Scene) -> Row { let output = Rectangle::new() .width(output_w) - .height(CHIP_H) + .height(c::controls::chip_height()) .radius(6.0) - .fill(Color::blend(if menu == Some(Menu::Output) { CHIP_OPEN } else { CHIP }, CHIP_HOVER, output_lit)) + .fill(Color::blend(if menu == Some(Menu::Output) { overlays::chip_open() } else { overlays::chip() }, overlays::chip_hover(), output_lit)) .padding(Padding { top: 0.0, right: 9.0, bottom: 0.0, left: 9.0 }) .align_child(Start, Center) .cursor(Pointer) @@ -816,7 +780,7 @@ fn chips(scene: &Scene) -> Row { .child( Row::new(children![ icon(if device.is_some_and(|device| device.bluetooth) { Glyph::Bluetooth } else { Glyph::Speaker }, 12.0, theme::now().accent), - Text::new(output_label).size(11.0).color(theme::now().text).elide(), + Text::new(output_label).size(text::small()).color(theme::now().text).elide(), icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().text, output_lit)), ]) .gap(6.0) @@ -825,9 +789,9 @@ fn chips(scene: &Scene) -> Row { let player = Rectangle::new() .width(player_w) - .height(CHIP_H) + .height(c::controls::chip_height()) .radius(6.0) - .fill(Color::blend(if menu == Some(Menu::Player) { CHIP_OPEN } else { CLEAR }, CHIP_HOVER, player_lit)) + .fill(Color::blend(if menu == Some(Menu::Player) { overlays::chip_open() } else { CLEAR }, overlays::chip_hover(), player_lit)) .padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 4.0 }) .align_child(Start, Center) .cursor(Pointer) @@ -836,19 +800,19 @@ fn chips(scene: &Scene) -> Row { .on_hover(hovering(Spot::Player)) .child( Row::new(children![ - Text::new(player_label).size(11.0).color(Color::blend(theme::now().dim, theme::now().text, player_lit)).elide(), + Text::new(player_label).size(text::small()).color(Color::blend(theme::now().dim, theme::now().text, player_lit)).elide(), icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().text, player_lit)), ]) .gap(6.0) .align(Center), ); - Row::new(children![output, player]).gap(8.0).align(Center).width(SIDE_W).height(CHIP_H) + Row::new(children![output, player]).gap(8.0).align(Center).width(side_w()).height(c::controls::chip_height()) } // a monospace label's width plus whatever surrounds it fn label_width(label: &str, size: f32, extra: f32) -> f32 { - label.chars().count() as f32 * size * LETTER + extra + label.chars().count() as f32 * size * font::letter_width() + extra } fn timeline(scene: &Scene) -> Column { @@ -869,27 +833,27 @@ fn timeline(scene: &Scene) -> Column { // hovered, the track thickens and brightens and the knob grows let lit = island.lit(Spot::Timeline); - let bar_h = BAR_H + 4.0 * lit; + let bar_h = c::controls::bar_height() + 4.0 * lit; - let bar = Rectangle::new().width(SIDE_W).height(KNOB).align_child(Start, Center).child( + let bar = Rectangle::new().width(side_w()).height(c::controls::knob()).align_child(Start, Center).child( Stack::new(children![ - Rectangle::new().width(SIDE_W).height(bar_h).radius(Full).fill(Color::blend(TRACK, CHIP_HOVER, lit)).child( + Rectangle::new().width(side_w()).height(bar_h).radius(Full).fill(Color::blend(overlays::track(), overlays::chip_hover(), lit)).child( Rectangle::new() - .width((SIDE_W * fraction).max(bar_h)) + .width((side_w() * fraction).max(bar_h)) .height(bar_h) .radius(Full) .fill(Color::blend(theme::now().accent, theme::now().flame, 0.4 * lit)), ), Rectangle::new() - .width(KNOB) - .height(KNOB) + .width(c::controls::knob()) + .height(c::controls::knob()) .radius(Full) .fill(Color::WHITE) .shadow(Shadow::drop("#000000").opacity(0.4).blur(6.0)) - .translate(SIDE_W * fraction - KNOB / 2.0, (bar_h - KNOB) / 2.0) + .translate(side_w() * fraction - c::controls::knob() / 2.0, (bar_h - c::controls::knob()) / 2.0) .scale(1.0 + 0.25 * lit), ]) - .width(SIDE_W) + .width(side_w()) .height(bar_h), ); @@ -905,7 +869,7 @@ fn timeline(scene: &Scene) -> Column { .on_drag(|point| { let mut island = Island::write(); - island.scrub = Some((point.x / SIDE_W).clamp(0.0, 1.0)); + island.scrub = Some((point.x / side_w()).clamp(0.0, 1.0)); island.seek_shown_until = None; }) .on_click(move |_| seek(&name, length)) @@ -917,12 +881,12 @@ fn timeline(scene: &Scene) -> Column { Column::new(children![ bar, - Row::new(children![Text::new(clock(shown)).size(11.0).color(theme::now().dim), Text::new(total).size(11.0).color(theme::now().dim)]) + Row::new(children![Text::new(clock(shown)).size(text::small()).color(theme::now().dim), Text::new(total).size(text::small()).color(theme::now().dim)]) .justify(SpaceBetween) - .width(SIDE_W), + .width(side_w()), ]) .gap(4.0) - .width(SIDE_W) + .width(side_w()) } fn leave_bar(inside: bool) { @@ -943,7 +907,7 @@ fn seek(player: &str, length: f32) { return; }; - island.seek_shown_until = Some(Instant::now() + Duration::from_millis(1500)); + island.seek_shown_until = Some(Instant::now() + Duration::from_millis(c::timing::seek_shown_ms())); fraction }; @@ -984,14 +948,14 @@ fn controls(player: &MediaPlayer, island: &Island) -> Row { // hovered, the transport icons go bold: thicker strokes, nothing else let lit = island.lit(Spot::PlayPause); - let middle = Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center); + let middle = Rectangle::new().width(c::controls::height()).height(c::controls::height()).align_child(Center, Center); let middle = if shown { - let bar = || Rectangle::new().width(10.0 + BOLD * lit).height(30.0 + BOLD * lit).radius(3.0).fill(theme::now().accent); + let bar = || Rectangle::new().width(10.0 + c::animation::bold() * lit).height(30.0 + c::animation::bold() * lit).radius(3.0).fill(theme::now().accent); - middle.child(Row::new(children![bar(), bar()]).gap(7.0 - BOLD * lit)) + middle.child(Row::new(children![bar(), bar()]).gap(7.0 - c::animation::bold() * lit)) } else { - middle.child(bold_icon(Glyph::Play, 30.0, theme::now().accent, BOLD * lit)) + middle.child(bold_icon(Glyph::Play, 30.0, theme::now().accent, c::animation::bold() * lit)) }; Row::new(children![ @@ -1008,8 +972,8 @@ fn controls(player: &MediaPlayer, island: &Island) -> Row { .gap(26.0) .align(Center) .justify(Center) - .width(SIDE_W) - .height(CONTROLS_H) + .width(side_w()) + .height(c::controls::height()) } // whether to show `player` as playing: a just-clicked play/pause wins until the player has answered @@ -1060,7 +1024,7 @@ fn button(glyph: Glyph, lit: f32) -> Rectangle { .height(40.0) .align_child(Center, Center) .cursor(Pointer) - .child(bold_icon(glyph, 18.0, theme::now().text, BOLD * lit)) + .child(bold_icon(glyph, 18.0, theme::now().text, c::animation::bold() * lit)) } fn eq_header(scene: &Scene) -> Row { @@ -1080,10 +1044,10 @@ fn eq_header(scene: &Scene) -> Row { right.push(Box::new( Rectangle::new() - .width(VOLUME_CHIP_W) + .width(c::controls::volume_chip_width()) .height(22.0) .radius(6.0) - .fill(Color::blend(CHIP, CHIP_HOVER, scene.island.lit(Spot::Volume))) + .fill(Color::blend(overlays::chip(), overlays::chip_hover(), scene.island.lit(Spot::Volume))) .align_child(Center, Center) .cursor(Pointer) .scale(1.0 + 0.06 * scene.island.lit(Spot::Volume)) @@ -1093,7 +1057,7 @@ fn eq_header(scene: &Scene) -> Row { .child( Row::new(children![ icon(if channel.muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, theme::now().dim), - Text::new(label).size(11.0).color(theme::now().text), + Text::new(label).size(text::small()).color(theme::now().text), ]) .gap(5.0) .align(Center), @@ -1102,57 +1066,57 @@ fn eq_header(scene: &Scene) -> Row { } right.push(Box::new( - Rectangle::new().width(70.0).height(22.0).radius(6.0).fill(CHIP).align_child(Center, Center).child(Text::new(state).size(11.0).color(theme::now().dim)), + Rectangle::new().width(70.0).height(22.0).radius(6.0).fill(overlays::chip()).align_child(Center, Center).child(Text::new(state).size(text::small()).color(theme::now().dim)), )); - right.push(Box::new(Text::new(eq.preset).size(12.0).color(theme::now().text))); + right.push(Box::new(Text::new(eq.preset).size(text::body()).color(theme::now().text))); - Row::new(children![Text::new("Equalizer").size(15.0).color(theme::now().accent), Row::new(right).gap(12.0).align(Center)]) + Row::new(children![Text::new("Equalizer").size(text::section()).color(theme::now().accent), Row::new(right).gap(12.0).align(Center)]) .justify(SpaceBetween) .align(Center) - .width(INNER_W) - .height(EQ_HEAD_H) + .width(inner_w()) + .height(c::equalizer::header_height()) } // the knob's centre for a gain, from the top of the slider fn knob_y(gain: f32) -> f32 { - KNOB / 2.0 + (1.0 - (gain + MAX_GAIN) / (2.0 * MAX_GAIN)) * (SLIDER_H - KNOB) + c::controls::knob() / 2.0 + (1.0 - (gain + crate::config::sound::equalizer::max_gain()) / (2.0 * crate::config::sound::equalizer::max_gain())) * (c::equalizer::slider_height() - c::controls::knob()) } fn gain_at(y: f32) -> f32 { - (1.0 - (y - KNOB / 2.0) / (SLIDER_H - KNOB)) * 2.0 * MAX_GAIN - MAX_GAIN + (1.0 - (y - c::controls::knob() / 2.0) / (c::equalizer::slider_height() - c::controls::knob())) * 2.0 * crate::config::sound::equalizer::max_gain() - crate::config::sound::equalizer::max_gain() } // ten vertical sliders, a glowing curve through their knobs, and the bands' names fn equalizer(scene: &Scene) -> Column { let gains = scene.sound.eq(scene.player.identity()).gains; - let column = INNER_W / BANDS.len() as f32; + let column = inner_w() / crate::config::sound::equalizer::bands().len() as f32; let mut tracks: Vec> = Vec::new(); let mut knobs: Vec> = Vec::new(); let mut labels: Vec> = Vec::new(); - for (band, (frequency, gain)) in BANDS.iter().zip(gains).enumerate() { + for (band, (frequency, gain)) in crate::config::sound::equalizer::bands().iter().zip(gains).enumerate() { let y = knob_y(gain); // hovered, a band's track widens and brightens, its knob grows and its label turns pink let lit = scene.island.lit(Spot::Band(band)); - let track_w = TRACK_W + 4.0 * lit; + let track_w = c::equalizer::track_width() + 4.0 * lit; tracks.push(Box::new( - Rectangle::new().width(column).height(SLIDER_H).align_child(Center, Start).child( + Rectangle::new().width(column).height(c::equalizer::slider_height()).align_child(Center, Start).child( Rectangle::new() .width(track_w) - .height(SLIDER_H) + .height(c::equalizer::slider_height()) .radius(track_w / 2.0) - .fill(Color::blend(TRACK, CHIP_HOVER, lit)) + .fill(Color::blend(overlays::track(), overlays::chip_hover(), lit)) .align_child(Center, End) .child( // rounded itself rather than clipped by the track: clips are gpu layers Rectangle::new() .width(track_w) - .height(SLIDER_H - y) + .height(c::equalizer::slider_height() - y) .radius(track_w / 2.0) .fill(Color::blend(theme::now().accent, theme::now().flame, 0.45 * lit)), ), @@ -1165,7 +1129,7 @@ fn equalizer(scene: &Scene) -> Column { knobs.push(Box::new( Rectangle::new() .width(column) - .height(SLIDER_H) + .height(c::equalizer::slider_height()) .align_child(Center, Start) .cursor(Pointer) .on_hover(hovering(Spot::Band(band))) @@ -1173,12 +1137,12 @@ fn equalizer(scene: &Scene) -> Column { .child( // a pink ring instead of a blurred shadow: ten blurs per frame cost the gpu more than the rest of the card Rectangle::new() - .width(KNOB) - .height(KNOB) + .width(c::controls::knob()) + .height(c::controls::knob()) .radius(Full) .fill(Color::WHITE) .border(2.0, Color::blend(faded(theme::now().accent, 150), theme::now().accent, lit)) - .translate(0.0, y - KNOB / 2.0) + .translate(0.0, y - c::controls::knob() / 2.0) .scale(1.0 + 0.25 * lit), ), )); @@ -1186,12 +1150,12 @@ fn equalizer(scene: &Scene) -> Column { let label = if *frequency >= 1000 { format!("{}k", frequency / 1000) } else { frequency.to_string() }; labels.push(Box::new( - Rectangle::new().width(column).height(LABEL_H).align_child(Center, Center).child(Text::new(label).size(10.0).color(Color::blend(theme::now().dim, theme::now().accent, lit))), + Rectangle::new().width(column).height(c::equalizer::label_height()).align_child(Center, Center).child(Text::new(label).size(text::tiny()).color(Color::blend(theme::now().dim, theme::now().accent, lit))), )); } Column::new(children![ - Stack::new(children![Row::new(tracks), curve(&gains, scene.levels), Row::new(knobs)]).width(INNER_W).height(SLIDER_H), + Stack::new(children![Row::new(tracks), curve(&gains, scene.levels), Row::new(knobs)]).width(inner_w()).height(c::equalizer::slider_height()), Row::new(labels), ]) .gap(8.0) @@ -1199,15 +1163,15 @@ fn equalizer(scene: &Scene) -> Column { // the curve through the knobs, shivering with the player's sound fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas { - let column = INNER_W / BANDS.len() as f32; + let column = inner_w() / crate::config::sound::equalizer::bands().len() as f32; - let points: Vec<(f32, f32)> = (0..=CURVE_STEPS) + let points: Vec<(f32, f32)> = (0..=c::equalizer::curve_steps()) .map(|step| { - let along = step as f32 / CURVE_STEPS as f32; + let along = step as f32 / c::equalizer::curve_steps() as f32; // cosine blend between neighbouring knobs keeps the curve round - let place = along * (BANDS.len() - 1) as f32; - let band = (place.floor() as usize).min(BANDS.len() - 2); + let place = along * (crate::config::sound::equalizer::bands().len() - 1) as f32; + let band = (place.floor() as usize).min(crate::config::sound::equalizer::bands().len() - 2); let eased = (1.0 - ((place - band as f32) * PI).cos()) / 2.0; let y = mix(knob_y(gains[band]), knob_y(gains[band + 1]), eased); @@ -1216,7 +1180,7 @@ fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas { let shiver = level * 9.0 * if step % 2 == 0 { -1.0 } else { 0.6 }; - (column / 2.0 + along * (INNER_W - column), y + shiver) + (column / 2.0 + along * (inner_w() - column), y + shiver) }) .collect(); @@ -1231,7 +1195,7 @@ fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas { }; // a wide faint stroke under a thin bright one reads as a glow - Canvas::new().width(INNER_W).height(SLIDER_H).shapes(shapes![stroke(7.0, 45), stroke(2.0, 235)]) + Canvas::new().width(inner_w()).height(c::equalizer::slider_height()).shapes(shapes![stroke(7.0, 45), stroke(2.0, 235)]) } fn presets(scene: &Scene) -> Column { @@ -1239,23 +1203,23 @@ fn presets(scene: &Scene) -> Column { let mut rows: Vec> = Vec::new(); - for row in EQ_PRESETS.chunks(4) { + for row in sound::presets().chunks(4) { let mut buttons: Vec> = Vec::new(); - for (name, _) in row { - let chosen = current == *name; + for &(name, _) in row { + let chosen = current == name; let lit = scene.island.lit(Spot::Preset(name)); let player = scene.player.name().to_string(); let identity = scene.player.identity().to_string(); // a hovered preset brightens: grey ones toward white, the chosen pink one toward its flame - let fill = if chosen { Color::blend(theme::now().accent, theme::now().flame, lit * 0.6) } else { Color::blend(CHIP, CHIP_HOVER, lit) }; + let fill = if chosen { Color::blend(theme::now().accent, theme::now().flame, lit * 0.6) } else { Color::blend(overlays::chip(), overlays::chip_hover(), lit) }; buttons.push(Box::new( Rectangle::new() - .width(PRESET_W) - .height(PRESET_H) + .width(preset_w()) + .height(c::equalizer::preset_height()) .radius(8.0) .fill(fill) .align_child(Center, Center) @@ -1266,18 +1230,18 @@ fn presets(scene: &Scene) -> Column { Sound::preset(&player, &identity, name); }) .child( - Text::new(*name) - .size(12.0) + Text::new(name) + .size(text::body()) .weight(if chosen { Weight::Bold } else { Weight::Regular }) .color(if chosen { theme::now().on_accent } else { theme::now().text }), ), )); } - rows.push(Box::new(Row::new(buttons).gap(PRESET_GAP))); + rows.push(Box::new(Row::new(buttons).gap(c::equalizer::preset_gap()))); } - Column::new(rows).gap(PRESET_GAP) + Column::new(rows).gap(c::equalizer::preset_gap()) } // the dropdown of output devices or of players @@ -1296,7 +1260,7 @@ fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle { let glyph = if sink.bluetooth { Glyph::Bluetooth } else { Glyph::Speaker }; let current = current.as_deref() == Some(sink.name.as_str()); - rows.push(Box::new(menu_row(&parse::middle_ellipsis(sink_label(sink), MENU_LETTERS), current, glyph, row, scene.island, move || { + rows.push(Box::new(menu_row(&parse::middle_ellipsis(sink_label(sink), c::menus::row_letters()), current, glyph, row, scene.island, move || { Sound::set_output(&player, &identity, &device); }))); } @@ -1314,10 +1278,10 @@ fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle { } } - let height = rows.len().clamp(1, MENU_ROWS) as f32 * MENU_ROW_H + 12.0; + let height = rows.len().clamp(1, c::menus::rows()) as f32 * c::menus::row_height() + 12.0; Rectangle::new() - .width(MENU_W) + .width(side_w()) .height(height) .radius(10.0) .fill(theme::now().surface_high) @@ -1325,7 +1289,7 @@ fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle { .shadow(Shadow::drop("#000000").opacity(0.5).blur(20.0).offset(0.0, 8.0)) .padding(6.0) .clip() - .translate(MENU_X, MENU_Y) + .translate(menu_x(), menu_y()) .child(ScrollArea::new("island-menu", Column::new(rows))) } @@ -1336,9 +1300,9 @@ fn sink_label(sink: &Sink) -> &str { fn menu_row(label: &str, current: bool, glyph: Glyph, row: usize, island: &Island, choose: impl Fn() + 'static) -> Rectangle { Rectangle::new() .width(Parent) - .height(MENU_ROW_H) + .height(c::menus::row_height()) .radius(6.0) - .fill(Color::blend(if current { CHIP } else { CLEAR }, CHIP_HOVER, island.lit(Spot::Row(row)))) + .fill(Color::blend(if current { overlays::chip() } else { CLEAR }, overlays::chip_hover(), island.lit(Spot::Row(row)))) .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }) .align_child(Start, Center) .cursor(Pointer) @@ -1352,7 +1316,7 @@ fn menu_row(label: &str, current: bool, glyph: Glyph, row: usize, island: &Islan .child( Row::new(children![ icon(glyph, 11.0, if current { theme::now().accent } else { theme::now().dim }), - Text::new(label).size(12.0).color(if current { theme::now().accent } else { theme::now().text }).elide(), + Text::new(label).size(text::body()).color(if current { theme::now().accent } else { theme::now().text }).elide(), ]) .gap(9.0) .align(Center), @@ -1377,11 +1341,11 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle { let picture = Rectangle::new().width(size).height(size).radius(Full).align_child(Center, Center); - let picture = if art.is_empty() { picture } else { picture.fill(Image::cover(art).thumbnail(THUMB, THUMB)) }; + let picture = if art.is_empty() { picture } else { picture.fill(Image::cover(art).thumbnail(c::disc::cover_pixels(), c::disc::cover_pixels())) }; // a rectangle holds one child, so the hole sits on the picture, not beside it let picture = if hole { - picture.child(Rectangle::new().width(HOLE).height(HOLE).radius(Full).fill(theme::now().surface_low).border(2.0, theme::now().outline)) + picture.child(Rectangle::new().width(c::disc::hole()).height(c::disc::hole()).radius(Full).fill(theme::now().surface_low).border(2.0, theme::now().outline)) } else { picture }; @@ -1395,34 +1359,32 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle { * player has no stream to listen to */ fn pill_levels(playing: bool, levels: &[f32]) -> Canvas { - const BARS: usize = 7; - const WIDTH: f32 = 36.0; - const HEIGHT: f32 = 26.0; + let (bars, width, height) = (c::pill::bars(), c::pill::bars_width(), c::pill::bars_height()); if !playing { return icon(Glyph::Pause, 13.0, theme::now().dim); } // lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line - let middle = HEIGHT / 2.0; + let middle = height / 2.0; let mut glow = Path::new(); let mut body = Path::new(); - for index in 0..BARS { + for index in 0..bars { let level = if levels.is_empty() { - [0.55, 0.85, 0.6, 0.75, 0.45, 0.6, 0.35][index] + [0.55, 0.85, 0.6, 0.75, 0.45, 0.6, 0.35][index % 7] } else { /* - * the loudest band of this bar's seventh of the spectrum, linear with a little - * gain: typical music sits at 0.15 to 0.35 here, and a lifting curve made every - * bar look nearly full + * the loudest band of this bar's share of the spectrum, linear with a little + * gain (bars_boost): typical music sits at 0.15 to 0.35 here, and a lifting + * curve made every bar look nearly full */ - (band(levels, index, BARS) * 1.4).min(1.0) + (band(levels, index, bars) * c::pill::bars_boost()).min(1.0) }; let half = 1.5 + level * (middle - 3.0); - let x = 3.0 + index as f32 * (WIDTH - 6.0) / (BARS - 1) as f32; + let x = 3.0 + index as f32 * (width - 6.0) / bars.saturating_sub(1).max(1) as f32; body = body.move_to(x, middle - half).line_to(x, middle + half); @@ -1431,7 +1393,7 @@ fn pill_levels(playing: bool, levels: &[f32]) -> Canvas { } } - Canvas::new().width(WIDTH).height(HEIGHT).shapes(shapes![ + Canvas::new().width(width).height(height).shapes(shapes![ glow.stroke(7.0, faded(theme::now().accent, 55)).cap(Cap::Round), body.stroke(3.2, theme::now().accent).cap(Cap::Round), ]) diff --git a/src/main.rs b/src/main.rs index 42bb8d9..04dc50b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,14 @@ mod art; +mod config; mod control; mod edge; mod glyph; +mod hypr; mod island; mod map; mod parse; mod prompt; +mod settings; mod sound; mod spectrum; mod system; @@ -17,17 +20,22 @@ mod workspaces; use amane::{App, Palette, Service}; fn main() { + // config.toml first: the font is chosen once, before anything draws + config::load(); + // the card's glow follows the cover art, which art::Art copies to this one path Palette::write().open(&art::palette_file(), 8); // one island on every monitor (the card opens only on one of them), a workspace pill top left // and the control centre top right on each; all three hidden until the pointer rests on the - // top edge (edge.rs), whose strip comes first so the bar lies over it + // top edge (edge.rs), whose strip comes first so the bar lies over it. the config window + // (settings.rs) is a normal window, opened on demand App::new() - .font("Fira Code") + .font(&config::appearance::font::family()) .ipc("island", island::command) .ipc("control", control::command) .ipc("tray", tray::command) + .ipc("config", settings::command) .window_per_monitor(edge::spacer) .window_per_monitor(edge::view) .window_per_monitor(island::view) diff --git a/src/map.rs b/src/map.rs index 3be4e20..820316d 100644 --- a/src/map.rs +++ b/src/map.rs @@ -11,34 +11,18 @@ use std::time::Instant; use amane::{Blend, Button, Canvas, Cap, Center, Circle, Color, Column, Full, Path, Pointer, Rectangle, Shape, Stack, Start, Text, Weight, Widget, shapes}; +use crate::config::appearance::{overlays, text}; +use crate::config::control::maps; use crate::glyph::{Glyph, glyph_shapes}; -use crate::island::{CHIP_OPEN, faded}; +use crate::island::faded; use crate::theme; -// the map's own height, under the menu's header -pub const AREA_H: f32 = 480.0; +// the map's sizes and timings are settings (config.rs: control.maps.*) -const BUBBLE: f32 = 40.0; -const NODE_R: f32 = 22.0; -const ICON: f32 = 18.0; -const LABEL_W: f32 = 140.0; -const LABEL_H: f32 = 30.0; - -// how far each one floats from its place, either way -const DRIFT_X: f32 = 26.0; -const DRIFT_Y: f32 = 16.0; - -// at most this many around the bubble (the strongest come first): more don't fit at this size without labels meeting -pub const NODES: usize = 8; - -// the wobbling line of what isn't connected -const IDLE_LINE: Color = Color::rgba(205, 205, 212, 85); - -// a click on the bubble shows its rings at least until all three have run out once; a ring takes RING_S -const RING_S: f32 = 1.6; -const RING_GAP_S: f32 = 0.5; -pub const RIPPLE_S: f32 = 2.0 * RING_GAP_S + RING_S; -const RING_REACH: f32 = 230.0; +// a click on the bubble shows its rings at least until all three have run out once +pub fn ripple_s() -> f32 { + 2.0 * maps::ring_gap_s() + maps::ring_s() +} #[derive(Clone, Copy, PartialEq)] pub enum Link { @@ -87,13 +71,13 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang let t = clock(); let (cx, cy) = (width / 2.0, height / 2.0); - let bubble = BUBBLE * (1.0 + 0.08 * centre.lit) + (t * 1.3).sin() * 1.5; + let bubble = maps::bubble() * (1.0 + 0.08 * centre.lit) + (t * 1.3).sin() * 1.5; // the ellipse the farthest ones sit on, wider than tall like the menu, with room for their labels above or below - let (rx, ry) = (width / 2.0 - LABEL_W / 2.0 - DRIFT_X - 6.0, cy - NODE_R - LABEL_H - DRIFT_Y - 9.0); + let (rx, ry) = (width / 2.0 - maps::label_width() / 2.0 - maps::drift_x() - 6.0, cy - maps::node_radius() - maps::label_height() - maps::drift_y() - 9.0); // and the one the nearest stay outside of, clear of the bubble, its ring and its name - let (inner_x, inner_y) = (BUBBLE + NODE_R + 150.0, BUBBLE + NODE_R + 80.0); + let (inner_x, inner_y) = (maps::bubble() + maps::node_radius() + 150.0, maps::bubble() + maps::node_radius() + 80.0); let count = nodes.len().max(1) as f32; @@ -121,8 +105,8 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang // a slow float, each on its own rhythm, two waves so the path never quite repeats let drift = ( - ((t * 0.31 + phase * 1.7).sin() * 0.7 + (t * 0.53 + phase * 0.9).sin() * 0.3) * DRIFT_X, - ((t * 0.27 + phase * 2.3).cos() * 0.7 + (t * 0.47 + phase * 1.3).cos() * 0.3) * DRIFT_Y, + ((t * 0.31 + phase * 1.7).sin() * 0.7 + (t * 0.53 + phase * 0.9).sin() * 0.3) * maps::drift_x(), + ((t * 0.27 + phase * 2.3).cos() * 0.7 + (t * 0.47 + phase * 1.3).cos() * 0.3) * maps::drift_y(), ); let x = cx + ex * push + drift.0; @@ -133,7 +117,7 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang let (ux, uy) = (dx / length, dy / length); let start = (cx + ux * bubble, cy + uy * bubble); - let span = (length - bubble - NODE_R).max(0.0); + let span = (length - bubble - maps::node_radius()).max(0.0); let point = |along: f32, swing: f32| (start.0 + ux * span * along - uy * swing, start.1 + uy * span * along + ux * swing); @@ -180,21 +164,21 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang let (fill, mark) = match node.link { Link::Connected => (theme.accent, theme.on_accent), - Link::Connecting => (Color::blend(CHIP_OPEN, theme.accent, 0.45), theme.text), - Link::Idle => (CHIP_OPEN, theme.text), + Link::Connecting => (Color::blend(overlays::chip_open(), theme.accent, 0.45), theme.text), + Link::Idle => (overlays::chip_open(), theme.text), }; let lit = node.lit; // its disc and icon go into the map's one canvas (see glyph_shapes), on top of every line - let radius = NODE_R * (1.0 + 0.12 * lit); + let radius = maps::node_radius() * (1.0 + 0.12 * lit); discs.push(Box::new(Circle::new().center(x, y).radius(radius).fill(Color::blend(fill, theme.flame, 0.2 * lit)))); discs.push(Box::new(Circle::new().center(x, y).radius(radius).stroke(1.0, Color::blend(theme.outline, theme.accent, lit)))); - discs.extend(glyph_shapes(node.glyph, ICON, mark, 0.0, (x - ICON / 2.0, y - ICON / 2.0))); + discs.extend(glyph_shapes(node.glyph, maps::icon(), mark, 0.0, (x - maps::icon() / 2.0, y - maps::icon() / 2.0))); // what's left as a widget is only where the pointer finds it - let target = Rectangle::new().width(2.0 * NODE_R).height(2.0 * NODE_R).radius(Full).translate(x - NODE_R, y - NODE_R).on_hover(node.on_hover); + let target = Rectangle::new().width(2.0 * maps::node_radius()).height(2.0 * maps::node_radius()).radius(Full).translate(x - maps::node_radius(), y - maps::node_radius()).on_hover(node.on_hover); let target = match node.on_click { Some(click) => target.cursor(Pointer).on_click(click), @@ -206,13 +190,13 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang let bold = node.link == Link::Connected; // away from the bubble: above the ones above it, so a label never lies on its own line - let label_y = if y < cy - NODE_R { y - NODE_R - 5.0 - LABEL_H } else { y + NODE_R + 5.0 }; + let label_y = if y < cy - maps::node_radius() { y - maps::node_radius() - 5.0 - maps::label_height() } else { y + maps::node_radius() + 5.0 }; floating.push(Box::new( - Rectangle::new().width(LABEL_W).height(LABEL_H).align_child(Center, Start).translate(x - LABEL_W / 2.0, label_y).child( + Rectangle::new().width(maps::label_width()).height(maps::label_height()).align_child(Center, Start).translate(x - maps::label_width() / 2.0, label_y).child( Column::new(vec![ - Box::new(centred(Text::new(node.name).size(11.5).weight(if bold { Weight::Bold } else { Weight::Regular }).color(Color::blend(theme.text, theme.flame, 0.3 * lit)).elide(), 15.0)), - Box::new(centred(Text::new(node.detail).size(10.0).color(theme.dim).elide(), 13.0)), + Box::new(centred(Text::new(node.name).size(text::label()).weight(if bold { Weight::Bold } else { Weight::Regular }).color(Color::blend(theme.text, theme.flame, 0.3 * lit)).elide(), 15.0)), + Box::new(centred(Text::new(node.detail).size(text::tiny()).color(theme.dim).elide(), 13.0)), ]) .gap(2.0), ), @@ -220,7 +204,7 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang } let mut lines: Vec> = shapes![ - idle.stroke(1.5, IDLE_LINE).cap(Cap::Round), + idle.stroke(1.5, overlays::idle_line()).cap(Cap::Round), glow.stroke(7.0, faded(theme.accent, 45)).cap(Cap::Round), solid.stroke(2.4, theme.accent).cap(Cap::Round), running.stroke(2.2, faded(theme.accent, 220)).cap(Cap::Round), @@ -229,17 +213,17 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang // while it searches, rings run out from the bubble and fade, the first one from the moment of the click if let Some(since) = centre.searching { for ring in 0..3 { - let age = since - ring as f32 * RING_GAP_S; + let age = since - ring as f32 * maps::ring_gap_s(); if age < 0.0 { continue; } - let grown = (age / RING_S).fract(); + let grown = (age / maps::ring_s().max(0.01)).fract(); let eased = 1.0 - (1.0 - grown).powi(2); lines.push(Box::new( - Circle::new().center(cx, cy).radius(bubble + 4.0 + eased * RING_REACH).stroke(2.0, faded(theme.accent, (150.0 * (1.0 - grown)) as u8)), + Circle::new().center(cx, cy).radius(bubble + 4.0 + eased * maps::ring_reach()).stroke(2.0, faded(theme.accent, (150.0 * (1.0 - grown)) as u8)), )); } } @@ -255,18 +239,18 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang // this desktop's name under its bubble, and what to know under that (on hover: what a click does) layers.push(Box::new( Rectangle::new() - .width(LABEL_W * 2.0) + .width(maps::label_width() * 2.0) .height(16.0) .align_child(Center, Center) - .translate(cx - LABEL_W, cy + BUBBLE + 12.0) - .child(Text::new(centre.host).size(12.0).weight(Weight::Bold).color(theme.text)), + .translate(cx - maps::label_width(), cy + maps::bubble() + 12.0) + .child(Text::new(centre.host).size(text::body()).weight(Weight::Bold).color(theme.text)), )); let note = if centre.lit > 0.5 { Some("Click to search again") } else { centre.note }; if let Some(note) = note { layers.push(Box::new( - Rectangle::new().width(LABEL_W * 3.0).height(16.0).align_child(Center, Center).translate(cx - LABEL_W * 1.5, cy + BUBBLE + 32.0).child(Text::new(note).size(11.0).color(theme.dim)), + Rectangle::new().width(maps::label_width() * 3.0).height(16.0).align_child(Center, Center).translate(cx - maps::label_width() * 1.5, cy + maps::bubble() + 32.0).child(Text::new(note).size(text::small()).color(theme.dim)), )); } @@ -275,10 +259,10 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang layers.push(Box::new( Rectangle::new() - .width(2.0 * BUBBLE) - .height(2.0 * BUBBLE) + .width(2.0 * maps::bubble()) + .height(2.0 * maps::bubble()) .radius(Full) - .translate(cx - BUBBLE, cy - BUBBLE) + .translate(cx - maps::bubble(), cy - maps::bubble()) .cursor(Pointer) .on_hover(centre.on_hover) .on_click(move |_| on_click()), @@ -290,5 +274,5 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang } fn centred(text: Text, height: f32) -> Rectangle { - Rectangle::new().width(LABEL_W).height(height).align_child(Center, Center).child(text) + Rectangle::new().width(maps::label_width()).height(height).align_child(Center, Center).child(text) } diff --git a/src/parse.rs b/src/parse.rs index 489785c..2d98cf3 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -1,6 +1,6 @@ // plain text parsing, kept free of amane so `rustc --test parse.rs` checks it on its own -// the equalizer's bands, in Hz +// the equalizer's 10 bands' default frequencies in Hz; config.toml's sound.equalizer.bands is what equalizers use pub const BANDS: [u32; 10] = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; // node names of the island's own equalizer sinks start with this @@ -237,12 +237,13 @@ pub fn middle_ellipsis(label: &str, max: usize) -> String { /* * a standalone `pipewire -c` config: one 10-band filter-chain sink named - * magic_island_eq., whose output plays into `target` (empty: the default) + * magic_island_eq. at these band frequencies, whose output plays into `target` + * (empty: the default) */ -pub fn eq_config(key: &str, description: &str, gains: &[f32; 10], target: &str) -> String { +pub fn eq_config(key: &str, description: &str, bands: &[u32; 10], gains: &[f32; 10], target: &str) -> String { let mut nodes = String::new(); - for (index, (band, gain)) in BANDS.iter().zip(gains).enumerate() { + for (index, (band, gain)) in bands.iter().zip(gains).enumerate() { let (label, q) = match index { 0 => ("bq_lowshelf", 0.7), 9 => ("bq_highshelf", 0.7), @@ -255,7 +256,7 @@ pub fn eq_config(key: &str, description: &str, gains: &[f32; 10], target: &str) ); } - let links: String = (1..BANDS.len()) + let links: String = (1..bands.len()) .map(|index| format!(" {{ output = \"b{index}:Out\" input = \"b{}:In\" }}\n", index + 1)) .collect(); @@ -327,7 +328,7 @@ pub fn eq_line(identity: &str, preset: &str, gains: &[f32; 10]) -> String { format!("{}\t{}\t{}\n", clean(identity), clean(preset), gains.join(" ")) } -// "key = value" lines, as in shell.conf and the matugen theme; blank lines, # comments and lines without = are skipped +// "key = value" lines, as in the matugen theme (and the old shell.conf); blank lines, # comments and lines without = are skipped pub fn settings(text: &str) -> Vec<(String, String)> { text.lines() .map(str::trim) @@ -732,6 +733,466 @@ pub fn icon_score(path: &str) -> u32 { fit + if symbolic { 1000 } else { 0 } } +// a value from config.toml or from hyprctl's json; json's objects are Tables +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Null, + Bool(bool), + Int(i64), + Float(f64), + Text(String), + List(Vec), + Table(Vec<(String, Value)>), +} + +impl Value { + pub fn get(&self, key: &str) -> Option<&Value> { + match self { + Value::Table(pairs) => pairs.iter().find(|(name, _)| name == key).map(|(_, value)| value), + _ => None, + } + } + + pub fn number(&self) -> Option { + match self { + Value::Int(number) => Some(*number as f64), + Value::Float(number) => Some(*number), + _ => None, + } + } + + pub fn text(&self) -> Option<&str> { + match self { + Value::Text(text) => Some(text), + _ => None, + } + } +} + +// one pass over toml or json text; both write strings, numbers, booleans and arrays the same way +struct Reader<'a> { + text: &'a str, + at: usize, +} + +impl Reader<'_> { + fn rest(&self) -> &str { + &self.text[self.at..] + } + + fn skip_space(&mut self) { + let rest = self.rest(); + + self.at += rest.len() - rest.trim_start().len(); + } + + fn eat(&mut self, expected: char) -> bool { + self.skip_space(); + + let found = self.rest().starts_with(expected); + + if found { + self.at += expected.len_utf8(); + } + + found + } + + fn word(&mut self, word: &str) -> bool { + let found = self.rest().starts_with(word); + + if found { + self.at += word.len(); + } + + found + } + + // "basic" with escapes (toml and json), or 'literal' (toml) + fn string(&mut self) -> Result { + let quote = self.rest().chars().next().ok_or("a string ends early")?; + let (all, start) = (self.text, self.at); + let mut letters = all[start + 1..].char_indices(); + let mut text = String::new(); + + while let Some((at, letter)) = letters.next() { + match letter { + _ if letter == quote => { + self.at = start + 1 + at + 1; + + return Ok(text); + } + '\\' if quote == '"' => { + let (_, escaped) = letters.next().ok_or("a string ends early")?; + + match escaped { + 'n' => text.push('\n'), + 't' => text.push('\t'), + 'r' => text.push('\r'), + 'b' => text.push('\u{8}'), + 'f' => text.push('\u{c}'), + 'u' => { + let digits: String = letters.by_ref().take(4).map(|(_, digit)| digit).collect(); + let code = u32::from_str_radix(&digits, 16).map_err(|_| "a bad \\u escape")?; + + text.push(char::from_u32(code).unwrap_or('\u{fffd}')); + } + other => text.push(other), + } + } + _ => text.push(letter), + } + } + + Err(String::from("a string has no closing quote")) + } + + fn number(&mut self) -> Result { + let length = self.rest().find(|letter: char| !(letter.is_ascii_alphanumeric() || "+-._".contains(letter))).unwrap_or(self.rest().len()); + let word = self.rest()[..length].replace('_', ""); + + self.at += length; + + let float = word.contains(['.', 'e', 'E']); + + match word.parse::() { + Ok(number) if !float => Ok(Value::Int(number)), + _ => word.parse::().map(Value::Float).map_err(|_| format!("`{word}` isn't a value")), + } + } + + fn value(&mut self) -> Result { + self.skip_space(); + + let first = self.rest().chars().next().ok_or("a value is missing")?; + + match first { + '"' | '\'' => self.string().map(Value::Text), + '[' => { + self.at += 1; + + let mut items = Vec::new(); + + while !self.eat(']') { + items.push(self.value()?); + + if !self.eat(',') && !self.rest().trim_start().starts_with(']') { + return Err(String::from("an array needs a comma or ]")); + } + } + + Ok(Value::List(items)) + } + '{' => { + self.at += 1; + + let mut pairs = Vec::new(); + + while !self.eat('}') { + self.skip_space(); + + let key = self.string()?; + + if !self.eat(':') { + return Err(String::from("an object needs a colon")); + } + + pairs.push((key, self.value()?)); + + if !self.eat(',') && !self.rest().trim_start().starts_with('}') { + return Err(String::from("an object needs a comma or }")); + } + } + + Ok(Value::Table(pairs)) + } + _ if self.word("true") => Ok(Value::Bool(true)), + _ if self.word("false") => Ok(Value::Bool(false)), + _ if self.word("null") => Ok(Value::Null), + _ => self.number(), + } + } + + // a key: bare words and "quoted" parts, joined by dots + fn key(&mut self) -> Result { + let mut parts = Vec::new(); + + loop { + self.skip_space(); + + if self.rest().starts_with(['"', '\'']) { + parts.push(self.string()?); + } else { + let length = self.rest().find(|letter: char| !(letter.is_ascii_alphanumeric() || "_-".contains(letter))).unwrap_or(self.rest().len()); + + if length == 0 { + return Err(String::from("a key is missing")); + } + + parts.push(self.rest()[..length].to_string()); + + self.at += length; + } + + if !self.eat('.') { + return Ok(parts.join(".")); + } + } + } + + // after a toml line's value or header only a # comment may follow + fn line_done(&mut self) -> Result<(), String> { + self.skip_space(); + + if self.rest().is_empty() || self.rest().starts_with('#') { Ok(()) } else { Err(format!("unexpected `{}`", self.rest())) } + } +} + +/* + * the toml config.toml is written in, as (table.key, value): [tables], key = value, + * dotted and quoted keys, strings, numbers, booleans and one-line arrays. lines it + * can't read come back as "line N: why"; the rest still count + */ +pub fn toml(text: &str) -> (Vec<(String, Value)>, Vec) { + let mut table = String::new(); + let mut values = Vec::new(); + let mut errors = Vec::new(); + + for (number, line) in text.lines().enumerate() { + let line = line.trim(); + + if line.is_empty() || line.starts_with('#') { + continue; + } + + let mut reader = Reader { text: line, at: 0 }; + + let read = if reader.eat('[') { + reader.key().and_then(|name| if reader.eat(']') { reader.line_done().map(|()| table = name) } else { Err(String::from("a table name needs ]")) }) + } else { + reader.key().and_then(|key| { + if !reader.eat('=') { + return Err(String::from("a key needs = and a value")); + } + + let value = reader.value()?; + + reader.line_done()?; + + values.push((if table.is_empty() { key } else { format!("{table}.{key}") }, value)); + + Ok(()) + }) + }; + + if let Err(why) = read { + errors.push(format!("line {}: {why}", number + 1)); + } + } + + (values, errors) +} + +// a value as toml writes it; floats keep their point, so they read back as floats +pub fn toml_text(value: &Value) -> String { + match value { + Value::Null => String::from("\"\""), + Value::Bool(on) => on.to_string(), + Value::Int(number) => number.to_string(), + Value::Float(number) => format!("{number:?}"), + Value::Text(text) => quoted(text), + Value::List(items) => format!("[{}]", items.iter().map(toml_text).collect::>().join(", ")), + Value::Table(pairs) => format!("{{ {} }}", pairs.iter().map(|(key, value)| format!("{key} = {}", toml_text(value))).collect::>().join(", ")), + } +} + +// a string in double quotes, as both toml and lua read it +pub fn quoted(text: &str) -> String { + let mut out = String::from("\""); + + for letter in text.chars() { + match letter { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + _ => out.push(letter), + } + } + + out.push('"'); + + out +} + +// every json value in the text, one after another (`hyprctl --batch` prints one object per command); +// text between them that isn't json (an error for one command) is skipped +pub fn json(text: &str) -> Vec { + let mut reader = Reader { text, at: 0 }; + let mut values = Vec::new(); + + loop { + reader.skip_space(); + + if reader.rest().is_empty() { + return values; + } + + let start = reader.at; + + match reader.value() { + Ok(value) => values.push(value), + Err(_) => match text[start..].char_indices().skip(1).find(|(_, letter)| matches!(letter, '{' | '[')) { + Some((next, _)) => reader.at = start + next, + None => return values, + }, + } + } +} + +// the kinds of value a Hyprland option holds, as `hyprctl -j getoption` names them +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum HyprKind { + Bool, + Int, + Float, + Text, + Gradient, + Vec2, + Css, +} + +/* + * one `hyprctl -j getoption` answer as (option, kind, the text the config window edits, + * whether Hyprland's own config sets it): gradients read "rgba(80d9ffff) rgba(b18cffff) 45deg", + * points "0 0", gaps "6 6 6 6" + */ +pub fn hypr_option(answer: &Value) -> Option<(String, HyprKind, String, bool)> { + let name = answer.get("option")?.text()?.to_string(); + let set = answer.get("set") == Some(&Value::Bool(true)); + + let kinds = [("bool", HyprKind::Bool), ("int", HyprKind::Int), ("float", HyprKind::Float), ("str", HyprKind::Text), ("gradient", HyprKind::Gradient), ("vec2", HyprKind::Vec2), ("css", HyprKind::Css)]; + + let (kind, value) = kinds.into_iter().find_map(|(key, kind)| Some((kind, answer.get(key)?)))?; + + Some((name, kind, hypr_text(kind, value)?, set)) +} + +// a Hyprland value (getoption's, or a default in `hyprctl descriptions`) as the text the window edits +pub fn hypr_text(kind: HyprKind, value: &Value) -> Option { + Some(match kind { + HyprKind::Bool => (value == &Value::Bool(true) || value == &Value::Int(1)).to_string(), + HyprKind::Int => (value.number()? as i64).to_string(), + HyprKind::Float => number_text(value.number()?), + HyprKind::Text => value.text()?.to_string(), + HyprKind::Css => value.text().map(String::from).or_else(|| value.number().map(|number| (number as i64).to_string()))?, + HyprKind::Gradient => gradient_text(value.text()?), + HyprKind::Vec2 => match value { + Value::List(point) => point.iter().filter_map(Value::number).map(number_text).collect::>().join(" "), + _ => return None, + }, + }) +} + +// 3 as "3.0", 0.25 as "0.25": floats keep a point so they read as floats +pub fn number_text(number: f64) -> String { + let rounded = (number * 1e6).round() / 1e6; + + if rounded.fract() == 0.0 { format!("{rounded:.1}") } else { rounded.to_string() } +} + +// hyprctl's "ff80d9ff ffb18cff 45deg" (argb) as "rgba(80d9ffff) rgba(b18cffff) 45deg"; an angle of 0 is left out +fn gradient_text(printed: &str) -> String { + printed + .split_whitespace() + .filter(|word| *word != "0deg") + .map(|word| match word.len() == 8 && word.chars().all(|digit| digit.is_ascii_hexdigit()) { + true => format!("rgba({}{})", &word[2..], &word[..2]), + false => word.to_string(), + }) + .collect::>() + .join(" ") +} + +// a colour the way the config window takes it, as Hyprland's "rgba(rrggbbaa)" +fn hypr_color(word: &str) -> Option { + let hex = |digits: &str| digits.chars().all(|digit| digit.is_ascii_hexdigit()); + + let rgba = if let Some(inner) = word.strip_prefix("rgba(").and_then(|rest| rest.strip_suffix(')')) { + inner.to_string() + } else if let Some(inner) = word.strip_prefix("rgb(").and_then(|rest| rest.strip_suffix(')')) { + format!("{inner}ff") + } else if let Some(digits) = word.strip_prefix('#') { + if digits.len() == 6 { format!("{digits}ff") } else { digits.to_string() } + } else { + let argb = Some(word.strip_prefix("0x").unwrap_or(word)).filter(|digits| digits.len() == 8 && digits.is_ascii())?; + + format!("{}{}", &argb[2..], &argb[..2]) + }; + + (rgba.len() == 8 && hex(&rgba)).then(|| format!("rgba({})", rgba.to_lowercase())) +} + +/* + * what the config window took, as the lua `hl.config` wants it for a kind: gradients as + * { colors = {..}, angle = N } (one colour alone as its string), points as { x, y }, gaps + * as one number or { top, right, bottom, left } from css-style 1 to 4 numbers + */ +pub fn hypr_lua(kind: HyprKind, text: &str) -> Result { + let text = text.trim(); + let numbers = || text.split_whitespace().map(|word| word.parse::().map_err(|_| format!("`{word}` isn't a number"))).collect::, _>>(); + + match kind { + HyprKind::Bool => match text { + "true" | "yes" | "on" | "1" => Ok(String::from("true")), + "false" | "no" | "off" | "0" => Ok(String::from("false")), + _ => Err(String::from("needs true or false")), + }, + HyprKind::Int => text.parse::().map(|number| number.to_string()).map_err(|_| String::from("needs a whole number")), + HyprKind::Float => text.parse::().map(number_text).map_err(|_| String::from("needs a number")), + HyprKind::Text => Ok(quoted(text)), + HyprKind::Gradient => { + let mut colors = Vec::new(); + let mut angle = None; + + for word in text.split_whitespace() { + if let Some(degrees) = word.strip_suffix("deg") { + angle = Some(degrees.parse::().map_err(|_| format!("`{word}` isn't an angle"))?); + } else { + colors.push(quoted(&hypr_color(word).ok_or(format!("`{word}` isn't a colour like rgba(80d9ffff) or #80d9ff"))?)); + } + } + + match (colors.as_slice(), angle) { + ([], _) => Err(String::from("needs a colour")), + ([color], None) => Ok(color.clone()), + _ => Ok(format!("{{ colors = {{ {} }}, angle = {} }}", colors.join(", "), number_text(angle.unwrap_or(0.0)))), + } + } + HyprKind::Vec2 => match numbers()?.as_slice() { + [x, y] => Ok(format!("{{ {}, {} }}", number_text(*x), number_text(*y))), + _ => Err(String::from("needs two numbers, like 0 0")), + }, + HyprKind::Css => { + let whole = numbers()?.iter().map(|number| number.round() as i64).collect::>(); + + let [top, right, bottom, left] = match whole.as_slice() { + [all] => [*all; 4], + [vertical, horizontal] => [*vertical, *horizontal, *vertical, *horizontal], + [top, horizontal, bottom] => [*top, *horizontal, *bottom, *horizontal], + [top, right, bottom, left] => [*top, *right, *bottom, *left], + _ => return Err(String::from("needs 1 to 4 numbers, like 6 or 6 10")), + }; + + if top == right && right == bottom && bottom == left { + Ok(top.to_string()) + } else { + Ok(format!("{{ top = {top}, right = {right}, bottom = {bottom}, left = {left} }}")) + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -844,7 +1305,7 @@ Sink #80 fn writes_equalizers() { let gains = [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, -1.0, 0.0, 3.5]; - let config = eq_config("fake0", "Lo\"-Fi ${x} Lab", &gains, "bluez_output.00_1B.1"); + let config = eq_config("fake0", "Lo\"-Fi ${x} Lab", &BANDS, &gains, "bluez_output.00_1B.1"); assert!(config.contains("node.name = \"magic_island_eq.fake0\" media.class = Audio/Sink")); assert!(config.contains("target.object = \"bluez_output.00_1B.1\"")); @@ -854,7 +1315,7 @@ Sink #80 // a hostile player name can't close the quoted string assert!(config.contains("node.description = \"Lo-Fi x Lab EQ\"")); - assert!(!eq_config("fake0", "x", &gains, "").contains("target.object")); + assert!(!eq_config("fake0", "x", &BANDS, &gains, "").contains("target.object")); assert!(eq_params(&gains).starts_with("{ params = [ \"b1:Gain\" 6.0 \"b2:Gain\" 5.0")); @@ -983,4 +1444,76 @@ Sink #80 assert!(!safe_name("openvpn.x; reboot")); assert!(!safe_name("")); } + + #[test] + fn reads_and_writes_toml() { + let text = "# top\nfont = \"Fira Code\" # a comment\n\n[island.pill]\nwidth = 220.0\nrows = 10\nshow = true\n\"quoted key\" = 'raw \\n'\n\ + [hyprland.general.col]\nactive_border = \"rgba(80d9ffff) 45deg\"\ngains = [5.0, -1.5, 0]\nbad = \nworse ] = 1\n[unclosed\n"; + + let (values, errors) = toml(text); + + assert_eq!(values, vec![ + (String::from("font"), Value::Text(String::from("Fira Code"))), + (String::from("island.pill.width"), Value::Float(220.0)), + (String::from("island.pill.rows"), Value::Int(10)), + (String::from("island.pill.show"), Value::Bool(true)), + (String::from("island.pill.quoted key"), Value::Text(String::from("raw \\n"))), + (String::from("hyprland.general.col.active_border"), Value::Text(String::from("rgba(80d9ffff) 45deg"))), + (String::from("hyprland.general.col.gains"), Value::List(vec![Value::Float(5.0), Value::Float(-1.5), Value::Int(0)])), + ]); + assert_eq!(errors.len(), 3); + assert!(errors[0].starts_with("line 12:")); + + // what toml_text writes reads back the same + for value in [Value::Float(220.0), Value::Float(0.66), Value::Int(-3), Value::Text(String::from("a \"b\"\\c\nd")), Value::List(vec![Value::Float(1.0), Value::Bool(false)])] { + let (read, errors) = toml(&format!("key = {}", toml_text(&value))); + + assert!(errors.is_empty()); + assert_eq!(read, vec![(String::from("key"), value)]); + } + } + + #[test] + fn reads_hyprland_json_and_writes_lua() { + let printed = "{\"option\": \"general:border_size\", \"int\": 2, \"set\": true }\n\ninvalid type (internal error) é\n{\"option\": \"decoration:rounding_power\", \"float\": 3.000000, \"set\": true }\n\n\ + {\"option\": \"general:col.active_border\", \"gradient\": \"ff80d9ff ffb18cff 45deg\", \"set\": true }\n\ + {\"option\": \"general:col.inactive_border\", \"gradient\": \"ff30374f 0deg\", \"set\": false }\n\ + {\"option\": \"decoration:shadow:offset\", \"vec2\": [0,-2.5], \"set\": false }\n{\"option\": \"general:gaps_in\", \"css\": \"6 6 6 6\", \"set\": true }\n\ + {\"option\": \"animations:enabled\", \"bool\": true, \"set\": false }\n{\"option\": \"general:layout\", \"str\": \"dwind\\u006ce\", \"set\": true }\n"; + + let options: Vec<_> = json(printed).iter().filter_map(hypr_option).map(|(name, kind, text, set)| (name, kind, text, set)).collect(); + + assert_eq!(options, vec![ + (String::from("general:border_size"), HyprKind::Int, String::from("2"), true), + (String::from("decoration:rounding_power"), HyprKind::Float, String::from("3.0"), true), + (String::from("general:col.active_border"), HyprKind::Gradient, String::from("rgba(80d9ffff) rgba(b18cffff) 45deg"), true), + (String::from("general:col.inactive_border"), HyprKind::Gradient, String::from("rgba(30374fff)"), false), + (String::from("decoration:shadow:offset"), HyprKind::Vec2, String::from("0.0 -2.5"), false), + (String::from("general:gaps_in"), HyprKind::Css, String::from("6 6 6 6"), true), + (String::from("animations:enabled"), HyprKind::Bool, String::from("true"), false), + (String::from("general:layout"), HyprKind::Text, String::from("dwindle"), true), + ]); + + // descriptions: one array of objects with nulls and nested lists + let described = json("[ {\"name\": \"a:b\", \"min\": null, \"map\": [{\"off\": 0}, {\"on\": 1}]} ]"); + + assert_eq!(described[0], Value::List(vec![Value::Table(vec![ + (String::from("name"), Value::Text(String::from("a:b"))), + (String::from("min"), Value::Null), + (String::from("map"), Value::List(vec![Value::Table(vec![(String::from("off"), Value::Int(0))]), Value::Table(vec![(String::from("on"), Value::Int(1))])])), + ])])); + + // every text the window shows goes back to lua in the form hl.config takes + assert_eq!(hypr_lua(HyprKind::Gradient, "rgba(80d9ffff) rgba(b18cffff) 45deg").unwrap(), "{ colors = { \"rgba(80d9ffff)\", \"rgba(b18cffff)\" }, angle = 45.0 }"); + assert_eq!(hypr_lua(HyprKind::Gradient, "#30374f").unwrap(), "\"rgba(30374fff)\""); + assert_eq!(hypr_lua(HyprKind::Gradient, "ee080d18").unwrap(), "\"rgba(080d18ee)\""); + assert!(hypr_lua(HyprKind::Gradient, "red").is_err()); + assert_eq!(hypr_lua(HyprKind::Vec2, "0 -2.5").unwrap(), "{ 0.0, -2.5 }"); + assert_eq!(hypr_lua(HyprKind::Css, "6 6 6 6").unwrap(), "6"); + assert_eq!(hypr_lua(HyprKind::Css, "6 10").unwrap(), "{ top = 6, right = 10, bottom = 6, left = 10 }"); + assert_eq!(hypr_lua(HyprKind::Bool, "yes").unwrap(), "true"); + assert!(hypr_lua(HyprKind::Int, "2.5").is_err()); + assert_eq!(hypr_lua(HyprKind::Float, "3").unwrap(), "3.0"); + assert_eq!(hypr_lua(HyprKind::Text, "say \"hi\"").unwrap(), "\"say \\\"hi\\\"\""); + } } diff --git a/src/prompt.rs b/src/prompt.rs index f141c39..d7ec60f 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -18,26 +18,23 @@ use amane::{ Zone, children, }; -use crate::control::{self, Control, RIGHT}; +use crate::config::appearance::{motion, overlays, text}; +use crate::config::control::pill as control_pill; +use crate::config::island::pill as island_pill; +use crate::config::popups::{layout, timing}; +use crate::control::{self, Control}; use crate::glyph::{Glyph, icon}; -use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, HOVER_MS, SHADOW, TOP_GAP, backdrop, veiled}; +use crate::island::{self, backdrop, veiled}; use crate::parse::{self, Pairing}; use crate::system; use crate::theme; -const POPUP_W: f32 = 360.0; -const POPUP_PAD: f32 = 16.0; -const INNER_W: f32 = POPUP_W - 2.0 * POPUP_PAD; -const HEADER_H: f32 = 36.0; -const FIELD_H: f32 = 36.0; -const CODE_H: f32 = 40.0; -const LINE_H: f32 = 18.0; -const BUTTON_W: f32 = 92.0; -const BUTTON_H: f32 = 32.0; -const GAP: f32 = 14.0; +// the popup's sizes are settings (config.rs: popups.*) +fn inner_w() -> f32 { + layout::width() - 2.0 * layout::pad() +} -// a bluetoothctl session that hasn't ended by then is stopped -const SESSION_S: u64 = 90; +// a bluetoothctl session that hasn't ended after popups.timing.session_s is stopped #[derive(Clone, PartialEq)] pub enum Ask { @@ -129,7 +126,7 @@ fn open(ask: Ask, wifi: bool, name: &str, target: &str) { prompt.request = Some(Request { ask, wifi, name: name.to_string(), target: target.to_string(), input: new_input() }); prompt.typed.clear(); prompt.copied = None; - prompt.appear = Animation::new(0.0).duration(Duration::from_millis(220)); + prompt.appear = Animation::new(0.0).duration(Duration::from_millis(timing::appear_ms())); prompt.appear.to(1.0); } @@ -236,7 +233,7 @@ fn run_session(address: &str, name: &str, pair: bool) { // stopped if it hasn't ended in time, which ends the reading below too thread::spawn(move || { - thread::sleep(Duration::from_secs(SESSION_S)); + thread::sleep(Duration::from_secs(timing::session_s())); let late = session().as_ref().is_some_and(|session| session.id == id); @@ -446,7 +443,7 @@ fn hovering(spot: u8) -> impl Fn(bool) + 'static { Prompt::write() .hover .entry(spot) - .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS))) + .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms()))) .to(if inside { 1.0 } else { 0.0 }); } } @@ -508,13 +505,13 @@ pub fn view(monitor: &Monitor) -> LayerWindow { }; let header = Row::new(children![ - Rectangle::new().width(HEADER_H).height(HEADER_H).radius(Full).fill(theme.accent).align_child(Center, Center).child(icon(mark, 17.0, theme.on_accent)), + Rectangle::new().width(layout::header_height()).height(layout::header_height()).radius(Full).fill(theme.accent).align_child(Center, Center).child(icon(mark, 17.0, theme.on_accent)), Column::new(children![ - Text::new(title).size(13.0).weight(Weight::Bold).color(theme.text).elide(), - Text::new(question).size(11.0).color(theme.dim).elide(), + Text::new(title).size(text::popup()).weight(Weight::Bold).color(theme.text).elide(), + Text::new(question).size(text::small()).color(theme.dim).elide(), ]) .gap(3.0) - .width(INNER_W - HEADER_H - 12.0), + .width(inner_w() - layout::header_height() - 12.0), ]) .gap(12.0) .align(Center); @@ -525,7 +522,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { match &request.ask { Ask::Password | Ask::Pin | Ask::Passkey => { let field = TextInput::new(request.input) - .size(13.0) + .size(text::popup()) .color(theme.text) .placeholder(match request.ask { Ask::Password => "password", @@ -540,22 +537,22 @@ pub fn view(monitor: &Monitor) -> LayerWindow { rows.push(Box::new( Rectangle::new() - .width(INNER_W) - .height(FIELD_H) + .width(inner_w()) + .height(layout::field_height()) .radius(10.0) - .fill(CHIP) + .fill(overlays::chip()) .border(1.0, theme.outline) .padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 12.0 }) .align_child(Start, Center) .child(field), )); - body_h = FIELD_H; + body_h = layout::field_height(); } Ask::Confirm(code) | Ask::Show(code) if !code.is_empty() => { rows.push(Box::new(big_line(&spaced(code), 26.0, theme.accent))); - body_h = CODE_H; + body_h = layout::code_height(); } Ask::Saved(password) => { let password = password.clone(); @@ -563,12 +560,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow { // a click on it copies it too rows.push(Box::new(big_line(&password, 18.0, theme.text).cursor(Pointer).on_click(move |_| copy(&password, || Prompt::write().copied = Some(0))))); - body_h = CODE_H; + body_h = layout::code_height(); } Ask::Failed { reason, .. } => { - rows.push(Box::new(Rectangle::new().width(INNER_W).height(LINE_H).align_child(Start, Center).child(Text::new(reason.as_str()).size(11.5).color(theme.text).elide()))); + rows.push(Box::new(Rectangle::new().width(inner_w()).height(layout::line_height()).align_child(Start, Center).child(Text::new(reason.as_str()).size(text::label()).color(theme.text).elide()))); - body_h = LINE_H; + body_h = layout::line_height(); } Ask::Confirm(_) | Ask::Show(_) => {} } @@ -582,9 +579,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let password = password.clone(); let label = if prompt.copied.is_some() { "Copied" } else { "Copy" }; - buttons.push(Box::new(button(label, Color::blend(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(move |_| copy(&password, || Prompt::write().copied = Some(0))))); + buttons.push(Box::new(button(label, Color::blend(overlays::chip_open(), overlays::chip_hover(), lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(move |_| copy(&password, || Prompt::write().copied = Some(0))))); } else if !matches!(request.ask, Ask::Failed { .. }) { - buttons.push(Box::new(button("Cancel", Color::blend(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(|_| refuse()))); + buttons.push(Box::new(button("Cancel", Color::blend(overlays::chip_open(), overlays::chip_hover(), lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(|_| refuse()))); } let primary = match request.ask { @@ -598,7 +595,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { if let Some(label) = primary { let ok = ready(&request.ask, &prompt.typed); - let fill = if ok { Color::blend(theme.accent, theme.flame, 0.35 * lit(1)) } else { CHIP }; + let fill = if ok { Color::blend(theme.accent, theme.flame, 0.35 * lit(1)) } else { overlays::chip() }; let ink = if ok { theme.on_accent } else { theme.dim }; let primary = button(label, fill, ink, if ok { lit(1) } else { 0.0 }).on_hover(hovering(1)); @@ -615,27 +612,27 @@ pub fn view(monitor: &Monitor) -> LayerWindow { buttons.push(Box::new(primary)); } - rows.push(Box::new(Row::new(vec![Box::new(Row::new(buttons).gap(10.0)) as Box]).justify(End).width(INNER_W).height(BUTTON_H))); + rows.push(Box::new(Row::new(vec![Box::new(Row::new(buttons).gap(10.0)) as Box]).justify(End).width(inner_w()).height(layout::button_height()))); let parts = if body_h > 0.0 { 3.0 } else { 2.0 }; - let card_h = 2.0 * POPUP_PAD + HEADER_H + body_h + BUTTON_H + GAP * (parts - 1.0); + let card_h = 2.0 * layout::pad() + layout::header_height() + body_h + layout::button_height() + layout::gap() * (parts - 1.0); let appear = prompt.appear.value(); - let content = Rectangle::new().width(POPUP_W).height(card_h).padding(POPUP_PAD).child(Column::new(rows).gap(GAP)); + let content = Rectangle::new().width(layout::width()).height(card_h).padding(layout::pad()).child(Column::new(rows).gap(layout::gap())); let card = Rectangle::new() - .width(POPUP_W) + .width(layout::width()) .height(card_h) .radius(22.0) .fill(backdrop(&colors, 255)) .border(1.0, Color::blend(theme.outline, theme.accent, 0.5)) .shadow(Shadow::drop(theme.accent).opacity(0.45).blur(22.0)) .translate(0.0, 8.0 * (1.0 - appear)) - .child(veiled(content, POPUP_W, card_h, 22.0, appear, &colors)); + .child(veiled(content, layout::width(), card_h, 22.0, appear, &colors)); // over the middle of the map menu, a little below the control centre's top - let (right, top) = (RIGHT + (control::map_width(monitor.width as f32) - POPUP_W) / 2.0 - SHADOW, TOP_GAP + 110.0 - SHADOW); + let (right, top) = (control_pill::right() + (control::map_width(monitor.width as f32) - layout::width()) / 2.0 - island_pill::shadow(), island_pill::top_gap() + 110.0 - island_pill::shadow()); /* * the keyboard only while there's something to type: the other popups come on their @@ -644,7 +641,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { */ let typing = matches!(request.ask, Ask::Password | Ask::Pin | Ask::Passkey); - let window = window(POPUP_W + 2.0 * SHADOW, card_h + 2.0 * SHADOW) + let window = window(layout::width() + 2.0 * island_pill::shadow(), card_h + 2.0 * island_pill::shadow()) .margin(Margin { top: top.max(0.0) as i32, right: right.max(0.0) as i32, bottom: 0, left: 0 }) .keyboard(if typing { Keyboard::Exclusive } else { Keyboard::OnDemand }) .on_key(|key| match key { @@ -660,9 +657,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow { _ => {} }); - let region = InputArea { x: SHADOW as i32, y: SHADOW as i32, width: POPUP_W as i32, height: card_h.ceil() as i32 }; + let region = InputArea { x: island_pill::shadow() as i32, y: island_pill::shadow() as i32, width: layout::width() as i32, height: card_h.ceil() as i32 }; - window.input_region(vec![region]).child(Rectangle::new().width(Parent).height(Parent).padding(SHADOW).align_child(Start, Start).child(card)) + window.input_region(vec![region]).child(Rectangle::new().width(Parent).height(Parent).padding(island_pill::shadow()).align_child(Start, Start).child(card)) } // a code spaced in threes, the way devices show it @@ -671,9 +668,9 @@ fn spaced(code: &str) -> String { } fn big_line(text: &str, size: f32, color: Color) -> Rectangle { - Rectangle::new().width(INNER_W).height(CODE_H).align_child(Center, Center).child(Text::new(text).size(size).weight(Weight::Bold).color(color).elide()) + Rectangle::new().width(inner_w()).height(layout::code_height()).align_child(Center, Center).child(Text::new(text).size(size).weight(Weight::Bold).color(color).elide()) } fn button(label: &str, fill: Color, ink: Color, lit: f32) -> Rectangle { - Rectangle::new().width(BUTTON_W).height(BUTTON_H).radius(10.0).fill(fill).align_child(Center, Center).scale(1.0 + 0.04 * lit).child(Text::new(label).size(12.0).weight(Weight::Bold).color(ink)) + Rectangle::new().width(layout::button_width()).height(layout::button_height()).radius(10.0).fill(fill).align_child(Center, Center).scale(1.0 + 0.04 * lit).child(Text::new(label).size(text::body()).weight(Weight::Bold).color(ink)) } diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..66480ef --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,699 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::process::Command; + +use amane::{ + Center, Color, Column, Cursor, End, Full, Padding, Rectangle, Row, ScrollArea, Service, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget, Window, children, close_window, open_window, window_size, +}; + +use crate::config::{self, Kind, Opt}; +use crate::hypr::{self, Hypr, HyprOption}; +use crate::parse::{HyprKind, Value}; +use crate::theme::{self, Colors}; + +/* + * the config window: every shell setting (config.rs) and every Hyprland option (hypr.rs) + * on pages in a sidebar, with a search over all of them. a normal window, opened from the + * control centre's Settings button or `amane ipc call config open|toggle` + */ + +const NAME: &str = "magic-island-settings"; +const TITLE: &str = "Magic Island settings"; + +// the window's own layout; its text sizes come from appearance.text like the rest +const SIDEBAR_W: f32 = 240.0; +const PAD: f32 = 20.0; +const CONTROL_W: f32 = 320.0; +const FIELD_H: f32 = 30.0; +const SLIDER_W: f32 = 200.0; +const NUMBER_W: f32 = 104.0; +const RESET_W: f32 = 56.0; + +const SEARCH: &str = "settings-search"; + +pub struct Ui { + // a shell page ("island") or a Hyprland one ("hyprland:decoration") + page: String, + query: String, + message: Option<(String, bool)>, + hover: Option, +} + +impl Service for Ui { + fn new() -> Self { + Self { page: String::from("appearance"), query: String::new(), message: None, hover: None } + } + + fn listen() {} +} + +pub fn open() { + open_window(NAME, view); + + hypr::load(); +} + +// whether the window is open: amane can't say, Hyprland lists it by title +fn is_open() -> bool { + Command::new("hyprctl").arg("clients").output().is_ok_and(|done| String::from_utf8_lossy(&done.stdout).contains(&format!("title: {TITLE}\n"))) +} + +/* + * `amane ipc call config ...`: open, close, toggle, page , search , + * get , set , reset , status. paths as in config.toml; + * hyprland.