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.
This commit is contained in:
server2
2026-10-15 02:18:57 +02:00
parent dfe3867529
commit 4fbfad3ce3
20 changed files with 3172 additions and 781 deletions
+33 -12
View File
@@ -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/<pid>/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=<your router, or a public resolver>
```
3. `hotspot = <Name>` in `shell.conf` (applies live).
3. `hotspot = "<Name>"` 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 <sink> # move the shown player to a sink (pactl sink name)
amane ipc call island status # player=<identity> expanded=<bool> menu=<bool>
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=<bool> page=<id> errors=<n> search=<words>
```
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, `<player>\t<preset or Custom>\t<10 gains in dB>`.
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout.
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
-16
View File
@@ -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/<name>.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
+2 -1
View File
@@ -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()));
}
}
}
+814
View File
@@ -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<Self>;
fn value(&self) -> Value;
}
impl Setting for bool {
const KIND: Kind = Kind::Bool;
fn read(value: &Value) -> Option<Self> {
if let Value::Bool(on) = value { Some(*on) } else { None }
}
fn value(&self) -> Value {
Value::Bool(*self)
}
}
impl Setting for f32 {
const KIND: Kind = Kind::Float;
fn read(value: &Value) -> Option<Self> {
value.number().map(|number| number as f32)
}
// through its own shortest text, so 0.66 is written 0.66 and not 0.6600000262260437
fn value(&self) -> Value {
Value::Float(self.to_string().parse().unwrap_or(f64::from(*self)))
}
}
macro_rules! whole_setting {
($($ty:ty),*) => {$(
impl Setting for $ty {
const KIND: Kind = Kind::Int;
fn read(value: &Value) -> Option<Self> {
if let Value::Int(number) = value { <$ty>::try_from(*number).ok() } else { None }
}
fn value(&self) -> Value {
Value::Int(i64::try_from(*self).unwrap_or(i64::MAX))
}
}
)*};
}
whole_setting!(u32, u64, usize);
impl Setting for String {
const KIND: Kind = Kind::Text;
fn read(value: &Value) -> Option<Self> {
value.text().map(String::from)
}
fn value(&self) -> Value {
Value::Text(self.clone())
}
}
impl Setting for Color {
const KIND: Kind = Kind::Color;
fn read(value: &Value) -> Option<Self> {
color(value.text()?)
}
fn value(&self) -> Value {
Value::Text(self.hex())
}
}
impl Setting for [f32; 10] {
const KIND: Kind = Kind::List;
fn read(value: &Value) -> Option<Self> {
let Value::List(items) = value else { return None };
let numbers: Vec<f32> = items.iter().map(|item| item.number().map(|number| number as f32)).collect::<Option<_>>()?;
numbers.try_into().ok()
}
fn value(&self) -> Value {
Value::List(self.iter().map(Setting::value).collect())
}
}
impl Setting for [u32; 10] {
const KIND: Kind = Kind::List;
fn read(value: &Value) -> Option<Self> {
let Value::List(items) = value else { return None };
let numbers: Vec<u32> = items.iter().map(u32::read).collect::<Option<_>>()?;
numbers.try_into().ok()
}
fn value(&self) -> Value {
Value::List(self.iter().map(Setting::value).collect())
}
}
// "#rrggbb" or "#rrggbbaa", the # optional
pub fn color(text: &str) -> Option<Color> {
let digits = text.trim().trim_start_matches('#');
if !(digits.len() == 6 || digits.len() == 8) || !digits.chars().all(|digit| digit.is_ascii_hexdigit()) {
return None;
}
let channel = |at: usize| u8::from_str_radix(&digits[at..at + 2], 16).ok();
Some(Color::rgba(channel(0)?, channel(2)?, channel(4)?, if digits.len() == 8 { channel(6)? } else { 255 }))
}
macro_rules! settings {
($( $module:ident { $( $group:ident { $( $name:ident : $ty:ty = $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/<name>.ap). Empty: the tile says how to set one up"
extra_output: String = String::new(); "A second Sound slider for the output whose name contains this, like an AV receiver. Empty: none"
}
animation {
open_ms: u64 = 520, 0..=5000; "How long the panel takes to open and close"
menu_ms: u64 = 380, 0..=5000; "How long a menu takes to open from its tile"
hover_grow: f32 = 6.0, 0..=100; "How much wider the pill gets when hovered"
collapse_ms: u64 = 250, 0..=5000; "How long after the pointer leaves the panel closes"
held_ms: u64 = 1200, 0..=10000; "How long a dragged slider shows where it was let go while the system catches up"
busy_s: u64 = 10, 1..=120; "How long a clicked tile shows it's working before giving up"
pending_s: u64 = 20, 1..=300; "How long a network or device shows as connecting after a click"
}
pill {
pad: f32 = 16.0, 0..=100; "Padding on each side of the pill's icons"
gap: f32 = 12.0, 0..=100; "Space between the pill's icons"
right: f32 = 12.0, 0..=400; "Distance of the pill and panel from the monitor's right edge"
}
panel {
width: f32 = 360.0, 240..=1000; "Width of the panel"
radius: f32 = 26.0, 0..=100; "Corner radius of the panel"
pad: f32 = 12.0, 0..=100; "Padding inside the panel"
gap: f32 = 10.0, 0..=100; "Space between the panel's tiles and blocks"
tile_height: f32 = 60.0, 30..=200; "Height of a connection tile"
button: f32 = 34.0, 16..=120; "Size of a tile's round button"
footer_height: f32 = 32.0, 16..=120; "Height of the Settings button at the bottom"
}
modules {
radius: f32 = 18.0, 0..=100; "Corner radius of the blocks under the tiles"
pad: f32 = 12.0, 0..=100; "Padding inside a block"
title_height: f32 = 18.0, 8..=80; "Height of a block's title row"
line_gap: f32 = 8.0, 0..=60; "Space between the lines in a block"
slider_height: f32 = 28.0, 8..=100; "Height of a slider"
keys_height: f32 = 26.0, 8..=100; "Height of the keyboard light's buttons"
profile_height: f32 = 48.0, 16..=200; "Height of the performance profile buttons"
vram_height: f32 = 14.0, 4..=60; "Height of the external GPU's memory line"
row_height: f32 = 22.0, 8..=100; "Height of a program row under the external GPU"
max_rows: usize = 5, 1..=50; "Programs listed under the external GPU before \"and N more\""
}
menus {
pad: f32 = 12.0, 0..=100; "Padding inside a menu"
header_height: f32 = 28.0, 10..=100; "Height of a menu's title row"
row_height: f32 = 44.0, 16..=150; "Height of a menu row"
rows: usize = 7, 1..=50; "The most VPN rows the VPN menu lists"
note_height: f32 = 18.0, 6..=80; "Height of a note line in a menu"
output_height: f32 = 22.0, 8..=100; "Height of a row in the Sound menu"
outputs: usize = 8, 1..=50; "The most outputs the Sound menu lists"
}
ethernet {
width: f32 = 500.0, 240..=1200; "Width of the Ethernet details"
detail_height: f32 = 28.0, 10..=100; "Height of a detail row"
label_width: f32 = 78.0, 20..=300; "Width of a detail's label"
copy_width: f32 = 58.0, 20..=200; "Width of a Copy button"
}
maps {
height: f32 = 480.0, 200..=1600; "Height of the Wi-Fi and Bluetooth maps"
min_width: f32 = 600.0, 200..=3000; "The narrowest a map gets on a small monitor (where it then runs under the island)"
island_gap: f32 = 16.0, 0..=200; "Space between a map and the media island's pill"
nodes: usize = 8, 1..=40; "The most networks or devices a map shows; the strongest come first"
bubble: f32 = 40.0, 10..=200; "Size of the bubble for this computer in the middle"
node_radius: f32 = 22.0, 6..=100; "Radius of a network's or device's disc"
icon: f32 = 18.0, 6..=80; "Size of the icons on the map"
label_width: f32 = 140.0, 40..=500; "Width of a node's label"
label_height: f32 = 30.0, 10..=100; "Height of a node's label"
drift_x: f32 = 26.0, 0..=200; "How far nodes float sideways"
drift_y: f32 = 16.0, 0..=200; "How far nodes float up and down"
ring_s: f32 = 1.6, 0.1..=10.0; "How long a search ring takes to ripple out, in seconds"
ring_gap_s: f32 = 0.5, 0.0..=10.0; "Time between the three search rings, in seconds"
ring_reach: f32 = 230.0, 20..=2000; "How far the search rings ripple"
}
polling {
links_ms: u64 = 2000, 200..=60000; "How often Wi-Fi, VPN and hotspot state is read"
power_ms: u64 = 2000, 200..=60000; "How often the battery and performance profile are read"
gpu_ms: u64 = 2000, 200..=60000; "How often the external GPU's programs are scanned while the panel is open"
gpu_wait_ms: u64 = 400, 50..=10000; "How often the GPU scan checks whether the panel has opened"
wifi_rate_s: u64 = 60, 1..=3600; "How long a Wi-Fi network's link speed is kept before it's asked again"
battery_estimate_s: u64 = 30, 1..=3600; "How long a battery time estimate is kept"
}
}
popups {
layout {
width: f32 = 360.0, 200..=1000; "Width of a popup (password, PIN, failure)"
pad: f32 = 16.0, 0..=100; "Padding inside a popup"
header_height: f32 = 36.0, 10..=120; "Height of a popup's title row"
field_height: f32 = 36.0, 16..=120; "Height of the text field"
code_height: f32 = 40.0, 16..=120; "Height of a shown PIN or passkey"
line_height: f32 = 18.0, 8..=80; "Height of a line of text"
button_width: f32 = 92.0, 30..=300; "Width of a button"
button_height: f32 = 32.0, 16..=100; "Height of a button"
gap: f32 = 14.0, 0..=100; "Space between a popup's parts"
}
timing {
appear_ms: u64 = 220, 0..=2000; "How long a popup takes to appear"
session_s: u64 = 90, 5..=900; "How long a Bluetooth pairing may take before it's stopped"
}
}
bar {
hiding {
dwell_ms: u64 = 300, 0..=5000; "How long the pointer rests on the top edge of a fullscreen monitor before the bar shows"
hide_ms: u64 = 700, 0..=10000; "How long after the pointer leaves the bar hides again"
slide_ms: u64 = 220, 0..=3000; "How long the bar takes to slide in or out"
}
peeks {
workspaces_ms: u64 = 1600, 0..=20000; "How long the workspaces show on a fullscreen monitor after a workspace change"
island_ms: u64 = 3500, 0..=20000; "How long the island shows on a fullscreen monitor after a song change"
pop_s: f32 = 0.7, 0.0..=5.0; "How long the island's pop lasts on a new song, in seconds"
switch_s: f32 = 0.35, 0.0..=5.0; "How long the workspace highlight takes to slide over, in seconds"
media_check_ms: u64 = 300, 50..=5000; "How often the song is checked for a change, for the island's peek"
}
}
}
// what config.toml set, by path; a missing or bad setting takes its default
static VALUES: LazyLock<RwLock<HashMap<String, Value>>> = LazyLock::new(Default::default);
// what was wrong in the file when it was last read, for the config window
static ERRORS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// ponytail: a lock and a hash lookup per call, a few hundred a frame; a snapshot struct if a profile ever shows it
fn get<T: Setting>(path: &str, default: impl FnOnce() -> T) -> T {
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).and_then(T::read).unwrap_or_else(default)
}
pub fn file() -> PathBuf {
theme::config_file("config.toml")
}
pub fn option(path: &str) -> Option<&'static Opt> {
OPTIONS.iter().find(|option| option.path == path)
}
// at startup: config.toml, or the first time, one made from the old shell.conf
pub fn load() {
let 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<f64> = match value {
Value::List(items) => items.iter().filter_map(Value::number).collect(),
_ => value.number().into_iter().collect(),
};
if let Some((min, max)) = option.range
&& numbers.iter().any(|number| *number < min || *number > max)
{
return Err(format!("must be from {min} to {max}"));
}
Ok(())
}
fn kind_words(option: &Opt) -> String {
match option.kind {
Kind::Bool => String::from("true or false"),
Kind::Int => String::from("a whole number"),
Kind::Float => String::from("a number"),
Kind::Text => String::from("text in quotes"),
Kind::Color => String::from("a colour like \"#f7a8c8\" or \"#ffffff12\""),
Kind::List => match (option.default)() {
Value::List(items) => format!("a list of {} numbers", items.len()),
_ => String::from("a list"),
},
}
}
pub fn errors() -> Vec<String> {
ERRORS.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
// a setting's value, its default where config.toml doesn't set it
pub fn current(option: &Opt) -> Value {
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(option.path).cloned().unwrap_or_else(option.default)
}
pub fn is_default(option: &Opt) -> bool {
current(option) == (option.default)()
}
// the Hyprland options set here, as (hyprland.decoration.blur.size, value), sorted
pub fn overrides() -> Vec<(String, Value)> {
let mut found: Vec<(String, Value)> = VALUES
.read()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.filter(|(path, _)| path.starts_with("hyprland."))
.map(|(path, value)| (path.clone(), value.clone()))
.collect();
found.sort_by(|a, b| a.0.cmp(&b.0));
found
}
pub fn override_of(path: &str) -> Option<Value> {
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).cloned()
}
// sets one setting and saves the file; the shell redraws with it at once
pub fn set(path: &str, value: Value) -> Result<(), String> {
check(path, &value)?;
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(&current(option))));
}
let overrides = overrides();
if !overrides.is_empty() {
out.push_str(
"\n# Hyprland options changed in the config window. The shell writes them to ~/.config/hypr/amane.lua;\n\
# load that last in hyprland.lua with require(\"amane\") so they win over the rest of your config.\n",
);
let mut table = String::new();
for (path, value) in overrides {
let (at, name) = path.rsplit_once('.').unwrap_or(("hyprland", &path));
if at != table {
out.push_str(&format!("\n[{at}]\n"));
table = at.to_string();
}
out.push_str(&format!("{name} = {}\n", parse::toml_text(&value)));
}
}
out
}
// help text as comment lines of at most `width` letters
fn wrapped(text: &str, width: usize) -> Vec<String> {
let mut lines: Vec<String> = vec![String::new()];
for word in text.split_whitespace() {
let line = lines.last_mut().expect("starts with one line");
if !line.is_empty() && line.len() + 1 + word.len() > width {
lines.push(word.to_string());
} else {
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
}
lines
}
// a value as the config window shows it
pub fn shown(value: &Value) -> String {
match value {
Value::Bool(on) => on.to_string(),
Value::Int(number) => number.to_string(),
Value::Float(number) => parse::number_text(*number),
Value::Text(text) => text.clone(),
Value::List(items) => items.iter().map(shown).collect::<Vec<_>>().join(" "),
Value::Null | Value::Table(_) => String::new(),
}
}
// what was typed for a setting, as its value; checked when it's set
pub fn from_text(option: &Opt, text: &str) -> Result<Value, String> {
let text = text.trim();
let wrong = || format!("{} needs {}", option.name(), kind_words(option));
match option.kind {
Kind::Bool => match text {
"true" | "yes" | "on" | "1" => Ok(Value::Bool(true)),
"false" | "no" | "off" | "0" => Ok(Value::Bool(false)),
_ => Err(wrong()),
},
Kind::Int => text.parse::<i64>().map(Value::Int).map_err(|_| wrong()),
Kind::Float => text.parse::<f64>().map(Value::Float).map_err(|_| wrong()),
Kind::Text => Ok(Value::Text(text.to_string())),
Kind::Color => color(text).map(|color| Value::Text(color.hex())).ok_or_else(wrong),
Kind::List => {
let whole = matches!((option.default)(), Value::List(items) if items.first().is_some_and(|item| matches!(item, Value::Int(_))));
text.split(|letter: char| letter.is_whitespace() || letter == ',')
.filter(|word| !word.is_empty())
.map(|word| if whole { word.parse().map(Value::Int).ok() } else { word.parse().map(Value::Float).ok() })
.collect::<Option<Vec<_>>>()
.map(Value::List)
.ok_or_else(wrong)
}
}
}
// the shell's pages in the window, in the order they're declared
pub fn modules() -> Vec<&'static str> {
let mut found: Vec<&'static str> = Vec::new();
for option in OPTIONS {
if !found.contains(&option.module()) {
found.push(option.module());
}
}
found
}
// a page's or group's name as the window shows it
pub fn title(id: &str) -> String {
match id {
"control" => String::from("Control centre"),
"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)
}
+224 -239
View File
File diff suppressed because it is too large Load Diff
+17 -26
View File
@@ -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>, 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)
+14
View File
@@ -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<Box<dyn Shape>> = 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
}
}
}
+410
View File
@@ -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<HyprOption>,
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<Sender<Job>> = 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<Job>) {
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<Job> = 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<Vec<&'static str>> = 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<String, (HyprKind, String, bool)> {
if names.is_empty() {
return HashMap::new();
}
let batch = names.iter().map(|name| format!("j/getoption {name}")).collect::<Vec<_>>().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<HyprOption> = 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<HyprKind> {
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<String> = 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<String, HyprKind> = {
let hypr = Hypr::read();
if hypr.loaded { hypr.options.iter().map(|option| (option.key(), option.kind)).collect() } else { HashMap::new() }
};
let kinds: HashMap<String, HyprKind> = 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);
}
}
+169 -207
View File
@@ -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<String>) {
// 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<Box<dyn Widget>> = Vec::new();
let mut knobs: Vec<Box<dyn Widget>> = Vec::new();
let mut labels: Vec<Box<dyn Widget>> = 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<Box<dyn Widget>> = Vec::new();
for row in EQ_PRESETS.chunks(4) {
for row in sound::presets().chunks(4) {
let mut buttons: Vec<Box<dyn Widget>> = 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),
])
+10 -2
View File
@@ -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)
+35 -51
View File
@@ -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<Node>) -> 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<Node>) -> 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<Node>) -> 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<Node>) -> 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<Node>) -> 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<Node>) -> Rectang
}
let mut lines: Vec<Box<dyn Shape>> = 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<Node>) -> 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<Node>) -> 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<Node>) -> 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<Node>) -> 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)
}
+541 -8
View File
@@ -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.<key>, whose output plays into `target` (empty: the default)
* magic_island_eq.<key> 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<Value>),
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<f64> {
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<String, String> {
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<Value, String> {
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::<i64>() {
Ok(number) if !float => Ok(Value::Int(number)),
_ => word.parse::<f64>().map(Value::Float).map_err(|_| format!("`{word}` isn't a value")),
}
}
fn value(&mut self) -> Result<Value, String> {
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<String, String> {
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<String>) {
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::<Vec<_>>().join(", ")),
Value::Table(pairs) => format!("{{ {} }}", pairs.iter().map(|(key, value)| format!("{key} = {}", toml_text(value))).collect::<Vec<_>>().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<Value> {
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<String> {
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::<Vec<_>>().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::<Vec<_>>()
.join(" ")
}
// a colour the way the config window takes it, as Hyprland's "rgba(rrggbbaa)"
fn hypr_color(word: &str) -> Option<String> {
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<String, String> {
let text = text.trim();
let numbers = || text.split_whitespace().map(|word| word.parse::<f64>().map_err(|_| format!("`{word}` isn't a number"))).collect::<Result<Vec<_>, _>>();
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::<i64>().map(|number| number.to_string()).map_err(|_| String::from("needs a whole number")),
HyprKind::Float => text.parse::<f64>().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::<f64>().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::<Vec<_>>();
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\\\"\"");
}
}
+41 -44
View File
@@ -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<dyn Widget>]).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<dyn Widget>]).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))
}
+699
View File
@@ -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<String>,
}
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 <id>, search <words>,
* get <path>, set <path> <value>, reset <path>, status. paths as in config.toml;
* hyprland.<option> ones go to Hyprland
*/
pub fn command(arguments: &[String]) -> String {
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
match words.as_slice() {
["open"] => open(),
["close"] => close_window(NAME),
["toggle"] => {
if is_open() {
close_window(NAME);
} else {
open();
}
}
["page", page] => {
go_to(page);
open();
}
["search", rest @ ..] => {
Ui::write().query = rest.join(" ");
open();
}
["get", path] => {
return match config::option(path) {
Some(option) => config::shown(&config::current(option)),
None => match path.strip_prefix("hyprland.") {
Some(key) => Hypr::read().options.iter().find(|option| option.key() == key).map_or_else(|| String::from("not loaded; open the window first"), |option| option.current.clone()),
None => format!("no setting {path}"),
},
};
}
["set", path, value @ ..] => return set_text(path, &value.join(" ")).map_or_else(|why| why, |()| String::from("ok")),
["reset", path] => {
return match path.strip_prefix("hyprland.") {
Some(key) => {
hypr::reset(key);
String::from("ok")
}
None => config::reset(path).map_or_else(|why| why, |()| String::from("ok")),
};
}
["status"] => {
let ui = Ui::read();
return format!("open={} page={} errors={} search={}", is_open(), ui.page, config::errors().len(), ui.query);
}
_ => return String::from("usage: config open|close|toggle|page <id>|search <words>|get <path>|set <path> <value>|reset <path>|status"),
}
String::from("ok")
}
fn go_to(page: &str) {
let mut ui = Ui::write();
ui.page = page.to_string();
ui.query.clear();
ui.message = None;
if page.starts_with("hyprland:") {
hypr::load();
}
}
// a typed value for a setting; Hyprland's are checked here when the list is known, then applied by hypr.rs
fn set_text(path: &str, text: &str) -> Result<(), String> {
if let Some(key) = path.strip_prefix("hyprland.") {
let kind = Hypr::read().options.iter().find(|option| option.key() == key).map(|option| option.kind);
if let Some(kind) = kind {
crate::parse::hypr_lua(kind, text).map_err(|why| format!("{key} {why}"))?;
}
hypr::set(key, text);
return Ok(());
}
let option = config::option(path).ok_or(format!("no setting {path}"))?;
config::from_text(option, text).and_then(|value| config::set(path, value))
}
fn say(result: Result<(), String>, done: String) {
Ui::write().message = Some(match result {
Ok(()) => (done, false),
Err(why) => (why, true),
});
}
pub fn view() -> Window {
theme::begin();
let colors = theme::now();
let ui = Ui::read();
let hypr = Hypr::read();
let (width, height) = match window_size() {
(0.0, _) | (_, 0.0) => (1100.0, 760.0),
size => size,
};
let main_w = (width - SIDEBAR_W).max(400.0);
let row_w = main_w - 2.0 * PAD - 12.0;
let query = ui.query.trim().to_lowercase();
// the rows to show: a search goes through every page
let mut sections: Vec<Box<dyn Widget>> = Vec::new();
let shell_page = !ui.page.starts_with("hyprland:");
let category = ui.page.strip_prefix("hyprland:").unwrap_or("");
let title = if !query.is_empty() {
format!("Search: {}", ui.query.trim())
} else if shell_page {
config::title(&ui.page)
} else {
format!("Hyprland · {}", config::title(category))
};
let mut notes: Vec<(String, bool)> = Vec::new();
if shell_page || !query.is_empty() {
notes.extend(config::errors().into_iter().map(|error| (format!("config.toml {error}"), true)));
}
if (!shell_page || !query.is_empty()) && hypr.loaded && !hypr.required && !config::overrides().is_empty() {
notes.push((String::from("Hyprland options set here apply now, but a Hyprland reload drops them: add require(\"amane\") as the last line of ~/.config/hypr/hyprland.lua"), false));
}
for note in notes {
sections.push(Box::new(banner(&note.0, note.1, row_w)));
}
// shell settings, by group
let mut group = "";
let mut page = "";
for option in config::OPTIONS {
let shown = if query.is_empty() { shell_page && option.module() == ui.page } else { option.path.contains(&query) || option.help.to_lowercase().contains(&query) };
if !shown {
continue;
}
if option.group() != group || option.module() != page {
let heading = if query.is_empty() { config::title(option.group()) } else { format!("{} › {}", config::title(option.module()), config::title(option.group())) };
sections.push(Box::new(heading_row(&heading, row_w)));
group = option.group();
page = option.module();
}
sections.push(Box::new(shell_row(option, row_w, &colors)));
}
// Hyprland's, by group
if !shell_page || !query.is_empty() {
if !hypr.loaded {
sections.push(Box::new(Text::new("Reading Hyprland's options…").size(config::appearance::text::body()).color(colors.dim)));
}
let mut group: Option<(&str, &str)> = None;
for option in &hypr.options {
let shown = if query.is_empty() { option.category() == category } else { option.name.contains(&query) || option.help.to_lowercase().contains(&query) };
if !shown {
continue;
}
let here = (option.category(), option.group());
if group != Some(here) {
let named = if option.group().is_empty() { String::new() } else { config::title(option.group()) };
let heading = match (query.is_empty(), named.is_empty()) {
(true, true) => String::from("General"),
(true, false) => named,
(false, true) => format!("Hyprland › {}", config::title(option.category())),
(false, false) => format!("Hyprland › {} › {named}", config::title(option.category())),
};
sections.push(Box::new(heading_row(&heading, row_w)));
group = Some(here);
}
sections.push(Box::new(hypr_row(option, row_w, &colors)));
}
}
if sections.is_empty() {
sections.push(Box::new(Text::new("Nothing matches.").size(config::appearance::text::body()).color(colors.dim)));
}
let message = if shell_page { ui.message.clone() } else { hypr.message.clone().or(ui.message.clone()) };
let (words, wrong) = message.unwrap_or_else(|| (format!("Saved to {} as you change things; Enter applies a typed value", config::file().display()), false));
let footer = Text::new(words).size(config::appearance::text::small()).color(if wrong { config::appearance::overlays::low_battery() } else { colors.dim }).elide();
let main = Rectangle::new()
.width(main_w)
.height(height)
.padding(Padding { top: PAD, right: PAD, bottom: 12.0, left: PAD })
.child(Column::new(children![
Text::new(title).size(config::appearance::text::title()).weight(Weight::Bold).color(colors.text),
ScrollArea::new("settings-rows", Column::new(sections).gap(6.0)).width(main_w - 2.0 * PAD).height(height - PAD - 12.0 - 40.0 - 24.0),
Rectangle::new().width(main_w - 2.0 * PAD).height(20.0).align_child(Start, Center).child(footer),
]).gap(10.0));
Window::new()
.title(TITLE)
.size(1100.0, 760.0)
.resizable(true)
.child(
Rectangle::new()
.width(width)
.height(height)
.fill(colors.surface_low)
.child(Row::new(children![sidebar(&ui, &hypr, height, &colors), main])),
)
}
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> Rectangle {
let mut entries: Vec<Box<dyn Widget>> = vec![Box::new(caption("SHELL", colors))];
for module in config::modules() {
entries.push(Box::new(entry(module.to_string(), config::title(module), ui, colors)));
}
entries.push(Box::new(caption("HYPRLAND", colors)));
let mut categories: Vec<&str> = Vec::new();
for option in &hypr.options {
if !categories.contains(&option.category()) {
categories.push(option.category());
}
}
if categories.is_empty() {
entries.push(Box::new(Text::new(if hypr.loaded { "Hyprland didn't answer" } else { "Reading…" }).size(config::appearance::text::small()).color(colors.dim)));
}
for category in categories {
entries.push(Box::new(entry(format!("hyprland:{category}"), config::title(category), ui, colors)));
}
synced(SEARCH, &ui.query);
let search = Rectangle::new()
.width(SIDEBAR_W - 24.0)
.height(FIELD_H)
.radius(10.0)
.fill(config::appearance::overlays::chip())
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Cursor::Text)
.child(TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| {
SYNCED.with_borrow_mut(|synced| synced.insert(SEARCH, text.clone()));
Ui::write().query = text;
}));
Rectangle::new()
.width(SIDEBAR_W)
.height(height)
.fill(colors.surface)
.padding(12.0)
.child(Column::new(children![search, ScrollArea::new("settings-pages", Column::new(entries).gap(2.0)).width(SIDEBAR_W - 24.0).height(height - 24.0 - FIELD_H - 12.0)]).gap(12.0))
}
fn caption(text: &str, colors: &Colors) -> Rectangle {
Rectangle::new()
.width(SIDEBAR_W - 24.0)
.height(30.0)
.padding(Padding { top: 12.0, right: 0.0, bottom: 0.0, left: 10.0 })
.child(Text::new(text).size(config::appearance::text::tiny()).weight(Weight::Bold).color(colors.dim))
}
fn entry(id: String, title: String, ui: &Ui, colors: &Colors) -> Rectangle {
let chosen = ui.query.is_empty() && ui.page == id;
let lit = ui.hover.as_deref() == Some(id.as_str());
let fill = if chosen { colors.accent } else if lit { config::appearance::overlays::chip_hover() } else { Color::TRANSPARENT };
let (hovered, clicked) = (id.clone(), id);
Rectangle::new()
.width(SIDEBAR_W - 24.0)
.height(30.0)
.radius(8.0)
.fill(fill)
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Cursor::Pointer)
.on_hover(move |inside| {
let mut ui = Ui::write();
if inside {
ui.hover = Some(hovered.clone());
} else if ui.hover.as_deref() == Some(hovered.as_str()) {
ui.hover = None;
}
})
.on_click(move |_| {
SYNCED.with_borrow_mut(|synced| synced.remove(SEARCH));
go_to(&clicked);
})
.child(Text::new(title).size(config::appearance::text::body()).color(if chosen { colors.on_accent } else { colors.text }).elide())
}
fn heading_row(text: &str, width: f32) -> Rectangle {
Rectangle::new()
.width(width)
.height(34.0)
.padding(Padding { top: 14.0, right: 0.0, bottom: 0.0, left: 4.0 })
.child(Text::new(text.to_uppercase()).size(config::appearance::text::small()).weight(Weight::Bold).color(theme::now().accent))
}
fn banner(text: &str, wrong: bool, width: f32) -> Rectangle {
let colors = theme::now();
let words = Text::new(text).size(config::appearance::text::small()).color(colors.text).wrap();
let height = words.height_in(width - 24.0) + 16.0;
Rectangle::new()
.width(width)
.height(height)
.radius(10.0)
.fill(if wrong { faded(config::appearance::overlays::low_battery(), 60) } else { config::appearance::overlays::chip() })
.padding(Padding { top: 8.0, right: 12.0, bottom: 8.0, left: 12.0 })
.child(words)
}
fn faded(color: Color, alpha: u8) -> Color {
Color::rgba(color.red(), color.green(), color.blue(), alpha)
}
/*
* one setting: its label and key, the help under them, the control on the right, and
* Reset while it differs from its default
*/
fn row(label: String, key: String, help: String, control: Box<dyn Widget>, reset: Option<Box<dyn Fn()>>, width: f32) -> Rectangle {
let colors = theme::now();
let text_w = width - CONTROL_W - RESET_W - 2.0 * 14.0 - 24.0;
let help = Text::new(help).size(config::appearance::text::small()).color(colors.dim).wrap();
let help_h = help.height_in(text_w);
let label_h = config::appearance::text::body() * 1.5;
let height = (label_h + 2.0 + help_h).max(FIELD_H) + 20.0;
let reset: Box<dyn Widget> = match reset {
Some(reset) => Box::new(
Rectangle::new()
.width(RESET_W)
.height(24.0)
.radius(Full)
.fill(config::appearance::overlays::chip())
.align_child(Center, Center)
.cursor(Cursor::Pointer)
.on_click(move |_| reset())
.child(Text::new("Reset").size(config::appearance::text::tiny()).color(colors.text)),
),
None => Box::new(Rectangle::new().width(RESET_W).height(24.0)),
};
Rectangle::new()
.width(width)
.height(height)
.radius(12.0)
.fill(config::appearance::overlays::chip())
.padding(Padding { top: 10.0, right: 14.0, bottom: 10.0, left: 14.0 })
.child(
Row::new(children![
Column::new(children![
Row::new(children![
Text::new(label).size(config::appearance::text::body()).weight(Weight::Bold).color(colors.text),
Text::new(key).size(config::appearance::text::tiny()).color(colors.dim).elide(),
])
.gap(10.0)
.align(Center)
.width(text_w)
.height(label_h),
Rectangle::new().width(text_w).height(help_h).child(help),
])
.gap(2.0),
Row::new(vec![control, reset]).gap(12.0).align(Center).justify(End).width(CONTROL_W + RESET_W + 12.0),
])
.justify(SpaceBetween)
.align(Center)
.width(width - 28.0),
)
}
fn shell_row(option: &'static Opt, width: f32, colors: &Colors) -> Rectangle {
let value = config::current(option);
let control: Box<dyn Widget> = match (option.kind, &value) {
(Kind::Bool, Value::Bool(on)) => {
let on = *on;
Box::new(switch(on, move || say(config::set(option.path, Value::Bool(!on)), format!("{} = {}", option.path, !on))))
}
(Kind::Int | Kind::Float, _) if option.range.is_some() => {
let (min, max) = option.range.unwrap_or((0.0, 1.0));
let whole = option.kind == Kind::Int;
let number = value.number().unwrap_or(min);
Box::new(
Row::new(children![
slider((number - min) / (max - min), move |fraction| {
let picked = stepped(min + fraction * (max - min), min, max, whole);
let value = if whole { Value::Int(picked as i64) } else { Value::Float(picked) };
say(config::set(option.path, value), format!("{} = {}", option.path, config::shown(&if whole { Value::Int(picked as i64) } else { Value::Float(picked) })));
}),
field(option.path, &config::shown(&value), NUMBER_W, None, move |text| say(set_text(option.path, &text), format!("{} = {text}", option.path))),
])
.gap(12.0)
.align(Center),
)
}
_ => {
let swatch = (option.kind == Kind::Color).then(|| value.text().and_then(config::color)).flatten();
Box::new(field(option.path, &config::shown(&value), CONTROL_W, swatch, move |text| say(set_text(option.path, &text), format!("{} = {text}", option.path))))
}
};
let reset: Option<Box<dyn Fn()>> = (!config::is_default(option)).then(|| Box::new(move || say(config::reset(option.path), format!("{} is back to its default", option.path))) as Box<dyn Fn()>);
let mut help = option.help.to_string();
if !config::is_default(option) {
help.push_str(&format!(" · default {}", config::shown(&(option.default)())));
}
let _ = colors;
row(config::label(option.name()), option.path.to_string(), help, control, reset, width)
}
fn hypr_row(option: &HyprOption, width: f32, colors: &Colors) -> Rectangle {
let name = option.name;
let path = option.path();
let overridden = config::override_of(&path).is_some();
let control: Box<dyn Widget> = match option.kind {
HyprKind::Bool => {
let on = option.current == "true";
Box::new(switch(on, move || hypr::set(name, if on { "false" } else { "true" })))
}
HyprKind::Int if !option.choices.is_empty() => Box::new(choices(option, colors)),
HyprKind::Int | HyprKind::Float if option.range.is_some() => {
let (min, max) = option.range.unwrap_or((0.0, 1.0));
let whole = option.kind == HyprKind::Int;
let number: f64 = option.current.parse().unwrap_or(min);
Box::new(
Row::new(children![
slider((number - min) / (max - min), move |fraction| {
let picked = stepped(min + fraction * (max - min), min, max, whole);
hypr::set(name, &if whole { (picked as i64).to_string() } else { picked.to_string() });
}),
field(name, &option.current, NUMBER_W, None, move |text| hypr::set(name, &text)),
])
.gap(12.0)
.align(Center),
)
}
_ => {
let swatch = (option.kind == HyprKind::Gradient).then(|| first_color(&option.current)).flatten();
Box::new(field(name, &option.current, CONTROL_W, swatch, move |text| hypr::set(name, &text)))
}
};
let reset: Option<Box<dyn Fn()>> = overridden.then(|| Box::new(move || hypr::reset(name)) as Box<dyn Fn()>);
let mut help = option.help.clone();
if !option.default.is_empty() {
help.push_str(&format!(" · default {}", option.default));
}
if overridden {
help.push_str(" · set here");
} else if option.in_config {
help.push_str(" · set in your Hyprland config");
}
let label = option.label().replace("col.", "colour_").replace('.', "_");
row(config::label(&label), path, help, control, reset, width)
}
// "rgba(80d9ffff) ..." as its first colour, for the swatch
fn first_color(gradient: &str) -> Option<Color> {
let word = gradient.split_whitespace().next()?;
config::color(word.trim_start_matches("rgba(").trim_end_matches(')'))
}
// a value from a slider: whole numbers, or steps of about a hundredth of the range
fn stepped(value: f64, min: f64, max: f64, whole: bool) -> f64 {
let value = value.clamp(min, max);
if whole {
return value.round();
}
let step = 10f64.powf(((max - min).log10()).floor() - 2.0);
((value / step).round() * step * 1e6).round() / 1e6
}
fn switch(on: bool, toggle: impl Fn() + 'static) -> Rectangle {
let colors = theme::now();
Rectangle::new()
.width(46.0)
.height(26.0)
.radius(Full)
.fill(if on { colors.accent } else { config::appearance::overlays::track() })
.padding(4.0)
.align_child(Start, Center)
.cursor(Cursor::Pointer)
.on_click(move |_| toggle())
.child(Rectangle::new().width(18.0).height(18.0).radius(Full).fill(if on { colors.on_accent } else { colors.dim }).translate(if on { 20.0 } else { 0.0 }, 0.0))
}
fn slider(fraction: f64, pick: impl Fn(f64) + Clone + 'static) -> Rectangle {
let colors = theme::now();
let fraction = fraction.clamp(0.0, 1.0) as f32;
let knob = 16.0;
let pick_drag = pick.clone();
Rectangle::new()
.width(SLIDER_W)
.height(FIELD_H)
.align_child(Start, Center)
.cursor(Cursor::Pointer)
.on_click(|_| {})
.on_drag(move |point| pick_drag(f64::from(((point.x - knob / 2.0) / (SLIDER_W - knob)).clamp(0.0, 1.0))))
.on_scroll(move |scroll| pick(f64::from((fraction + if scroll.y < 0.0 { 0.01 } else { -0.01 }).clamp(0.0, 1.0))))
.child(Stack::new(children![
Rectangle::new().width(SLIDER_W).height(6.0).radius(Full).fill(config::appearance::overlays::track()).translate(0.0, (knob - 6.0) / 2.0),
Rectangle::new().width(knob / 2.0 + fraction * (SLIDER_W - knob)).height(6.0).radius(Full).fill(colors.accent).translate(0.0, (knob - 6.0) / 2.0),
Rectangle::new().width(knob).height(knob).radius(Full).fill(colors.flame).translate(fraction * (SLIDER_W - knob), 0.0),
]).width(SLIDER_W).height(knob))
}
// a Hyprland number with named values: one chip per name
fn choices(option: &HyprOption, colors: &Colors) -> Row {
let name = option.name;
let chips: Vec<Box<dyn Widget>> = option
.choices
.iter()
.map(|(label, value)| {
let chosen = option.current == value.to_string();
let value = value.to_string();
let letters = label.chars().count() as f32;
Box::new(
Rectangle::new()
.width(letters * config::appearance::text::small() * config::appearance::font::letter_width() + 18.0)
.height(26.0)
.radius(Full)
.fill(if chosen { colors.accent } else { config::appearance::overlays::chip() })
.align_child(Center, Center)
.cursor(Cursor::Pointer)
.on_click(move |_| hypr::set(name, &value))
.child(Text::new(label.replace('_', " ")).size(config::appearance::text::small()).color(if chosen { colors.on_accent } else { colors.text })),
) as Box<dyn Widget>
})
.collect();
Row::new(chips).gap(6.0).align(Center).justify(End).width(CONTROL_W)
}
// the text inputs' texts as last set from their values, so typing isn't overwritten until a value changes
thread_local! {
static SYNCED: RefCell<HashMap<&'static str, String>> = RefCell::new(HashMap::new());
}
fn synced(id: &'static str, text: &str) {
SYNCED.with_borrow_mut(|synced| {
if synced.get(id).map(String::as_str) != Some(text) {
TextInput::set_text(id, text);
synced.insert(id, text.to_string());
}
});
}
fn field(id: &'static str, text: &str, width: f32, swatch: Option<Color>, submit: impl Fn(String) + 'static) -> Row {
let colors = theme::now();
synced(id, text);
let swatch_w = if swatch.is_some() { FIELD_H + 8.0 } else { 0.0 };
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
if let Some(color) = swatch {
parts.push(Box::new(Rectangle::new().width(FIELD_H).height(FIELD_H).radius(8.0).fill(color).border(1.0, colors.outline)));
}
parts.push(Box::new(
Rectangle::new()
.width(width - swatch_w)
.height(FIELD_H)
.radius(8.0)
.fill(config::appearance::overlays::chip())
.border(1.0, colors.outline)
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Cursor::Text)
.child(TextInput::new(id).size(config::appearance::text::body()).color(colors.text).on_submit(submit)),
));
Row::new(parts).gap(8.0).align(Center)
}
+21 -21
View File
@@ -7,23 +7,23 @@ use std::{fs, thread};
use amane::{Argument, Bus, Media, Service};
use crate::art;
use crate::config::sound::{equalizer, presets as gains, timing};
use crate::parse::{self, EQ_PREFIX, Sink, Stream};
use crate::theme;
// how far a band can lift or cut, in dB
pub const MAX_GAIN: f32 = 12.0;
// gains for BANDS (31 Hz .. 16 kHz)
pub const EQ_PRESETS: [(&str, [f32; 10]); 8] = [
("Flat", [0.0; 10]),
("Bass", [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
("Treble", [-2.0, -1.5, -1.0, 0.0, 1.0, 2.0, 3.0, 4.5, 5.5, 6.0]),
("Vocal", [-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.5, 2.0, 0.0, -1.0]),
("Pop", [-1.0, 1.0, 2.5, 3.5, 2.5, 0.5, -0.5, -0.5, 1.0, 2.0]),
("Rock", [5.0, 4.0, 2.5, -0.5, -1.5, -0.5, 2.0, 3.5, 4.5, 5.0]),
("Jazz", [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.5, 2.5, 3.0]),
("Classic", [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0]),
];
// the presets with their gains for the 10 bands, low to high (config.rs: sound.presets.*)
pub fn presets() -> [(&'static str, [f32; 10]); 8] {
[
("Flat", gains::flat()),
("Bass", gains::bass()),
("Treble", gains::treble()),
("Vocal", gains::vocal()),
("Pop", gains::pop()),
("Rock", gains::rock()),
("Jazz", gains::jazz()),
("Classic", gains::classic()),
]
}
#[derive(Clone, PartialEq)]
pub struct Eq {
@@ -101,7 +101,7 @@ impl Service for Sound {
poll(&mut known);
save();
thread::sleep(Duration::from_secs(1));
thread::sleep(Duration::from_millis(timing::poll_ms()));
}
}
}
@@ -185,7 +185,7 @@ impl Sound {
}
fn hold(&mut self) {
self.held_until = Some(Instant::now() + Duration::from_millis(2500));
self.held_until = Some(Instant::now() + Duration::from_millis(timing::hold_ms()));
}
// from input handlers: sets and unmutes every stream of one player
@@ -261,7 +261,7 @@ impl Sound {
// from a dragged equalizer band; half-dB steps keep pw-cli calls few
pub fn set_band(player: &str, identity: &str, band: usize, gain: f32) {
let gain = ((gain * 2.0).round() / 2.0).clamp(-MAX_GAIN, MAX_GAIN);
let gain = ((gain * 2.0).round() / 2.0).clamp(-equalizer::max_gain(), equalizer::max_gain());
let gains = {
let mut sound = Self::write();
@@ -287,18 +287,18 @@ impl Sound {
// false when there is no preset by that name
pub fn preset(player: &str, identity: &str, name: &str) -> bool {
let Some((preset, gains)) = EQ_PRESETS.iter().find(|(preset, _)| preset.eq_ignore_ascii_case(name)) else {
let Some((preset, gains)) = presets().into_iter().find(|(preset, _)| preset.eq_ignore_ascii_case(name)) else {
return false;
};
{
let mut sound = Self::write();
sound.eqs.insert(identity.to_string(), Eq { preset: preset.to_string(), gains: *gains });
sound.eqs.insert(identity.to_string(), Eq { preset: preset.to_string(), gains });
sound.unsaved = true;
}
apply(player, identity, gains);
apply(player, identity, &gains);
true
}
@@ -445,7 +445,7 @@ fn start_eq(key: &str, identity: &str, gains: &[f32; 10], target: &str) {
let config = eq_file(key);
if fs::write(&config, parse::eq_config(key, identity, gains, target)).is_ok() {
if fs::write(&config, parse::eq_config(key, identity, &equalizer::bands(), gains, target)).is_ok() {
run(&["setsid", "-f", "pipewire", "-c", &config.display().to_string()]);
}
}
+6 -5
View File
@@ -9,10 +9,10 @@ use amane::{Media, Service};
use crate::art;
use crate::island::{self, Island};
use crate::parse;
use crate::config::sound::spectrum as settings;
use crate::sound::Sound;
// cava's bars, low to high frequencies; the ring mirrors them
pub const BARS: usize = 24;
// cava's bars (sound.spectrum.bars), low to high frequencies; the ring mirrors them
/*
* the shown player's own sound as bars: `pw-record --target <its stream>`
@@ -58,17 +58,18 @@ impl Service for Spectrum {
running = wanted.and_then(|stream| Capture::start(stream, &config));
}
thread::sleep(Duration::from_millis(200));
thread::sleep(Duration::from_millis(settings::check_ms()));
}
}
}
fn cava_config() -> String {
format!(
"[general]\nframerate = 60\nbars = {BARS}\n\
"[general]\nframerate = 60\nbars = {}\n\
[input]\nmethod = fifo\nsource = /dev/stdin\nsample_rate = 44100\nsample_bits = 16\n\
[output]\nmethod = raw\nraw_target = /dev/stdout\ndata_format = ascii\nascii_max_range = 100\nchannels = mono\n\
[smoothing]\nnoise_reduction = 70\n"
[smoothing]\nnoise_reduction = 70\n",
settings::bars()
)
}
+9 -9
View File
@@ -16,7 +16,7 @@ use amane::{Argument, Bluetooth, Bus, Service, Value};
use crate::control::Control;
use crate::parse;
use crate::theme::Theme;
use crate::config::control::{polling, services};
const IWD: &str = "net.connman.iwd";
const DEVICE: &str = "net.connman.iwd.Device";
@@ -129,7 +129,7 @@ impl Service for Links {
loop {
refresh();
thread::sleep(Duration::from_secs(2));
thread::sleep(Duration::from_millis(polling::links_ms()));
}
}
}
@@ -188,7 +188,7 @@ fn wifi_rate(bus: &Bus, device: &str, network: &str) -> Option<u32> {
if let Some((asked, on, rate)) = last.as_ref()
&& on == network
&& asked.elapsed() < Duration::from_secs(60)
&& asked.elapsed() < Duration::from_secs(polling::wifi_rate_s())
{
return *rate;
}
@@ -360,11 +360,11 @@ pub fn toggle_wifi() {
/*
* one radio: while the device is an access point it isn't a wi-fi client. the hotspot
* starts from iwd's profile /var/lib/iwd/ap/<name>.ap, named by `hotspot` in shell.conf
* starts from iwd's profile /var/lib/iwd/ap/<name>.ap, named by control.services.hotspot
*/
pub fn toggle_hotspot() {
let wifi = Links::read().wifi.clone();
let name = Theme::read().hotspot.clone();
let name = services::hotspot();
if !wifi.service {
root(&["rc-service", "iwd", "start"]);
@@ -634,7 +634,7 @@ impl Service for Power {
loop {
refresh_power();
thread::sleep(Duration::from_secs(2));
thread::sleep(Duration::from_millis(polling::power_ms()));
}
}
}
@@ -676,7 +676,7 @@ fn charge() -> Option<Charge> {
let mut estimate = ESTIMATE.lock().unwrap_or_else(PoisonError::into_inner);
let minutes = match &*estimate {
Some((at, was, minutes)) if *was == status && at.elapsed() < Duration::from_secs(30) => *minutes,
Some((at, was, minutes)) if *was == status && at.elapsed() < Duration::from_secs(polling::battery_estimate_s()) => *minutes,
_ => {
let minutes = parse::battery_minutes(&status, now, full, rate);
@@ -807,7 +807,7 @@ impl Service for Gpu {
loop {
if !Control::read().expanded() {
thread::sleep(Duration::from_millis(400));
thread::sleep(Duration::from_millis(polling::gpu_wait_ms()));
continue;
}
@@ -820,7 +820,7 @@ impl Service for Gpu {
*Self::write() = gpu;
}
thread::sleep(Duration::from_secs(2));
thread::sleep(Duration::from_millis(polling::gpu_ms()));
}
}
}
+53 -55
View File
@@ -6,13 +6,14 @@ use std::{env, fs, thread};
use amane::{Animation, Blend, Color, Service};
use crate::parse;
use crate::{config, parse};
/*
* the shell's colours. they come from the wallpaper: skwd-wall runs matugen with
* matugen/amane.colors, which writes ~/.config/amane/theme. ~/.config/amane/shell.conf
* says whether the open media card takes the song cover's colours instead, and how long
* a change cross-fades. without a theme file the shell keeps its original pink on purple
* matugen/amane.colors, which writes ~/.config/amane/theme. config.toml (config.rs) says
* whether the open media card takes the song cover's colours instead, how long a change
* cross-fades, and the colours used until there is a theme file. Theme also stands for
* config.toml: every view reads it, so a saved setting redraws the whole shell
*/
#[derive(Clone, Copy, PartialEq)]
pub struct Colors {
@@ -28,18 +29,23 @@ pub struct Colors {
pub surface_low: Color,
}
const PINK_ON_PURPLE: Colors = Colors {
accent: Color::rgb(247, 168, 200),
on_accent: Color::rgb(40, 18, 32),
accent_soft: Color::rgb(150, 82, 122),
flame: Color::rgb(255, 228, 240),
text: Color::rgb(241, 236, 246),
dim: Color::rgba(205, 192, 218, 160),
outline: Color::rgba(255, 255, 255, 26),
surface: Color::rgb(24, 18, 30),
surface_high: Color::rgb(35, 26, 44),
surface_low: Color::rgb(13, 10, 18),
};
// the colours without a theme file, from config.toml
fn fallback() -> Colors {
use config::appearance::fallback as colors;
Colors {
accent: colors::accent(),
on_accent: colors::on_accent(),
accent_soft: colors::accent_soft(),
flame: colors::flame(),
text: colors::text(),
dim: colors::dim(),
outline: colors::outline(),
surface: colors::surface(),
surface_high: colors::surface_high(),
surface_low: colors::surface_low(),
}
}
impl Colors {
// the background gradient's three stops, light to dark
@@ -70,42 +76,33 @@ impl Blend for Colors {
pub struct Theme {
colors: Animation<Colors>,
// the open media card and the equalizer take the cover's colours as their background
pub cover_colors: bool,
// the iwd access point profile the control centre's hotspot starts (/var/lib/iwd/ap/<name>.ap); empty: none
pub hotspot: String,
// a second Sound slider in the control centre, for the output whose name contains this; empty: none
pub extra_output: String,
}
impl Service for Theme {
fn new() -> Self {
Self {
colors: Animation::new(PINK_ON_PURPLE),
cover_colors: false,
hotspot: String::new(),
extra_output: String::new(),
}
Self { colors: Animation::new(fallback()) }
}
/*
* waits on the kernel instead of polling: amane::watch_file watches the folder, so
* matugen writing a new file and renaming it over the old one is seen too.
* shell.conf on its own thread, the theme here; either change re-reads both
* config.toml on its own thread, the theme here; either change re-reads both
*/
fn listen() {
reload();
thread::spawn(|| follow("shell.conf"));
thread::spawn(|| follow("config.toml"));
follow("theme");
}
}
// what was read last, (theme, shell.conf); none until the first read
// redraws every view, for a setting changed in the config window
pub fn touch() {
let _ = Theme::write();
}
// what was read last, (theme, config.toml); none until the first read
static LAST: Mutex<Option<(String, String)>> = Mutex::new(None);
fn follow(name: &str) {
@@ -119,7 +116,7 @@ fn follow(name: &str) {
// a deleted theme file sends no event: the defaults come back at the next start
fn reload() {
let read = (read("theme"), read("shell.conf"));
let read = (read("theme"), read("config.toml"));
let mut last = LAST.lock().unwrap_or_else(PoisonError::into_inner);
@@ -128,8 +125,12 @@ fn reload() {
return;
}
if last.as_ref().is_none_or(|(_, config)| *config != read.1) {
config::apply_text(&read.1);
}
// the colours at startup show at once, later changes cross-fade
apply(&read.0, &read.1, last.is_none());
apply(&read.0, last.is_none());
*last = Some(read);
}
@@ -138,18 +139,21 @@ fn read(name: &str) -> String {
fs::read_to_string(config_file(name)).unwrap_or_default()
}
// ~/.config/amane/<name>, or under $XDG_CONFIG_HOME
pub fn config_file(name: &str) -> PathBuf {
let config = env::var_os("XDG_CONFIG_HOME")
// $XDG_CONFIG_HOME, or ~/.config
pub fn config_home() -> PathBuf {
env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.unwrap_or_default();
config.join("amane").join(name)
.unwrap_or_default()
}
fn apply(theme: &str, config: &str, startup: bool) {
let mut colors = PINK_ON_PURPLE;
// ~/.config/amane/<name>, or under $XDG_CONFIG_HOME
pub fn config_file(name: &str) -> PathBuf {
config_home().join("amane").join(name)
}
fn apply(theme: &str, startup: bool) {
let mut colors = fallback();
// a missing or broken line keeps that colour's default
for (key, value) in parse::settings(theme) {
@@ -174,17 +178,10 @@ fn apply(theme: &str, config: &str, startup: bool) {
}
}
let settings = parse::settings(config);
let setting = |name: &str| settings.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str());
let fade = if startup { 0 } else { setting("transition_ms").and_then(|ms| ms.parse().ok()).unwrap_or(400) };
let fade = if startup { 0 } else { config::appearance::colors::transition_ms() };
let mut theme = Theme::write();
theme.cover_colors = setting("cover_colors") == Some("true");
theme.hotspot = setting("hotspot").unwrap_or("").to_string();
theme.extra_output = setting("extra_output").unwrap_or("").to_string();
// a change in the middle of a fade starts from the colours on screen now
let now = theme.colors.value();
@@ -194,19 +191,20 @@ fn apply(theme: &str, config: &str, startup: bool) {
// the colours of the frame being drawn, set by the view that draws it
thread_local! {
static NOW: Cell<Colors> = const { Cell::new(PINK_ON_PURPLE) };
static NOW: Cell<Colors> = Cell::new(fallback());
}
/*
* a view's first line: everything it draws this frame uses these colours (and
* reading Theme redraws it when they change). says whether the media card takes the cover's
* reading Theme redraws it when they or config.toml change). says whether the media
* card takes the cover's
*/
pub fn begin() -> bool {
let theme = Theme::read();
NOW.set(theme.colors.value());
theme.cover_colors
config::appearance::colors::cover_colors()
}
pub fn now() -> Colors {
+43 -51
View File
@@ -20,8 +20,11 @@ use amane::{
Start, Text, Value, Vertical, Weight, Widget, Zone,
};
use crate::config::appearance::{motion, overlays, text};
use crate::config::island::pill as island_pill;
use crate::config::tray::{menu, pill};
use crate::glyph::{Glyph, icon};
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded, veiled};
use crate::island::{CLEAR, backdrop, faded, veiled};
use crate::{edge, parse, png, theme};
const WATCHER: &str = "org.kde.StatusNotifierWatcher";
@@ -30,18 +33,7 @@ const ITEM: &str = "org.kde.StatusNotifierItem";
const MENU: &str = "com.canonical.dbusmenu";
const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
// the tray's pill: a slot per icon, like the workspaces' slots
pub const SLOT: f32 = 28.0;
const SLOT_GAP: f32 = 4.0;
const INSET: f32 = 5.0;
const ICON: f32 = 18.0;
// the menu
const MENU_W: f32 = 260.0;
const MENU_PAD: f32 = 6.0;
const ROW_H: f32 = 30.0;
const SEPARATOR_H: f32 = 9.0;
const MAX_ROWS: usize = 20;
// the tray's pill (a slot per icon, like the workspaces' slots) and its menu are settings (config.rs: tray.*)
const INTROSPECTION: &str = r#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
@@ -504,7 +496,7 @@ fn hovering(spot: String) -> impl Fn(bool) + 'static {
TrayUi::write()
.hover
.entry(spot.clone())
.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 });
}
}
@@ -570,7 +562,7 @@ fn open_menu(item: &Item, monitor: &str, x: f32) {
let mut ui = TrayUi::write();
ui.open = Some(Open { item, monitor, x, trail: Vec::new(), entries });
ui.appear = Animation::new(0.0).duration(Duration::from_millis(180));
ui.appear = Animation::new(0.0).duration(Duration::from_millis(menu::appear_ms()));
ui.appear.to(1.0);
ui.moves += 1;
ui.hover.retain(|spot, _| !spot.starts_with("row:"));
@@ -633,7 +625,7 @@ fn leave_soon() {
};
thread::spawn(move || {
thread::sleep(Duration::from_millis(400));
thread::sleep(Duration::from_millis(menu::close_ms()));
let gone = {
let ui = TrayUi::read();
@@ -663,7 +655,7 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
let theme = theme::now();
let count = shown.len() as f32;
let width = 2.0 * INSET + count * SLOT + (count - 1.0) * SLOT_GAP;
let width = 2.0 * pill::inset() + count * pill::slot() + (count - 1.0) * pill::slot_gap();
let mut slots: Vec<Box<dyn Widget>> = Vec::new();
@@ -673,20 +665,20 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
let open = ui.open.as_ref().is_some_and(|open| open.item.key == item.key && open.monitor == monitor);
let picture = match &item.icon {
Some(path) => Rectangle::new().width(ICON).height(ICON).fill(Image::contain(path.clone())),
Some(path) => Rectangle::new().width(pill::icon()).height(pill::icon()).fill(Image::contain(path.clone())),
// no icon to find: its first letter
None => Rectangle::new()
.width(ICON)
.height(ICON)
.width(pill::icon())
.height(pill::icon())
.align_child(Center, Center)
.child(Text::new(item.title.chars().next().map_or(String::from("?"), |letter| letter.to_uppercase().collect())).size(12.0).weight(Weight::Bold).color(theme.text)),
.child(Text::new(item.title.chars().next().map_or(String::from("?"), |letter| letter.to_uppercase().collect())).size(text::body()).weight(Weight::Bold).color(theme.text)),
};
let slot = Rectangle::new()
.width(SLOT)
.height(SLOT)
.width(pill::slot())
.height(pill::slot())
.radius(Full)
.fill(if open { faded(theme.accent, 90) } else { Color::blend(CLEAR, CHIP_HOVER, lit) })
.fill(if open { faded(theme.accent, 90) } else { Color::blend(CLEAR, overlays::chip_hover(), lit) })
.scale(1.0 + 0.08 * lit)
.align_child(Center, Center)
.cursor(Pointer)
@@ -694,7 +686,7 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
let slot = if item.attention { slot.border(1.5, theme.accent) } else { slot };
let x = left + INSET + index as f32 * (SLOT + SLOT_GAP);
let x = left + pill::inset() + index as f32 * (pill::slot() + pill::slot_gap());
let (clicked, scrolled, monitor) = ((*item).clone(), (*item).clone(), monitor.to_string());
let slot = slot
@@ -712,15 +704,15 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
let pill = Rectangle::new()
.width(width)
.height(COMPACT_H)
.height(island_pill::height())
.radius(Full)
.fill(backdrop(&theme.surfaces(), 255))
.border(1.0, theme.outline)
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(INSET)
.padding(pill::inset())
.align_child(Start, Center)
.on_hover(edge::over_bar)
.child(Row::new(slots).gap(SLOT_GAP).align(Center));
.child(Row::new(slots).gap(pill::slot_gap()).align(Center));
Some((pill, width))
}
@@ -749,10 +741,10 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
let theme = theme::now();
let colors = theme.surfaces();
let inner_w = MENU_W - 2.0 * MENU_PAD;
let inner_w = menu::width() - 2.0 * menu::pad();
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
let mut height = 2.0 * MENU_PAD;
let mut height = 2.0 * menu::pad();
// the app's name, or the submenu's with the way back
let lit_back = ui.hover.get("row:back").map_or(0.0, Animation::value);
@@ -761,26 +753,26 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
Some((_, label)) => Box::new(
Rectangle::new()
.width(inner_w)
.height(ROW_H)
.height(menu::row_height())
.radius(9.0)
.fill(Color::blend(CLEAR, CHIP_HOVER, lit_back))
.fill(Color::blend(CLEAR, overlays::chip_hover(), lit_back))
.padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 6.0 })
.align_child(Start, Center)
.cursor(Pointer)
.on_hover(hovering(String::from("row:back")))
.on_click(|_| enter(None))
.child(Row::new(vec![Box::new(icon(Glyph::Back, 12.0, theme.dim)) as Box<dyn Widget>, Box::new(Text::new(label.as_str()).size(12.0).weight(Weight::Bold).color(theme.text).elide())]).gap(8.0).align(Center)),
.child(Row::new(vec![Box::new(icon(Glyph::Back, 12.0, theme.dim)) as Box<dyn Widget>, Box::new(Text::new(label.as_str()).size(text::body()).weight(Weight::Bold).color(theme.text).elide())]).gap(8.0).align(Center)),
),
None => Box::new(Rectangle::new().width(inner_w).height(ROW_H).padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 10.0 }).align_child(Start, Center).child(Text::new(open.item.title.as_str()).size(11.0).weight(Weight::Bold).color(theme.dim).elide())),
None => Box::new(Rectangle::new().width(inner_w).height(menu::row_height()).padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 10.0 }).align_child(Start, Center).child(Text::new(open.item.title.as_str()).size(text::small()).weight(Weight::Bold).color(theme.dim).elide())),
};
rows.push(heading);
height += ROW_H;
height += menu::row_height();
for entry in open.entries.iter().take(MAX_ROWS) {
for entry in open.entries.iter().take(menu::max_rows()) {
if entry.separator {
rows.push(Box::new(Rectangle::new().width(inner_w).height(SEPARATOR_H).align_child(Center, Center).child(Rectangle::new().width(inner_w - 16.0).height(1.0).fill(faded(theme.outline, 160)))));
height += SEPARATOR_H;
rows.push(Box::new(Rectangle::new().width(inner_w).height(menu::separator_height()).align_child(Center, Center).child(Rectangle::new().width(inner_w - 16.0).height(1.0).fill(faded(theme.outline, 160)))));
height += menu::separator_height();
continue;
}
@@ -795,18 +787,18 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
_ => Box::new(Rectangle::new().width(14.0).height(14.0)),
};
let more: Box<dyn Widget> = Box::new(Text::new(if entry.submenu { "›" } else { "" }).size(14.0).color(theme.dim));
let more: Box<dyn Widget> = Box::new(Text::new(if entry.submenu { "›" } else { "" }).size(text::heading()).color(theme.dim));
let row = Rectangle::new()
.width(inner_w)
.height(ROW_H)
.height(menu::row_height())
.radius(9.0)
.fill(Color::blend(CLEAR, CHIP_HOVER, lit))
.fill(Color::blend(CLEAR, overlays::chip_hover(), lit))
.padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 6.0 })
.align_child(Start, Center)
.child(
Row::new(vec![
Box::new(Row::new(vec![mark, Box::new(Text::new(entry.label.as_str()).size(12.0).color(ink).elide())]).gap(8.0).align(Center)) as Box<dyn Widget>,
Box::new(Row::new(vec![mark, Box::new(Text::new(entry.label.as_str()).size(text::body()).color(ink).elide())]).gap(8.0).align(Center)) as Box<dyn Widget>,
more,
])
.justify(SpaceBetween)
@@ -823,15 +815,15 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
};
rows.push(Box::new(row));
height += ROW_H;
height += menu::row_height();
}
let appear = ui.appear.value();
let content = Rectangle::new().width(MENU_W).height(height).padding(MENU_PAD).child(Column::new(rows));
let content = Rectangle::new().width(menu::width()).height(height).padding(menu::pad()).child(Column::new(rows));
let card = Rectangle::new()
.width(MENU_W)
.width(menu::width())
.height(height)
.radius(16.0)
.fill(backdrop(&colors, 255))
@@ -848,17 +840,17 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
leave_soon();
}
})
.child(veiled(content, MENU_W, height, 16.0, appear, &colors));
.child(veiled(content, menu::width(), height, 16.0, appear, &colors));
let left = (open.x - 8.0 - SHADOW).max(0.0);
let top = TOP_GAP + COMPACT_H + 6.0 - SHADOW;
let left = (open.x - 8.0 - island_pill::shadow()).max(0.0);
let top = island_pill::top_gap() + island_pill::height() + 6.0 - island_pill::shadow();
let region = InputArea { x: SHADOW as i32, y: SHADOW as i32, width: MENU_W as i32, height: height.ceil() as i32 };
let region = InputArea { x: island_pill::shadow() as i32, y: island_pill::shadow() as i32, width: menu::width() as i32, height: height.ceil() as i32 };
window(MENU_W + 2.0 * SHADOW, height + 2.0 * SHADOW)
window(menu::width() + 2.0 * island_pill::shadow(), height + 2.0 * island_pill::shadow())
.margin(Margin { top: top.max(0.0) as i32, right: 0, bottom: 0, left: left as i32 })
.input_region(vec![region])
.child(Rectangle::new().width(Parent).height(Parent).padding(SHADOW).align_child(Start, Start).child(card))
.child(Rectangle::new().width(Parent).height(Parent).padding(island_pill::shadow()).align_child(Start, Start).child(card))
}
// for `amane ipc call tray ...`: list the items; open an item's menu (as a right click), choose or enter an entry, go back, close
+31 -34
View File
@@ -8,18 +8,15 @@ use amane::{
Rectangle, Row, Service, Shadow, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
};
use crate::edge::{self, Edge, HIDE_H, Part};
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded, mix};
use crate::config::appearance::{motion, overlays, text};
use crate::config::island::pill as island_pill;
use crate::config::workspaces::pill;
use crate::edge::{self, Edge, Part};
use crate::island::{CLEAR, backdrop, faded, mix};
use crate::{theme, tray};
// one round slot per workspace, inside the pill's inset; the pill sits LEFT px from the monitor's edge
const SLOT: f32 = 28.0;
const SLOT_GAP: f32 = 4.0;
const INSET: f32 = 5.0;
const LEFT: f32 = 12.0;
// between the workspaces' pill and the tray's
const TRAY_GAP: f32 = 8.0;
// one round slot per workspace, inside the pill's inset, the pill pill::left() px from the monitor's edge,
// the tray pill::tray_gap() after it (config.rs: workspaces.*)
// each hovered workspace's highlight by workspace id, fading between 0 and 1
#[derive(Default)]
@@ -51,7 +48,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let edge = Edge::read();
let shown = edge.shown(&monitor.name);
(edge::hidden_by(edge.part(Part::Workspaces, &monitor.name), 0.0, HIDE_H), edge::hidden_by(shown, 0.0, HIDE_H), edge.switched(&monitor.name), shown == 0.0)
(edge::hidden_by(edge.part(Part::Workspaces, &monitor.name), 0.0, edge::hide_h()), edge::hidden_by(shown, 0.0, edge::hide_h()), edge.switched(&monitor.name), shown == 0.0)
};
// special workspaces (scratchpads) aren't places to switch to
@@ -63,12 +60,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.collect();
let count = here.len() as f32;
let width = 2.0 * INSET + count * SLOT + (count - 1.0).max(0.0) * SLOT_GAP;
let width = 2.0 * pill::inset() + count * pill::slot() + (count - 1.0).max(0.0) * pill::slot_gap();
let window = |window_w: f32| {
LayerWindow::new()
.width(window_w)
.height(TOP_GAP + COMPACT_H + SHADOW)
.height(island_pill::top_gap() + island_pill::height() + island_pill::shadow())
.anchor_vertical(Vertical::Top)
.anchor_horizontal(Horizontal::Left)
// Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them
@@ -78,15 +75,15 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
};
// empty and click-through rather than hidden: see the island's view for why
if here.is_empty() || slide <= -HIDE_H {
return window(LEFT + width + SHADOW).click_through().child(Rectangle::new().width(0.0).height(0.0));
if here.is_empty() || slide <= -edge::hide_h() {
return window(pill::left() + width + island_pill::shadow()).click_through().child(Rectangle::new().width(0.0).height(0.0));
}
// the tray's pill right of this one (tray.rs), when an app has an icon there; a peek at a workspace change shows the workspaces alone
let tray = if peeking { None } else { tray::pill(&monitor.name, LEFT + width + TRAY_GAP) };
let tray_w = tray.as_ref().map_or(0.0, |(_, tray_w)| TRAY_GAP + tray_w);
let tray = if peeking { None } else { tray::pill(&monitor.name, pill::left() + width + pill::tray_gap()) };
let tray_w = tray.as_ref().map_or(0.0, |(_, tray_w)| pill::tray_gap() + tray_w);
let window = window(LEFT + width + tray_w + SHADOW);
let window = window(pill::left() + width + tray_w + island_pill::shadow());
let mut slots: Vec<Box<dyn Widget>> = Vec::new();
@@ -95,7 +92,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
* the workspace before to this one (edge.rs times it); the slots themselves stay clear
*/
let theme = theme::now();
let slot_x = |index: usize| index as f32 * (SLOT + SLOT_GAP);
let slot_x = |index: usize| index as f32 * (pill::slot() + pill::slot_gap());
let focused_at = here.iter().position(|workspace| workspace.focused());
let came_from = switched.and_then(|(from, t)| here.iter().position(|workspace| workspace.id() == from).map(|at| (at, t)));
@@ -137,13 +134,13 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
} else {
let resting = if workspace.windows() > 0 { theme.text } else { theme.dim };
(Color::blend(CLEAR, CHIP_HOVER, lit), Color::blend(resting, theme.text, lit), Weight::Regular)
(Color::blend(CLEAR, overlays::chip_hover(), lit), Color::blend(resting, theme.text, lit), Weight::Regular)
};
slots.push(Box::new(
Rectangle::new()
.width(SLOT)
.height(SLOT)
.width(pill::slot())
.height(pill::slot())
.radius(Full)
.fill(fill)
.align_child(Center, Center)
@@ -151,41 +148,41 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.cursor(Pointer)
.on_hover(move |inside| hovering(id, inside))
.on_click(move |_| focus(id))
.child(Text::new(label).size(12.0).weight(weight).color(color)),
.child(Text::new(label).size(text::body()).weight(weight).color(color)),
));
}
let row_w = width - 2.0 * INSET;
let row_w = width - 2.0 * pill::inset();
// the disc under the focused slot, a little wider while it's on its way
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
if let Some(x) = highlight_x {
layers.push(Box::new(Rectangle::new().width(SLOT + stretch).height(SLOT).radius(Full).fill(theme.accent).translate(x - stretch / 2.0, 0.0)));
layers.push(Box::new(Rectangle::new().width(pill::slot() + stretch).height(pill::slot()).radius(Full).fill(theme.accent).translate(x - stretch / 2.0, 0.0)));
}
layers.push(Box::new(Row::new(slots).gap(SLOT_GAP).align(Center)));
layers.push(Box::new(Row::new(slots).gap(pill::slot_gap()).align(Center)));
let pill = Rectangle::new()
.width(width)
.height(COMPACT_H)
.height(island_pill::height())
.radius(Full)
.fill(backdrop(&theme.surfaces(), 255))
.border(1.0, theme.outline)
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(INSET)
.padding(pill::inset())
.align_child(Start, Center)
.on_hover(edge::over_bar)
.child(Stack::new(layers).width(row_w).height(SLOT));
.child(Stack::new(layers).width(row_w).height(pill::slot()));
// clicks next to the pills reach the windows underneath
let mut regions = vec![InputArea { x: LEFT as i32, y: TOP_GAP as i32, width: width.ceil() as i32, height: COMPACT_H as i32 }];
let mut regions = vec![InputArea { x: pill::left() as i32, y: island_pill::top_gap() as i32, width: width.ceil() as i32, height: island_pill::height() as i32 }];
let mut pills: Vec<Box<dyn Widget>> = vec![Box::new(pill.translate(0.0, slide))];
// a peek keeps the workspaces in while the bar leaves: the tray slides away on its own
if let Some((tray, tray_pill_w)) = tray {
regions.push(InputArea { x: (LEFT + width + TRAY_GAP) as i32, y: TOP_GAP as i32, width: tray_pill_w.ceil() as i32, height: COMPACT_H as i32 });
regions.push(InputArea { x: (pill::left() + width + pill::tray_gap()) as i32, y: island_pill::top_gap() as i32, width: tray_pill_w.ceil() as i32, height: island_pill::height() as i32 });
pills.push(Box::new(tray.translate(0.0, tray_slide)));
}
@@ -193,9 +190,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
Rectangle::new()
.width(Parent)
.height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: LEFT })
.padding(Padding { top: island_pill::top_gap(), right: 0.0, bottom: 0.0, left: pill::left() })
.align_child(Start, Start)
.child(Row::new(pills).gap(TRAY_GAP).align(Start)),
.child(Row::new(pills).gap(pill::tray_gap()).align(Start)),
)
}
@@ -234,6 +231,6 @@ fn hovering(id: i64, inside: bool) {
hover
.0
.entry(id)
.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 });
}