diff --git a/.gitignore b/.gitignore index 4280ffc..6ddd420 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ Cargo.toml Cargo.lock target/ + +# written by matugen (through skwd-wall) from the wallpaper, see matugen/amane.colors +theme diff --git a/README.md b/README.md index 8d549db..9f7da86 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/matugen/amane.colors b/matugen/amane.colors new file mode 100644 index 0000000..19cb8ba --- /dev/null +++ b/matugen/amane.colors @@ -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}} diff --git a/shell.conf b/shell.conf new file mode 100644 index 0000000..1480ce4 --- /dev/null +++ b/shell.conf @@ -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 diff --git a/src/island.rs b/src/island.rs index 19dd533..f5b8c77 100644 --- a/src/island.rs +++ b/src/island.rs @@ -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), ]) } diff --git a/src/main.rs b/src/main.rs index bf604b9..9064cef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod island; mod parse; mod sound; mod spectrum; +mod theme; mod workspaces; use amane::{App, Palette, Service}; diff --git a/src/parse.rs b/src/parse.rs index a576a89..be22a4a 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -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); + } } diff --git a/src/sound.rs b/src/sound.rs index ce2b69a..1db802c 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -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() { diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..8b538f0 --- /dev/null +++ b/src/theme.rs @@ -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, + + // 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/, 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 = 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() +} diff --git a/src/workspaces.rs b/src/workspaces.rs index 913a8f8..000f009 100644 --- a/src/workspaces.rs +++ b/src/workspaces.rs @@ -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));