Compare commits
4
Commits
ecd9a8d3b0
...
036b326b04
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
036b326b04 | ||
|
|
d04cbac8d4 | ||
|
|
eabda748f5 | ||
|
|
2870ebbd13 |
@@ -2,3 +2,6 @@
|
|||||||
Cargo.toml
|
Cargo.toml
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# written by matugen (through skwd-wall) from the wallpaper, see matugen/amane.colors
|
||||||
|
theme
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ Notes for agents continuing work on the Magic Island shell. Read README.md first
|
|||||||
| `sound.rs` `Sound` | Service, 1 s poll | `pactl list sinks` + `sink-inputs` → each player's streams (matched by process ancestry, then app name), volume, mute, output, equalizer. Curves saved by player identity to `~/.config/amane/island-eq`. |
|
| `sound.rs` `Sound` | Service, 1 s poll | `pactl list sinks` + `sink-inputs` → each player's streams (matched by process ancestry, then app name), volume, mute, output, equalizer. Curves saved by player identity to `~/.config/amane/island-eq`. |
|
||||||
| `spectrum.rs` `Spectrum` | Service, 200 ms check | While the shown player plays: `pw-record --target <its stream> \| cava -p $XDG_RUNTIME_DIR/magic-island/cava.conf` → 24 levels. Stops when it pauses. |
|
| `spectrum.rs` `Spectrum` | Service, 200 ms check | While the shown player plays: `pw-record --target <its stream> \| cava -p $XDG_RUNTIME_DIR/magic-island/cava.conf` → 24 levels. Stops when it pauses. |
|
||||||
| `art.rs` `Art` | Service, 250 ms poll | Cover URL → local file (curl for https), copied to `palette-cover`, which `Palette` (opened in `main.rs`, 8 colours) watches. |
|
| `art.rs` `Art` | Service, 250 ms poll | Cover URL → local file (curl for https), copied to `palette-cover`, which `Palette` (opened in `main.rs`, 8 colours) watches. |
|
||||||
| `parse.rs` | plain functions, no amane | pactl/cava/proc parsing, filter-chain config, `pw-cli` params, EQ store, middle ellipsis. Unit-tested on its own. |
|
| `parse.rs` | plain functions, no amane | pactl/cava/proc parsing, filter-chain config, `pw-cli` params, EQ store, middle ellipsis, `key = value` settings and `#rrggbb`. Unit-tested on its own. |
|
||||||
| `workspaces.rs` | view + input-only `Hover` Service | Workspace pill per monitor (namespace `magic-workspaces`, top left): `Workspaces` filtered by `output() == monitor.name`, special workspaces skipped, click = `hyprctl dispatch 'hl.dsp.focus({ workspace = N })'` (Hyprland 0.56 with a Lua config rejects the `dispatch workspace N` that amane's `Workspaces::focus` sends). Reuses the island's `pub` colours, `backdrop()` and `HOVER_MS`. Redraws only on Hyprland events and hover. |
|
| `theme.rs` | `Theme` Service, `amane::watch_file` (inotify on the folder, so rename-over saves count) | The shell's 10 colours from `~/.config/amane/theme` (rendered by Skwd-wall from `matugen/amane.colors`) and `shell.conf` (`cover_colors`, `transition_ms`), faded together on one `Animation<Colors>`. Every view starts with `theme::begin()` (reads Theme, so it redraws on a change; returns `cover_colors`) and draws with `theme::now()`. No theme file: the original pink on purple. |
|
||||||
|
| `workspaces.rs` | view + input-only `Hover` Service | Workspace pill per monitor (namespace `magic-workspaces`, top left): `Workspaces` filtered by `output() == monitor.name`, special workspaces skipped, click = `hyprctl dispatch 'hl.dsp.focus({ workspace = N })'` (Hyprland 0.56 with a Lua config rejects the `dispatch workspace N` that amane's `Workspaces::focus` sends). Draws with `theme::now()`, reuses the island's `backdrop()` and `HOVER_MS`. Redraws only on Hyprland events and hover. |
|
||||||
|
|
||||||
The equalizer: one `setsid -f pipewire -c eq-<key>.conf` process per player with `libpipewire-module-filter-chain`, 10 biquads (low shelf 31 Hz, peaking 63 Hz–8 kHz, high shelf 16 kHz). The player's streams are moved into sink `magic_island_eq.<key>`; gains change live with `pw-cli set-param magic_island_eq.<key> Props '{ params = [ "b1:Gain" 6.0 ... ] }'`. A player only gets one when its curve or output is first changed, or it has a saved non-flat curve.
|
The equalizer: one `setsid -f pipewire -c eq-<key>.conf` process per player with `libpipewire-module-filter-chain`, 10 biquads (low shelf 31 Hz, peaking 63 Hz–8 kHz, high shelf 16 kHz). The player's streams are moved into sink `magic_island_eq.<key>`; gains change live with `pw-cli set-param magic_island_eq.<key> Props '{ params = [ "b1:Gain" 6.0 ... ] }'`. A player only gets one when its curve or output is first changed, or it has a saved non-flat curve.
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,34 @@
|
|||||||
|
|
||||||
A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card.
|
A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card.
|
||||||
|
|
||||||
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign.
|
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing".
|
||||||
- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next.
|
- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next.
|
||||||
- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays.
|
- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays.
|
||||||
- **Output switcher:** moves only the shown player's audio to another device.
|
- **Output switcher:** moves only the shown player's audio to another device.
|
||||||
- **Per-player volume** (scroll the volume chip, click to mute), set on that player's own PulseAudio/PipeWire streams, so it also works for browsers that ignore MPRIS volume.
|
- **Per-player volume** (scroll the volume chip, click to mute), set on that player's own PulseAudio/PipeWire streams, so it also works for browsers that ignore MPRIS volume.
|
||||||
- **Real 10-band equalizer per player** (31 Hz to 16 kHz, ±12 dB): a PipeWire filter-chain per player, with 8 presets (Flat, Bass, Treble, Vocal, Pop, Rock, Jazz, Classic). Curves are saved per player.
|
- **Real 10-band equalizer per player** (31 Hz to 16 kHz, ±12 dB): a PipeWire filter-chain per player, with 8 presets (Flat, Bass, Treble, Vocal, Pop, Rock, Jazz, Classic). Curves are saved per player.
|
||||||
- **Background:** a diagonal gradient from the cover's own colours, darkened so white text stays readable.
|
- **Background:** a diagonal gradient in the wallpaper's colours (see Colours); optionally the open card takes the song cover's colours.
|
||||||
- **Opening animation:** the pill fades out, the empty shape grows, then the card fades in. Closing runs backwards. Moving the pointer off the open card closes it.
|
- **Opening animation:** the pill fades out, the empty shape grows, then the card fades in. Closing runs backwards. Moving the pointer off the open card closes it.
|
||||||
- **Every monitor:** each monitor shows the pill. The card opens only on the monitor you click (a keybind opens it on the focused one), and only the focused monitor's pill shows live bars; the others show still bars, which keeps the cost at about one island's.
|
- **Every monitor:** each monitor shows the pill. The card opens only on the monitor you click (a keybind opens it on the focused one), and only the focused monitor's pill shows live bars; the others show still bars, which keeps the cost at about one island's.
|
||||||
- **Workspace pill:** top left on every monitor, that monitor's Hyprland workspaces as round numbered slots in the island's look. The focused workspace is pink, the one shown on another monitor a softer pink, empty ones dim. Click one to switch to it.
|
- **Workspace pill:** top left on every monitor, that monitor's Hyprland workspaces as round numbered slots in the island's look. The focused workspace takes the accent colour, the one shown on another monitor a softer accent, empty ones dim. Click one to switch to it.
|
||||||
|
|
||||||
|
## Colours
|
||||||
|
|
||||||
|
The shell takes its colours from the wallpaper through matugen. Skwd-wall v2 runs matugen on every new wallpaper and renders `matugen/amane.colors` into `~/.config/amane/theme`; the shell picks the file up the moment it changes (inotify through `amane::watch_file`) and cross-fades to the new colours. Without the file it keeps its original pink on purple; deleting it takes effect at the next start.
|
||||||
|
|
||||||
|
Register the template in Skwd-wall as a matugen integration:
|
||||||
|
|
||||||
|
- template: `~/.config/amane/matugen/amane.colors`
|
||||||
|
- output: `~/.config/amane/theme`
|
||||||
|
|
||||||
|
The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`).
|
||||||
|
|
||||||
|
`shell.conf` (read live, no restart):
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -22,7 +40,10 @@ src/sound.rs per-player volume, mute, output and equalizer (pactl, pw-cli,
|
|||||||
src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava)
|
src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava)
|
||||||
src/art.rs cover download (curl) and the palette file
|
src/art.rs cover download (curl) and the palette file
|
||||||
src/workspaces.rs the workspace pill, top left on every monitor
|
src/workspaces.rs the workspace pill, top left on every monitor
|
||||||
|
src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade
|
||||||
src/parse.rs pure text parsing with unit tests, no amane dependency
|
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
|
||||||
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq)
|
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq)
|
||||||
tools/ remote test driver and fake MPRIS players, see AGENTS.md
|
tools/ remote test driver and fake MPRIS players, see AGENTS.md
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# matugen template for amane: skwd-wall renders it on every new wallpaper into ~/.config/amane/theme,
|
||||||
|
# which the shell picks up the moment it changes. key=value, one colour per line; a missing line keeps the default
|
||||||
|
accent={{colors.primary.default.hex}}
|
||||||
|
on_accent={{colors.on_primary.default.hex}}
|
||||||
|
accent_soft={{colors.primary_container.default.hex}}
|
||||||
|
flame={{colors.primary_fixed.default.hex}}
|
||||||
|
text={{colors.on_surface.default.hex}}
|
||||||
|
dim={{colors.on_surface_variant.default.hex}}
|
||||||
|
outline={{colors.outline_variant.default.hex}}
|
||||||
|
surface={{colors.surface_container.default.hex}}
|
||||||
|
surface_high={{colors.surface_container_high.default.hex}}
|
||||||
|
surface_low={{colors.surface_container_lowest.default.hex}}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 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
|
||||||
+96
-63
@@ -4,7 +4,7 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use amane::{
|
use amane::{
|
||||||
Animation, Arc, Argument, Blend, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient,
|
Animation, Arc, Argument, Blend, Bus, Canvas, Cap, Center, Circle, Color, Column, Easing, End, Full, Gradient,
|
||||||
Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path,
|
Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path,
|
||||||
Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack,
|
Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack,
|
||||||
Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children, shapes,
|
Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children, shapes,
|
||||||
@@ -14,6 +14,7 @@ use crate::art::Art;
|
|||||||
use crate::parse::{self, BANDS, Sink};
|
use crate::parse::{self, BANDS, Sink};
|
||||||
use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound};
|
use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound};
|
||||||
use crate::spectrum::Spectrum;
|
use crate::spectrum::Spectrum;
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* opening is one linear timeline in two phases, so the shape never resizes with the
|
* opening is one linear timeline in two phases, so the shape never resizes with the
|
||||||
@@ -48,7 +49,6 @@ const DISC: f32 = 136.0;
|
|||||||
const HOLE: f32 = 26.0;
|
const HOLE: f32 = 26.0;
|
||||||
const CANDLES: usize = 40;
|
const CANDLES: usize = 40;
|
||||||
const CANDLE_REACH: f32 = 20.0;
|
const CANDLE_REACH: f32 = 20.0;
|
||||||
const FLAME: Color = Color::rgb(255, 228, 240);
|
|
||||||
|
|
||||||
const SIDE_GAP: f32 = 20.0;
|
const SIDE_GAP: f32 = 20.0;
|
||||||
const SIDE_W: f32 = INNER_W - DISC_BOX - SIDE_GAP;
|
const SIDE_W: f32 = INNER_W - DISC_BOX - SIDE_GAP;
|
||||||
@@ -91,20 +91,13 @@ 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)
|
// 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;
|
const LETTER: f32 = 0.66;
|
||||||
|
|
||||||
pub const PINK: Color = Color::rgb(247, 168, 200);
|
// the white overlays below stay: they read on any dark surface. the theme colours are in theme.rs
|
||||||
const PINK_DEEP: Color = Color::rgb(150, 82, 122);
|
|
||||||
pub const INK: Color = Color::rgb(40, 18, 32);
|
|
||||||
pub const TEXT: Color = Color::rgb(241, 236, 246);
|
|
||||||
pub const DIM: Color = Color::rgba(205, 192, 218, 160);
|
|
||||||
const CHIP: Color = Color::rgba(255, 255, 255, 18);
|
const CHIP: Color = Color::rgba(255, 255, 255, 18);
|
||||||
const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34);
|
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
|
// 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);
|
pub const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46);
|
||||||
pub const CLEAR: Color = Color::rgba(255, 255, 255, 0);
|
pub const CLEAR: Color = Color::rgba(255, 255, 255, 0);
|
||||||
const TRACK: Color = Color::rgba(255, 255, 255, 24);
|
const TRACK: Color = Color::rgba(255, 255, 255, 24);
|
||||||
pub const EDGE: Color = Color::rgba(255, 255, 255, 26);
|
|
||||||
const SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18);
|
|
||||||
const MENU_BG: Color = Color::rgb(35, 26, 44);
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
pub enum Menu {
|
pub enum Menu {
|
||||||
@@ -235,11 +228,13 @@ struct Scene<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn view(monitor: &Monitor) -> LayerWindow {
|
pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||||
|
// the wallpaper's colours for this frame; cover_colors: the open card takes the cover's background
|
||||||
|
let cover_colors = theme::begin();
|
||||||
|
|
||||||
let media = Media::read();
|
let media = Media::read();
|
||||||
let island = Island::read();
|
let island = Island::read();
|
||||||
let sound = Sound::read();
|
let sound = Sound::read();
|
||||||
let art = Art::read();
|
let art = Art::read();
|
||||||
let palette = Palette::read();
|
|
||||||
|
|
||||||
let here = monitor.name.as_str();
|
let here = monitor.name.as_str();
|
||||||
let focused = focused_monitor();
|
let focused = focused_monitor();
|
||||||
@@ -267,15 +262,21 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
.space(Zone::Ignore)
|
.space(Zone::Ignore)
|
||||||
.namespace("magic-island");
|
.namespace("magic-island");
|
||||||
|
|
||||||
/*
|
|
||||||
* no player: an empty window that lets every click through, never visible(false).
|
|
||||||
* hyprland 0.56 configures a layer surface before its first commit, so a window
|
|
||||||
* that starts hidden gets a size anyway: amane then logs "no child to draw" and,
|
|
||||||
* with nothing ever drawn, waits for frame callbacks that never come, and the
|
|
||||||
* island never showed up once a player started
|
|
||||||
*/
|
|
||||||
let Some(player) = shown(&media, island.selected.as_deref()) else {
|
let Some(player) = shown(&media, island.selected.as_deref()) else {
|
||||||
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
/*
|
||||||
|
* nothing playing: a quiet pill, so the island is always there. never
|
||||||
|
* visible(false): hyprland 0.56 configures a layer surface before its first
|
||||||
|
* commit, so a window that starts hidden gets a size anyway, amane logs "no
|
||||||
|
* child to draw" and waits for frame callbacks an undrawn surface never gets
|
||||||
|
*/
|
||||||
|
return window.click_through().child(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(Parent)
|
||||||
|
.height(Parent)
|
||||||
|
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 })
|
||||||
|
.align_child(Center, Start)
|
||||||
|
.child(idle(&theme::now().surfaces())),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
let (grow, pill, reveal) = phases(if opened_here { island.open.value() } else { 0.0 });
|
let (grow, pill, reveal) = phases(if opened_here { island.open.value() } else { 0.0 });
|
||||||
@@ -296,8 +297,20 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
let height = mix(COMPACT_H, WIDE_H, grow);
|
let height = mix(COMPACT_H, WIDE_H, grow);
|
||||||
let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow);
|
let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow);
|
||||||
|
|
||||||
// a gradient of the cover's own colours, for the pill and the card alike
|
/*
|
||||||
let colors = backdrop_colors(&palette);
|
* the wallpaper's surfaces as a gradient for the pill and the card; with cover_colors
|
||||||
|
* the card's background fades over to the cover's gradient as the card comes in
|
||||||
|
* (only the background: accents and text keep the wallpaper's colours)
|
||||||
|
*/
|
||||||
|
let wallpaper = theme::now().surfaces();
|
||||||
|
|
||||||
|
let colors = if cover_colors {
|
||||||
|
let cover = backdrop_colors(&Palette::read());
|
||||||
|
|
||||||
|
std::array::from_fn(|stop| Color::blend(wallpaper[stop], cover[stop], reveal))
|
||||||
|
} else {
|
||||||
|
wallpaper
|
||||||
|
};
|
||||||
|
|
||||||
// at most one face at a time, each faded by a veil instead of a gpu layer
|
// at most one face at a time, each faded by a veil instead of a gpu layer
|
||||||
let face = if reveal > 0.0 {
|
let face = if reveal > 0.0 {
|
||||||
@@ -323,7 +336,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
.height(height)
|
.height(height)
|
||||||
.radius(radius)
|
.radius(radius)
|
||||||
.fill(backdrop(&colors, 255))
|
.fill(backdrop(&colors, 255))
|
||||||
.border(1.0, Color::blend(EDGE, faded(PINK, 110), lift));
|
.border(1.0, Color::blend(theme::now().outline, faded(theme::now().accent, 110), lift));
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* one faint pink glow around the island, pill and card alike, stronger while the
|
* one faint pink glow around the island, pill and card alike, stronger while the
|
||||||
@@ -331,7 +344,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
* of blur / 2), inside the window's SHADOW margin. no clip anywhere: neither face
|
* of blur / 2), inside the window's SHADOW margin. no clip anywhere: neither face
|
||||||
* ever overflows the shape
|
* ever overflows the shape
|
||||||
*/
|
*/
|
||||||
let shape = shape.shadow(Shadow::drop(PINK).opacity(mix(0.3, 0.7, lift)).blur(22.0));
|
let shape = shape.shadow(Shadow::drop(theme::now().accent).opacity(mix(0.3, 0.7, lift)).blur(22.0));
|
||||||
|
|
||||||
let tapped = monitor.name.clone();
|
let tapped = monitor.name.clone();
|
||||||
let hovered = monitor.name.clone();
|
let hovered = monitor.name.clone();
|
||||||
@@ -589,6 +602,19 @@ pub fn faded(color: Color, alpha: u8) -> Color {
|
|||||||
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
.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))
|
||||||
|
}
|
||||||
|
|
||||||
// the pill: cover, title, the player's live levels
|
// the pill: cover, title, the player's live levels
|
||||||
fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle {
|
fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle {
|
||||||
Rectangle::new()
|
Rectangle::new()
|
||||||
@@ -602,7 +628,7 @@ fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Re
|
|||||||
.width(Parent)
|
.width(Parent)
|
||||||
.height(COMPACT_H)
|
.height(COMPACT_H)
|
||||||
.align_child(Start, Center)
|
.align_child(Start, Center)
|
||||||
.child(Text::new(title(player)).size(12.0).color(TEXT).elide()),
|
.child(Text::new(title(player)).size(12.0).color(theme::now().text).elide()),
|
||||||
pill_levels(playing, levels),
|
pill_levels(playing, levels),
|
||||||
])
|
])
|
||||||
.gap(10.0)
|
.gap(10.0)
|
||||||
@@ -624,7 +650,7 @@ fn content(scene: &Scene) -> Rectangle {
|
|||||||
Rectangle::new().width(WIDE_W).height(WIDE_H).padding(PAD).child(
|
Rectangle::new().width(WIDE_W).height(WIDE_H).padding(PAD).child(
|
||||||
Column::new(children![
|
Column::new(children![
|
||||||
Row::new(children![disc(scene), side(scene)]).gap(SIDE_GAP).align(Center).width(INNER_W).height(HEADER_H),
|
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(EDGE),
|
Rectangle::new().width(INNER_W).height(1.0).fill(theme::now().outline),
|
||||||
eq_header(scene),
|
eq_header(scene),
|
||||||
equalizer(scene),
|
equalizer(scene),
|
||||||
presets(scene),
|
presets(scene),
|
||||||
@@ -644,8 +670,8 @@ fn disc(scene: &Scene) -> Stack {
|
|||||||
.width(DISC)
|
.width(DISC)
|
||||||
.height(DISC)
|
.height(DISC)
|
||||||
.radius(Full)
|
.radius(Full)
|
||||||
.fill(PINK)
|
.fill(theme::now().accent)
|
||||||
.shadow(Shadow::drop(PINK).opacity(0.35).blur(18.0))
|
.shadow(Shadow::drop(theme::now().accent).opacity(0.35).blur(18.0))
|
||||||
.align_child(Center, Center)
|
.align_child(Center, Center)
|
||||||
.translate(offset, offset)
|
.translate(offset, offset)
|
||||||
// scroll over the cd for this player's volume
|
// scroll over the cd for this player's volume
|
||||||
@@ -697,9 +723,9 @@ fn ring(levels: &[f32]) -> Canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Canvas::new().width(DISC_BOX).height(DISC_BOX).shapes(shapes![
|
Canvas::new().width(DISC_BOX).height(DISC_BOX).shapes(shapes![
|
||||||
glow.stroke(9.0, faded(PINK, 50)).cap(Cap::Round),
|
glow.stroke(9.0, faded(theme::now().accent, 50)).cap(Cap::Round),
|
||||||
body.stroke(4.5, faded(PINK, 215)).cap(Cap::Round),
|
body.stroke(4.5, faded(theme::now().accent, 215)).cap(Cap::Round),
|
||||||
flames.stroke(4.0, FLAME).cap(Cap::Round),
|
flames.stroke(4.0, theme::now().flame).cap(Cap::Round),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,8 +748,8 @@ fn side(scene: &Scene) -> Column {
|
|||||||
|
|
||||||
Column::new(children![
|
Column::new(children![
|
||||||
Column::new(children![
|
Column::new(children![
|
||||||
Text::new(title(player)).size(19.0).weight(Weight::Bold).color(TEXT).elide(),
|
Text::new(title(player)).size(19.0).weight(Weight::Bold).color(theme::now().text).elide(),
|
||||||
Text::new(format!("BY {artist}")).size(12.0).color(DIM).elide(),
|
Text::new(format!("BY {artist}")).size(12.0).color(theme::now().dim).elide(),
|
||||||
chips(scene),
|
chips(scene),
|
||||||
])
|
])
|
||||||
.gap(8.0)
|
.gap(8.0)
|
||||||
@@ -765,9 +791,9 @@ fn chips(scene: &Scene) -> Row {
|
|||||||
.on_hover(hovering(Spot::Output))
|
.on_hover(hovering(Spot::Output))
|
||||||
.child(
|
.child(
|
||||||
Row::new(children![
|
Row::new(children![
|
||||||
icon(if device.is_some_and(|device| device.bluetooth) { Glyph::Bluetooth } else { Glyph::Speaker }, 12.0, PINK),
|
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(TEXT).elide(),
|
Text::new(output_label).size(11.0).color(theme::now().text).elide(),
|
||||||
icon(Glyph::Chevron, 8.0, Color::blend(DIM, TEXT, output_lit)),
|
icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().text, output_lit)),
|
||||||
])
|
])
|
||||||
.gap(6.0)
|
.gap(6.0)
|
||||||
.align(Center),
|
.align(Center),
|
||||||
@@ -786,8 +812,8 @@ fn chips(scene: &Scene) -> Row {
|
|||||||
.on_hover(hovering(Spot::Player))
|
.on_hover(hovering(Spot::Player))
|
||||||
.child(
|
.child(
|
||||||
Row::new(children![
|
Row::new(children![
|
||||||
Text::new(player_label).size(11.0).color(Color::blend(DIM, TEXT, player_lit)).elide(),
|
Text::new(player_label).size(11.0).color(Color::blend(theme::now().dim, theme::now().text, player_lit)).elide(),
|
||||||
icon(Glyph::Chevron, 8.0, Color::blend(DIM, TEXT, player_lit)),
|
icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().text, player_lit)),
|
||||||
])
|
])
|
||||||
.gap(6.0)
|
.gap(6.0)
|
||||||
.align(Center),
|
.align(Center),
|
||||||
@@ -828,7 +854,7 @@ fn timeline(scene: &Scene) -> Column {
|
|||||||
.width((SIDE_W * fraction).max(bar_h))
|
.width((SIDE_W * fraction).max(bar_h))
|
||||||
.height(bar_h)
|
.height(bar_h)
|
||||||
.radius(Full)
|
.radius(Full)
|
||||||
.fill(Color::blend(PINK, FLAME, 0.4 * lit)),
|
.fill(Color::blend(theme::now().accent, theme::now().flame, 0.4 * lit)),
|
||||||
),
|
),
|
||||||
Rectangle::new()
|
Rectangle::new()
|
||||||
.width(KNOB)
|
.width(KNOB)
|
||||||
@@ -867,7 +893,7 @@ fn timeline(scene: &Scene) -> Column {
|
|||||||
|
|
||||||
Column::new(children![
|
Column::new(children![
|
||||||
bar,
|
bar,
|
||||||
Row::new(children![Text::new(clock(shown)).size(11.0).color(DIM), Text::new(total).size(11.0).color(DIM)])
|
Row::new(children![Text::new(clock(shown)).size(11.0).color(theme::now().dim), Text::new(total).size(11.0).color(theme::now().dim)])
|
||||||
.justify(SpaceBetween)
|
.justify(SpaceBetween)
|
||||||
.width(SIDE_W),
|
.width(SIDE_W),
|
||||||
])
|
])
|
||||||
@@ -937,11 +963,11 @@ fn controls(player: &MediaPlayer, island: &Island) -> Row {
|
|||||||
let middle = Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center);
|
let middle = Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center);
|
||||||
|
|
||||||
let middle = if shown {
|
let middle = if shown {
|
||||||
let bar = || Rectangle::new().width(10.0 + BOLD * lit).height(30.0 + BOLD * lit).radius(3.0).fill(PINK);
|
let bar = || Rectangle::new().width(10.0 + BOLD * lit).height(30.0 + 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 - BOLD * lit))
|
||||||
} else {
|
} else {
|
||||||
middle.child(bold_icon(Glyph::Play, 30.0, PINK, BOLD * lit))
|
middle.child(bold_icon(Glyph::Play, 30.0, theme::now().accent, BOLD * lit))
|
||||||
};
|
};
|
||||||
|
|
||||||
Row::new(children![
|
Row::new(children![
|
||||||
@@ -1010,7 +1036,7 @@ fn button(glyph: Glyph, lit: f32) -> Rectangle {
|
|||||||
.height(40.0)
|
.height(40.0)
|
||||||
.align_child(Center, Center)
|
.align_child(Center, Center)
|
||||||
.cursor(Pointer)
|
.cursor(Pointer)
|
||||||
.child(bold_icon(glyph, 18.0, TEXT, BOLD * lit))
|
.child(bold_icon(glyph, 18.0, theme::now().text, BOLD * lit))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eq_header(scene: &Scene) -> Row {
|
fn eq_header(scene: &Scene) -> Row {
|
||||||
@@ -1042,8 +1068,8 @@ fn eq_header(scene: &Scene) -> Row {
|
|||||||
.on_click(move |_| Sound::toggle_mute(&muting))
|
.on_click(move |_| Sound::toggle_mute(&muting))
|
||||||
.child(
|
.child(
|
||||||
Row::new(children![
|
Row::new(children![
|
||||||
icon(if channel.muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, DIM),
|
icon(if channel.muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, theme::now().dim),
|
||||||
Text::new(label).size(11.0).color(TEXT),
|
Text::new(label).size(11.0).color(theme::now().text),
|
||||||
])
|
])
|
||||||
.gap(5.0)
|
.gap(5.0)
|
||||||
.align(Center),
|
.align(Center),
|
||||||
@@ -1052,12 +1078,12 @@ fn eq_header(scene: &Scene) -> Row {
|
|||||||
}
|
}
|
||||||
|
|
||||||
right.push(Box::new(
|
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(DIM)),
|
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)),
|
||||||
));
|
));
|
||||||
|
|
||||||
right.push(Box::new(Text::new(eq.preset).size(12.0).color(TEXT)));
|
right.push(Box::new(Text::new(eq.preset).size(12.0).color(theme::now().text)));
|
||||||
|
|
||||||
Row::new(children![Text::new("Equalizer").size(15.0).color(PINK), Row::new(right).gap(12.0).align(Center)])
|
Row::new(children![Text::new("Equalizer").size(15.0).color(theme::now().accent), Row::new(right).gap(12.0).align(Center)])
|
||||||
.justify(SpaceBetween)
|
.justify(SpaceBetween)
|
||||||
.align(Center)
|
.align(Center)
|
||||||
.width(INNER_W)
|
.width(INNER_W)
|
||||||
@@ -1104,7 +1130,7 @@ fn equalizer(scene: &Scene) -> Column {
|
|||||||
.width(track_w)
|
.width(track_w)
|
||||||
.height(SLIDER_H - y)
|
.height(SLIDER_H - y)
|
||||||
.radius(track_w / 2.0)
|
.radius(track_w / 2.0)
|
||||||
.fill(Color::blend(PINK, FLAME, 0.45 * lit)),
|
.fill(Color::blend(theme::now().accent, theme::now().flame, 0.45 * lit)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@@ -1127,7 +1153,7 @@ fn equalizer(scene: &Scene) -> Column {
|
|||||||
.height(KNOB)
|
.height(KNOB)
|
||||||
.radius(Full)
|
.radius(Full)
|
||||||
.fill(Color::WHITE)
|
.fill(Color::WHITE)
|
||||||
.border(2.0, Color::blend(faded(PINK, 150), PINK, lit))
|
.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 - KNOB / 2.0)
|
||||||
.scale(1.0 + 0.25 * lit),
|
.scale(1.0 + 0.25 * lit),
|
||||||
),
|
),
|
||||||
@@ -1136,7 +1162,7 @@ fn equalizer(scene: &Scene) -> Column {
|
|||||||
let label = if *frequency >= 1000 { format!("{}k", frequency / 1000) } else { frequency.to_string() };
|
let label = if *frequency >= 1000 { format!("{}k", frequency / 1000) } else { frequency.to_string() };
|
||||||
|
|
||||||
labels.push(Box::new(
|
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(DIM, PINK, lit))),
|
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))),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,7 +1203,7 @@ fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas {
|
|||||||
path = path.line_to(*x, *y);
|
path = path.line_to(*x, *y);
|
||||||
}
|
}
|
||||||
|
|
||||||
path.stroke(width, faded(PINK, alpha)).cap(Cap::Round)
|
path.stroke(width, faded(theme::now().accent, alpha)).cap(Cap::Round)
|
||||||
};
|
};
|
||||||
|
|
||||||
// a wide faint stroke under a thin bright one reads as a glow
|
// a wide faint stroke under a thin bright one reads as a glow
|
||||||
@@ -1200,7 +1226,7 @@ fn presets(scene: &Scene) -> Column {
|
|||||||
let identity = scene.player.identity().to_string();
|
let identity = scene.player.identity().to_string();
|
||||||
|
|
||||||
// a hovered preset brightens: grey ones toward white, the chosen pink one toward its flame
|
// a hovered preset brightens: grey ones toward white, the chosen pink one toward its flame
|
||||||
let fill = if chosen { Color::blend(PINK, 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(CHIP, CHIP_HOVER, lit) };
|
||||||
|
|
||||||
buttons.push(Box::new(
|
buttons.push(Box::new(
|
||||||
Rectangle::new()
|
Rectangle::new()
|
||||||
@@ -1219,7 +1245,7 @@ fn presets(scene: &Scene) -> Column {
|
|||||||
Text::new(*name)
|
Text::new(*name)
|
||||||
.size(12.0)
|
.size(12.0)
|
||||||
.weight(if chosen { Weight::Bold } else { Weight::Regular })
|
.weight(if chosen { Weight::Bold } else { Weight::Regular })
|
||||||
.color(if chosen { INK } else { TEXT }),
|
.color(if chosen { theme::now().on_accent } else { theme::now().text }),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1270,8 +1296,8 @@ fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle {
|
|||||||
.width(MENU_W)
|
.width(MENU_W)
|
||||||
.height(height)
|
.height(height)
|
||||||
.radius(10.0)
|
.radius(10.0)
|
||||||
.fill(MENU_BG)
|
.fill(theme::now().surface_high)
|
||||||
.border(1.0, EDGE)
|
.border(1.0, theme::now().outline)
|
||||||
.shadow(Shadow::drop("#000000").opacity(0.5).blur(20.0).offset(0.0, 8.0))
|
.shadow(Shadow::drop("#000000").opacity(0.5).blur(20.0).offset(0.0, 8.0))
|
||||||
.padding(6.0)
|
.padding(6.0)
|
||||||
.clip()
|
.clip()
|
||||||
@@ -1301,8 +1327,8 @@ fn menu_row(label: &str, current: bool, glyph: Glyph, row: usize, island: &Islan
|
|||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
Row::new(children![
|
Row::new(children![
|
||||||
icon(glyph, 11.0, if current { PINK } else { DIM }),
|
icon(glyph, 11.0, if current { theme::now().accent } else { theme::now().dim }),
|
||||||
Text::new(label).size(12.0).color(if current { PINK } else { TEXT }).elide(),
|
Text::new(label).size(12.0).color(if current { theme::now().accent } else { theme::now().text }).elide(),
|
||||||
])
|
])
|
||||||
.gap(9.0)
|
.gap(9.0)
|
||||||
.align(Center),
|
.align(Center),
|
||||||
@@ -1320,10 +1346,10 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
|
|||||||
.width(size)
|
.width(size)
|
||||||
.height(size)
|
.height(size)
|
||||||
.radius(Full)
|
.radius(Full)
|
||||||
.fill(PINK_DEEP)
|
.fill(theme::now().accent_soft)
|
||||||
.align_child(Center, Center);
|
.align_child(Center, Center);
|
||||||
|
|
||||||
let frame = if rim > 0.0 { frame.border(rim, PINK) } else { frame };
|
let frame = if rim > 0.0 { frame.border(rim, theme::now().accent) } else { frame };
|
||||||
|
|
||||||
let picture = Rectangle::new().width(size).height(size).radius(Full).align_child(Center, Center);
|
let picture = Rectangle::new().width(size).height(size).radius(Full).align_child(Center, Center);
|
||||||
|
|
||||||
@@ -1331,7 +1357,7 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
|
|||||||
|
|
||||||
// a rectangle holds one child, so the hole sits on the picture, not beside it
|
// a rectangle holds one child, so the hole sits on the picture, not beside it
|
||||||
let picture = if hole {
|
let picture = if hole {
|
||||||
picture.child(Rectangle::new().width(HOLE).height(HOLE).radius(Full).fill(SURFACE_BOTTOM).border(2.0, EDGE))
|
picture.child(Rectangle::new().width(HOLE).height(HOLE).radius(Full).fill(theme::now().surface_low).border(2.0, theme::now().outline))
|
||||||
} else {
|
} else {
|
||||||
picture
|
picture
|
||||||
};
|
};
|
||||||
@@ -1350,7 +1376,7 @@ fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
|
|||||||
const HEIGHT: f32 = 26.0;
|
const HEIGHT: f32 = 26.0;
|
||||||
|
|
||||||
if !playing {
|
if !playing {
|
||||||
return icon(Glyph::Pause, 13.0, DIM);
|
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
|
// lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line
|
||||||
@@ -1382,8 +1408,8 @@ 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(PINK, 55)).cap(Cap::Round),
|
glow.stroke(7.0, faded(theme::now().accent, 55)).cap(Cap::Round),
|
||||||
body.stroke(3.2, PINK).cap(Cap::Round),
|
body.stroke(3.2, theme::now().accent).cap(Cap::Round),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1405,6 +1431,7 @@ enum Glyph {
|
|||||||
Muted,
|
Muted,
|
||||||
Bluetooth,
|
Bluetooth,
|
||||||
Chevron,
|
Chevron,
|
||||||
|
Note,
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawn, not typed, so the island looks the same whatever fonts are installed
|
// drawn, not typed, so the island looks the same whatever fonts are installed
|
||||||
@@ -1473,6 +1500,12 @@ fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
|
|||||||
Glyph::Chevron => shapes![
|
Glyph::Chevron => shapes![
|
||||||
Path::new().move_to(0.12 * s, 0.32 * s).line_to(0.5 * s, 0.7 * s).line_to(0.88 * s, 0.32 * s).stroke(1.5, color).cap(Cap::Round)
|
Path::new().move_to(0.12 * s, 0.32 * s).line_to(0.5 * s, 0.7 * s).line_to(0.88 * s, 0.32 * s).stroke(1.5, color).cap(Cap::Round)
|
||||||
],
|
],
|
||||||
|
// an eighth note: round head, stem, a curved flag
|
||||||
|
Glyph::Note => shapes![
|
||||||
|
Circle::new().center(0.32 * s, 0.74 * s).radius(0.17 * s).fill(color),
|
||||||
|
Line::new().from(0.47 * s, 0.74 * s).to(0.47 * s, 0.12 * s).stroke(thin, color).cap(Cap::Round),
|
||||||
|
Path::new().move_to(0.47 * s, 0.12 * s).quad_to(0.62 * s, 0.3 * s, 0.84 * s, 0.38 * s).stroke(thin, color).cap(Cap::Round),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
Canvas::new().width(size).height(size).shapes(shapes)
|
Canvas::new().width(size).height(size).shapes(shapes)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod island;
|
|||||||
mod parse;
|
mod parse;
|
||||||
mod sound;
|
mod sound;
|
||||||
mod spectrum;
|
mod spectrum;
|
||||||
|
mod theme;
|
||||||
mod workspaces;
|
mod workspaces;
|
||||||
|
|
||||||
use amane::{App, Palette, Service};
|
use amane::{App, Palette, Service};
|
||||||
|
|||||||
@@ -308,6 +308,25 @@ pub fn eq_line(identity: &str, preset: &str, gains: &[f32; 10]) -> String {
|
|||||||
format!("{}\t{}\t{}\n", clean(identity), clean(preset), gains.join(" "))
|
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
|
||||||
|
pub fn settings(text: &str) -> Vec<(String, String)> {
|
||||||
|
text.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.starts_with('#'))
|
||||||
|
.filter_map(|line| line.split_once('='))
|
||||||
|
.map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// "#rrggbb" as red, green, blue; anything else is none
|
||||||
|
pub fn rgb(hex: &str) -> Option<[u8; 3]> {
|
||||||
|
let digits = hex.strip_prefix('#').filter(|digits| digits.len() == 6)?;
|
||||||
|
|
||||||
|
let channel = |at: usize| u8::from_str_radix(digits.get(at..at + 2)?, 16).ok();
|
||||||
|
|
||||||
|
Some([channel(0)?, channel(2)?, channel(4)?])
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -430,4 +449,18 @@ Sink #80
|
|||||||
assert_eq!(eq_store(&line), [(String::from("Lo-Fi Lab"), String::from("Bass"), gains)]);
|
assert_eq!(eq_store(&line), [(String::from("Lo-Fi Lab"), String::from("Bass"), gains)]);
|
||||||
assert!(eq_store("broken\tline\n").is_empty());
|
assert!(eq_store("broken\tline\n").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_settings_and_colours() {
|
||||||
|
let text = "# a comment\ncover_colors = true\n\naccent=#ffb3af\nbroken\n";
|
||||||
|
|
||||||
|
let expected = [(String::from("cover_colors"), String::from("true")), (String::from("accent"), String::from("#ffb3af"))];
|
||||||
|
|
||||||
|
assert_eq!(settings(text), expected);
|
||||||
|
|
||||||
|
assert_eq!(rgb("#ffb3af"), Some([255, 179, 175]));
|
||||||
|
assert_eq!(rgb("#ffb3a"), None);
|
||||||
|
assert_eq!(rgb("ffb3af"), None);
|
||||||
|
assert_eq!(rgb("#gg0000"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-7
@@ -2,12 +2,13 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use std::{env, fs, thread};
|
use std::{fs, thread};
|
||||||
|
|
||||||
use amane::{Argument, Bus, Media, Service};
|
use amane::{Argument, Bus, Media, Service};
|
||||||
|
|
||||||
use crate::art;
|
use crate::art;
|
||||||
use crate::parse::{self, EQ_PREFIX, Sink, Stream};
|
use crate::parse::{self, EQ_PREFIX, Sink, Stream};
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
// how far a band can lift or cut, in dB
|
// how far a band can lift or cut, in dB
|
||||||
pub const MAX_GAIN: f32 = 12.0;
|
pub const MAX_GAIN: f32 = 12.0;
|
||||||
@@ -392,12 +393,7 @@ fn eq_file(key: &str) -> PathBuf {
|
|||||||
|
|
||||||
// saved equalizers live with the shell's config, so they outlast logouts
|
// saved equalizers live with the shell's config, so they outlast logouts
|
||||||
fn store_file() -> PathBuf {
|
fn store_file() -> PathBuf {
|
||||||
let config = env::var_os("XDG_CONFIG_HOME")
|
theme::config_file("island-eq")
|
||||||
.map(PathBuf::from)
|
|
||||||
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
config.join("amane").join("island-eq")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save() {
|
fn save() {
|
||||||
|
|||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
use std::cell::Cell;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Mutex, PoisonError};
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::{env, fs, thread};
|
||||||
|
|
||||||
|
use amane::{Animation, Blend, Color, Service};
|
||||||
|
|
||||||
|
use crate::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
|
||||||
|
*/
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub struct Colors {
|
||||||
|
pub accent: Color,
|
||||||
|
pub on_accent: Color,
|
||||||
|
pub accent_soft: Color,
|
||||||
|
pub flame: Color,
|
||||||
|
pub text: Color,
|
||||||
|
pub dim: Color,
|
||||||
|
pub outline: Color,
|
||||||
|
pub surface: Color,
|
||||||
|
pub surface_high: Color,
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
|
||||||
|
impl Colors {
|
||||||
|
// the background gradient's three stops, light to dark
|
||||||
|
pub fn surfaces(&self) -> [Color; 3] {
|
||||||
|
[self.surface_high, self.surface, self.surface_low]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// so the colours can ride one Animation, all ten fading together
|
||||||
|
impl Blend for Colors {
|
||||||
|
fn blend(from: Self, to: Self, amount: f32) -> Self {
|
||||||
|
let mix = |from: Color, to: Color| Color::blend(from, to, amount);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
accent: mix(from.accent, to.accent),
|
||||||
|
on_accent: mix(from.on_accent, to.on_accent),
|
||||||
|
accent_soft: mix(from.accent_soft, to.accent_soft),
|
||||||
|
flame: mix(from.flame, to.flame),
|
||||||
|
text: mix(from.text, to.text),
|
||||||
|
dim: mix(from.dim, to.dim),
|
||||||
|
outline: mix(from.outline, to.outline),
|
||||||
|
surface: mix(from.surface, to.surface),
|
||||||
|
surface_high: mix(from.surface_high, to.surface_high),
|
||||||
|
surface_low: mix(from.surface_low, to.surface_low),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Theme {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
colors: Animation::new(PINK_ON_PURPLE),
|
||||||
|
cover_colors: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
fn listen() {
|
||||||
|
reload();
|
||||||
|
|
||||||
|
thread::spawn(|| follow("shell.conf"));
|
||||||
|
|
||||||
|
follow("theme");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// what was read last, (theme, shell.conf); none until the first read
|
||||||
|
static LAST: Mutex<Option<(String, String)>> = Mutex::new(None);
|
||||||
|
|
||||||
|
fn follow(name: &str) {
|
||||||
|
for _ in amane::watch_file(&config_file(name).to_string_lossy()) {
|
||||||
|
// one save can arrive as several writes: let it finish, then read once
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 mut last = LAST.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
|
||||||
|
// a write that changes nothing redraws nothing
|
||||||
|
if last.as_ref() == Some(&read) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the colours at startup show at once, later changes cross-fade
|
||||||
|
apply(&read.0, &read.1, last.is_none());
|
||||||
|
|
||||||
|
*last = Some(read);
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
config.join("amane").join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(theme: &str, config: &str, startup: bool) {
|
||||||
|
let mut colors = PINK_ON_PURPLE;
|
||||||
|
|
||||||
|
// a missing or broken line keeps that colour's default
|
||||||
|
for (key, value) in parse::settings(theme) {
|
||||||
|
let Some([red, green, blue]) = parse::rgb(&value) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let color = Color::rgb(red, green, blue);
|
||||||
|
|
||||||
|
match key.as_str() {
|
||||||
|
"accent" => colors.accent = color,
|
||||||
|
"on_accent" => colors.on_accent = color,
|
||||||
|
"accent_soft" => colors.accent_soft = color,
|
||||||
|
"flame" => colors.flame = color,
|
||||||
|
"text" => colors.text = color,
|
||||||
|
"dim" => colors.dim = color,
|
||||||
|
"outline" => colors.outline = color,
|
||||||
|
"surface" => colors.surface = color,
|
||||||
|
"surface_high" => colors.surface_high = color,
|
||||||
|
"surface_low" => colors.surface_low = color,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mut theme = Theme::write();
|
||||||
|
|
||||||
|
theme.cover_colors = setting("cover_colors") == Some("true");
|
||||||
|
|
||||||
|
// a change in the middle of a fade starts from the colours on screen now
|
||||||
|
let now = theme.colors.value();
|
||||||
|
|
||||||
|
theme.colors = Animation::new(now).duration(Duration::from_millis(fade));
|
||||||
|
theme.colors.to(colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
pub fn begin() -> bool {
|
||||||
|
let theme = Theme::read();
|
||||||
|
|
||||||
|
NOW.set(theme.colors.value());
|
||||||
|
|
||||||
|
theme.cover_colors
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now() -> Colors {
|
||||||
|
NOW.get()
|
||||||
|
}
|
||||||
+20
-16
@@ -4,11 +4,12 @@ use std::thread;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use amane::{
|
use amane::{
|
||||||
Animation, Blend, Center, Color, Full, Horizontal, InputArea, Layer, LayerWindow, Monitor, Padding, Palette, Parent, Pointer,
|
Animation, Blend, Center, Color, Full, Horizontal, InputArea, Layer, LayerWindow, Monitor, Padding, Parent, Pointer,
|
||||||
Rectangle, Row, Service, Shadow, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
|
Rectangle, Row, Service, Shadow, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, DIM, EDGE, HOVER_MS, INK, PINK, SHADOW, TEXT, TOP_GAP, backdrop, backdrop_colors, faded};
|
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded};
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
// one round slot per workspace, inside the pill's inset; the pill sits LEFT px from the monitor's edge
|
// 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: f32 = 28.0;
|
||||||
@@ -31,14 +32,15 @@ impl Service for Hover {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* a pill in the top left corner of every monitor with that monitor's hyprland workspaces,
|
* a pill in the top left corner of every monitor with that monitor's hyprland workspaces,
|
||||||
* in the island's look (cover gradient, pink glow). the focused workspace is pink, the one
|
* in the wallpaper's colours like the island. the focused workspace takes the accent, the
|
||||||
* shown on another monitor a softer pink, empty ones dim; a click switches to it.
|
* one shown on another monitor a softer accent, empty ones dim; a click switches to it.
|
||||||
* Workspaces follows hyprland's events, so nothing polls
|
* Workspaces follows hyprland's events, so nothing polls
|
||||||
*/
|
*/
|
||||||
pub fn view(monitor: &Monitor) -> LayerWindow {
|
pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||||
|
theme::begin();
|
||||||
|
|
||||||
let workspaces = Workspaces::read();
|
let workspaces = Workspaces::read();
|
||||||
let hover = Hover::read();
|
let hover = Hover::read();
|
||||||
let palette = Palette::read();
|
|
||||||
|
|
||||||
// special workspaces (scratchpads) aren't places to switch to
|
// special workspaces (scratchpads) aren't places to switch to
|
||||||
let here: Vec<&Workspace> = workspaces
|
let here: Vec<&Workspace> = workspaces
|
||||||
@@ -78,14 +80,16 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
_ => workspace.index().to_string(),
|
_ => workspace.index().to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (fill, color, weight) = if workspace.focused() {
|
let theme = theme::now();
|
||||||
(PINK, INK, Weight::Bold)
|
|
||||||
} else if workspace.active() {
|
|
||||||
(faded(PINK, 70), PINK, Weight::Bold)
|
|
||||||
} else {
|
|
||||||
let resting = if workspace.windows() > 0 { TEXT } else { DIM };
|
|
||||||
|
|
||||||
(Color::blend(CLEAR, CHIP_HOVER, lit), Color::blend(resting, TEXT, lit), Weight::Regular)
|
let (fill, color, weight) = if workspace.focused() {
|
||||||
|
(theme.accent, theme.on_accent, Weight::Bold)
|
||||||
|
} else if workspace.active() {
|
||||||
|
(faded(theme.accent, 70), theme.accent, Weight::Bold)
|
||||||
|
} 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)
|
||||||
};
|
};
|
||||||
|
|
||||||
slots.push(Box::new(
|
slots.push(Box::new(
|
||||||
@@ -103,15 +107,15 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let colors = backdrop_colors(&palette);
|
let theme = theme::now();
|
||||||
|
|
||||||
let pill = Rectangle::new()
|
let pill = Rectangle::new()
|
||||||
.width(width)
|
.width(width)
|
||||||
.height(COMPACT_H)
|
.height(COMPACT_H)
|
||||||
.radius(Full)
|
.radius(Full)
|
||||||
.fill(backdrop(&colors, 255))
|
.fill(backdrop(&theme.surfaces(), 255))
|
||||||
.border(1.0, EDGE)
|
.border(1.0, theme.outline)
|
||||||
.shadow(Shadow::drop(PINK).opacity(0.3).blur(22.0))
|
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
|
||||||
.padding(INSET)
|
.padding(INSET)
|
||||||
.align_child(Start, Center)
|
.align_child(Start, Center)
|
||||||
.child(Row::new(slots).gap(SLOT_GAP).align(Center));
|
.child(Row::new(slots).gap(SLOT_GAP).align(Center));
|
||||||
|
|||||||
Reference in New Issue
Block a user