colours from the wallpaper through matugen

- matugen/amane.colors: a matugen template (primary, on_primary,
  primary_container, primary_fixed, on_surface, on_surface_variant,
  outline_variant, surface_container high/normal/lowest) that skwd-wall
  renders into ~/.config/amane/theme on every new wallpaper
- theme.rs: a Theme service reads that file and shell.conf twice a
  second (matugen replaces its output, which a file watch can lose) and
  fades all ten colours together on one Animation; without a theme file
  the shell keeps its pink on purple
- the island, the idle pill and the workspace pill draw with the theme;
  white overlays (chips, tracks, hover tints) stay as they are
- shell.conf: cover_colors (true: the open media card and equalizer take
  the cover's gradient as background, accents stay the wallpaper's) and
  transition_ms (0 switches instantly), both applied without a restart
- parse: settings() and rgb(), with a test (6 pass)
- checked on the desktop with a hand-written theme file: pill, workspace
  pill and card recolour, cover_colors switches the card background live
This commit is contained in:
server2
2026-10-09 04:28:39 +02:00
parent 2870ebbd13
commit eabda748f5
10 changed files with 348 additions and 83 deletions
+3
View File
@@ -2,3 +2,6 @@
Cargo.toml
Cargo.lock
target/
# written by matugen (through skwd-wall) from the wallpaper, see matugen/amane.colors
theme
+23 -2
View File
@@ -8,10 +8,28 @@ A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), runn
- **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.
- **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.
- **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 reads that file twice a second and cross-fades to the new colours. Without the file it keeps its original pink on purple.
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
@@ -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/art.rs cover download (curl) and the palette file
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
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)
tools/ remote test driver and fake MPRIS players, see AGENTS.md
```
+12
View File
@@ -0,0 +1,12 @@
# matugen template for amane: skwd-wall renders it on every new wallpaper into ~/.config/amane/theme,
# which the shell reads twice a second. 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}}
+9
View File
@@ -0,0 +1,9 @@
# amane shell settings, read twice a second: changes apply without a restart.
# 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
+65 -58
View File
@@ -14,6 +14,7 @@ use crate::art::Art;
use crate::parse::{self, BANDS, Sink};
use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound};
use crate::spectrum::Spectrum;
use crate::theme;
/*
* 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 CANDLES: usize = 40;
const CANDLE_REACH: f32 = 20.0;
const FLAME: Color = Color::rgb(255, 228, 240);
const SIDE_GAP: f32 = 20.0;
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)
const LETTER: f32 = 0.66;
pub const PINK: Color = Color::rgb(247, 168, 200);
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);
// the white overlays below stay: they read on any dark surface. the theme colours are in theme.rs
const CHIP: Color = Color::rgba(255, 255, 255, 18);
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);
pub const CLEAR: Color = Color::rgba(255, 255, 255, 0);
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)]
pub enum Menu {
@@ -235,11 +228,13 @@ struct Scene<'a> {
}
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 island = Island::read();
let sound = Sound::read();
let art = Art::read();
let palette = Palette::read();
let here = monitor.name.as_str();
let focused = focused_monitor();
@@ -280,7 +275,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 })
.align_child(Center, Start)
.child(idle(&backdrop_colors(&palette))),
.child(idle(&theme::now().surfaces())),
);
};
@@ -302,8 +297,20 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let height = mix(COMPACT_H, WIDE_H, 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
let face = if reveal > 0.0 {
@@ -329,7 +336,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.height(height)
.radius(radius)
.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
@@ -337,7 +344,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
* of blur / 2), inside the window's SHADOW margin. no clip anywhere: neither face
* 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 hovered = monitor.name.clone();
@@ -602,10 +609,10 @@ fn idle(colors: &[Color; 3]) -> Rectangle {
.height(COMPACT_H)
.radius(Full)
.fill(backdrop(colors, 255))
.border(1.0, EDGE)
.shadow(Shadow::drop(PINK).opacity(0.3).blur(22.0))
.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, PINK), Text::new("Nothing playing").size(12.0).color(DIM)]).gap(8.0).align(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
@@ -621,7 +628,7 @@ fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Re
.width(Parent)
.height(COMPACT_H)
.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),
])
.gap(10.0)
@@ -643,7 +650,7 @@ fn content(scene: &Scene) -> Rectangle {
Rectangle::new().width(WIDE_W).height(WIDE_H).padding(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(EDGE),
Rectangle::new().width(INNER_W).height(1.0).fill(theme::now().outline),
eq_header(scene),
equalizer(scene),
presets(scene),
@@ -663,8 +670,8 @@ fn disc(scene: &Scene) -> Stack {
.width(DISC)
.height(DISC)
.radius(Full)
.fill(PINK)
.shadow(Shadow::drop(PINK).opacity(0.35).blur(18.0))
.fill(theme::now().accent)
.shadow(Shadow::drop(theme::now().accent).opacity(0.35).blur(18.0))
.align_child(Center, Center)
.translate(offset, offset)
// scroll over the cd for this player's volume
@@ -716,9 +723,9 @@ fn ring(levels: &[f32]) -> Canvas {
}
Canvas::new().width(DISC_BOX).height(DISC_BOX).shapes(shapes![
glow.stroke(9.0, faded(PINK, 50)).cap(Cap::Round),
body.stroke(4.5, faded(PINK, 215)).cap(Cap::Round),
flames.stroke(4.0, FLAME).cap(Cap::Round),
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),
])
}
@@ -741,8 +748,8 @@ fn side(scene: &Scene) -> Column {
Column::new(children![
Column::new(children![
Text::new(title(player)).size(19.0).weight(Weight::Bold).color(TEXT).elide(),
Text::new(format!("BY {artist}")).size(12.0).color(DIM).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(theme::now().dim).elide(),
chips(scene),
])
.gap(8.0)
@@ -784,9 +791,9 @@ fn chips(scene: &Scene) -> Row {
.on_hover(hovering(Spot::Output))
.child(
Row::new(children![
icon(if device.is_some_and(|device| device.bluetooth) { Glyph::Bluetooth } else { Glyph::Speaker }, 12.0, PINK),
Text::new(output_label).size(11.0).color(TEXT).elide(),
icon(Glyph::Chevron, 8.0, Color::blend(DIM, TEXT, output_lit)),
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(),
icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().text, output_lit)),
])
.gap(6.0)
.align(Center),
@@ -805,8 +812,8 @@ fn chips(scene: &Scene) -> Row {
.on_hover(hovering(Spot::Player))
.child(
Row::new(children![
Text::new(player_label).size(11.0).color(Color::blend(DIM, TEXT, player_lit)).elide(),
icon(Glyph::Chevron, 8.0, Color::blend(DIM, TEXT, player_lit)),
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(theme::now().dim, theme::now().text, player_lit)),
])
.gap(6.0)
.align(Center),
@@ -847,7 +854,7 @@ fn timeline(scene: &Scene) -> Column {
.width((SIDE_W * fraction).max(bar_h))
.height(bar_h)
.radius(Full)
.fill(Color::blend(PINK, FLAME, 0.4 * lit)),
.fill(Color::blend(theme::now().accent, theme::now().flame, 0.4 * lit)),
),
Rectangle::new()
.width(KNOB)
@@ -886,7 +893,7 @@ fn timeline(scene: &Scene) -> Column {
Column::new(children![
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)
.width(SIDE_W),
])
@@ -956,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 = 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))
} 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![
@@ -1029,7 +1036,7 @@ fn button(glyph: Glyph, lit: f32) -> Rectangle {
.height(40.0)
.align_child(Center, Center)
.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 {
@@ -1061,8 +1068,8 @@ fn eq_header(scene: &Scene) -> Row {
.on_click(move |_| Sound::toggle_mute(&muting))
.child(
Row::new(children![
icon(if channel.muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, DIM),
Text::new(label).size(11.0).color(TEXT),
icon(if channel.muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, theme::now().dim),
Text::new(label).size(11.0).color(theme::now().text),
])
.gap(5.0)
.align(Center),
@@ -1071,12 +1078,12 @@ 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(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)
.align(Center)
.width(INNER_W)
@@ -1123,7 +1130,7 @@ fn equalizer(scene: &Scene) -> Column {
.width(track_w)
.height(SLIDER_H - y)
.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)),
),
),
));
@@ -1146,7 +1153,7 @@ fn equalizer(scene: &Scene) -> Column {
.height(KNOB)
.radius(Full)
.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)
.scale(1.0 + 0.25 * lit),
),
@@ -1155,7 +1162,7 @@ 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(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))),
));
}
@@ -1196,7 +1203,7 @@ fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas {
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
@@ -1219,7 +1226,7 @@ fn presets(scene: &Scene) -> Column {
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(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(
Rectangle::new()
@@ -1238,7 +1245,7 @@ fn presets(scene: &Scene) -> Column {
Text::new(*name)
.size(12.0)
.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 }),
),
));
}
@@ -1289,8 +1296,8 @@ fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle {
.width(MENU_W)
.height(height)
.radius(10.0)
.fill(MENU_BG)
.border(1.0, EDGE)
.fill(theme::now().surface_high)
.border(1.0, theme::now().outline)
.shadow(Shadow::drop("#000000").opacity(0.5).blur(20.0).offset(0.0, 8.0))
.padding(6.0)
.clip()
@@ -1320,8 +1327,8 @@ fn menu_row(label: &str, current: bool, glyph: Glyph, row: usize, island: &Islan
})
.child(
Row::new(children![
icon(glyph, 11.0, if current { PINK } else { DIM }),
Text::new(label).size(12.0).color(if current { PINK } else { TEXT }).elide(),
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(),
])
.gap(9.0)
.align(Center),
@@ -1339,10 +1346,10 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
.width(size)
.height(size)
.radius(Full)
.fill(PINK_DEEP)
.fill(theme::now().accent_soft)
.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);
@@ -1350,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
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 {
picture
};
@@ -1369,7 +1376,7 @@ fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
const HEIGHT: f32 = 26.0;
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
@@ -1401,8 +1408,8 @@ fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
}
Canvas::new().width(WIDTH).height(HEIGHT).shapes(shapes![
glow.stroke(7.0, faded(PINK, 55)).cap(Cap::Round),
body.stroke(3.2, PINK).cap(Cap::Round),
glow.stroke(7.0, faded(theme::now().accent, 55)).cap(Cap::Round),
body.stroke(3.2, theme::now().accent).cap(Cap::Round),
])
}
+1
View File
@@ -3,6 +3,7 @@ mod island;
mod parse;
mod sound;
mod spectrum;
mod theme;
mod workspaces;
use amane::{App, Palette, Service};
+33
View File
@@ -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(" "))
}
// "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)]
mod tests {
use super::*;
@@ -430,4 +449,18 @@ Sink #80
assert_eq!(eq_store(&line), [(String::from("Lo-Fi Lab"), String::from("Bass"), gains)]);
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
View File
@@ -2,12 +2,13 @@ use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use std::{env, fs, thread};
use std::{fs, thread};
use amane::{Argument, Bus, Media, Service};
use crate::art;
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;
@@ -392,12 +393,7 @@ fn eq_file(key: &str) -> PathBuf {
// saved equalizers live with the shell's config, so they outlast logouts
fn store_file() -> 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("island-eq")
theme::config_file("island-eq")
}
fn save() {
+179
View File
@@ -0,0 +1,179 @@
use std::cell::Cell;
use std::path::PathBuf;
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,
}
}
// two small files read twice a second: matugen writes a new file each time, which a file watch can lose
fn listen() {
let mut last = None;
loop {
let read = (read("theme"), read("shell.conf"));
if last.as_ref() != Some(&read) {
// the colours at startup show at once, later changes cross-fade
apply(&read.0, &read.1, last.is_none());
last = Some(read);
}
thread::sleep(Duration::from_millis(500));
}
}
}
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
View File
@@ -4,11 +4,12 @@ use std::thread;
use std::time::Duration;
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,
};
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
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,
* in the island's look (cover gradient, pink glow). the focused workspace is pink, the one
* shown on another monitor a softer pink, empty ones dim; a click switches to it.
* in the wallpaper's colours like the island. the focused workspace takes the accent, the
* one shown on another monitor a softer accent, empty ones dim; a click switches to it.
* Workspaces follows hyprland's events, so nothing polls
*/
pub fn view(monitor: &Monitor) -> LayerWindow {
theme::begin();
let workspaces = Workspaces::read();
let hover = Hover::read();
let palette = Palette::read();
// special workspaces (scratchpads) aren't places to switch to
let here: Vec<&Workspace> = workspaces
@@ -78,14 +80,16 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
_ => workspace.index().to_string(),
};
let (fill, color, weight) = if workspace.focused() {
(PINK, INK, Weight::Bold)
} else if workspace.active() {
(faded(PINK, 70), PINK, Weight::Bold)
} else {
let resting = if workspace.windows() > 0 { TEXT } else { DIM };
let theme = theme::now();
(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(
@@ -103,15 +107,15 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
));
}
let colors = backdrop_colors(&palette);
let theme = theme::now();
let pill = Rectangle::new()
.width(width)
.height(COMPACT_H)
.radius(Full)
.fill(backdrop(&colors, 255))
.border(1.0, EDGE)
.shadow(Shadow::drop(PINK).opacity(0.3).blur(22.0))
.fill(backdrop(&theme.surfaces(), 255))
.border(1.0, theme.outline)
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(INSET)
.align_child(Start, Center)
.child(Row::new(slots).gap(SLOT_GAP).align(Center));