diff --git a/README.md b/README.md index ed12872..9fe4ebe 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), runn - **Background:** a diagonal gradient from the cover's own colours, darkened so white text stays readable. - **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. ## Layout @@ -20,6 +21,7 @@ src/island.rs the view: pill, card, ring, equalizer, dropdowns, animation, src/sound.rs per-player volume, mute, output and equalizer (pactl, pw-cli, pipewire) 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/parse.rs pure text parsing with unit tests, no amane dependency 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/src/island.rs b/src/island.rs index 9be1656..43a8576 100644 --- a/src/island.rs +++ b/src/island.rs @@ -25,7 +25,7 @@ const PILL_GONE: f32 = 0.18; const SHAPE_DONE: f32 = 0.6; // hover highlights ease in and out over this long; the pill widens by PILL_HOVER_GROW -const HOVER_MS: u64 = 220; +pub const HOVER_MS: u64 = 220; const PILL_HOVER_GROW: f32 = 10.0; // how much thicker, in px, the transport icons get when hovered @@ -33,14 +33,14 @@ const BOLD: f32 = 2.0; // sizes, in pixels const COMPACT_W: f32 = 220.0; -const COMPACT_H: f32 = 38.0; +pub const COMPACT_H: f32 = 38.0; const WIDE_W: f32 = 560.0; const WIDE_RADIUS: f32 = 28.0; const PAD: f32 = 22.0; const INNER_W: f32 = WIDE_W - 2.0 * PAD; const GAP: f32 = 16.0; -const TOP_GAP: f32 = 6.0; -const SHADOW: f32 = 34.0; +pub const TOP_GAP: f32 = 6.0; +pub const SHADOW: f32 = 34.0; // the cd: its box, the cover, the hole, and how far the candles reach past it const DISC_BOX: f32 = 204.0; @@ -91,18 +91,18 @@ 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; -const PINK: Color = Color::rgb(247, 168, 200); +pub const PINK: Color = Color::rgb(247, 168, 200); const PINK_DEEP: Color = Color::rgb(150, 82, 122); -const INK: Color = Color::rgb(40, 18, 32); -const TEXT: Color = Color::rgb(241, 236, 246); -const DIM: Color = Color::rgba(205, 192, 218, 160); +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_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 -const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46); -const CLEAR: Color = Color::rgba(255, 255, 255, 0); +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); -const EDGE: Color = Color::rgba(255, 255, 255, 26); +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); @@ -551,7 +551,7 @@ fn veiled(face: Rectangle, width: f32, height: f32, radius: f32, shown: f32, col } // the cover's colours for the backdrop: most common, second most common, darkest -fn backdrop_colors(palette: &Palette) -> [Color; 3] { +pub fn backdrop_colors(palette: &Palette) -> [Color; 3] { let first = palette.dominant(); let second = palette.colors().get(1).copied().unwrap_or(first); @@ -563,7 +563,7 @@ fn backdrop_colors(palette: &Palette) -> [Color; 3] { * one gradient for the pill, the card and the veils; 150 degrees runs from the * top left to the bottom right, like the reference's light */ -fn backdrop(colors: &[Color; 3], alpha: u8) -> Gradient { +pub fn backdrop(colors: &[Color; 3], alpha: u8) -> Gradient { Gradient::linear(150.0, [(0.0, faded(colors[0], alpha)), (0.55, faded(colors[1], alpha)), (1.0, faded(colors[2], alpha))]) } @@ -578,7 +578,7 @@ fn deepen(color: Color, max: f32) -> Color { Color::rgb((red * scale) as u8, (green * scale) as u8, (blue * scale) as u8) } -fn faded(color: Color, alpha: u8) -> Color { +pub fn faded(color: Color, alpha: u8) -> Color { Color::rgba(color.red(), color.green(), color.blue(), alpha) } diff --git a/src/main.rs b/src/main.rs index 9832a44..bf604b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod island; mod parse; mod sound; mod spectrum; +mod workspaces; use amane::{App, Palette, Service}; @@ -10,6 +11,11 @@ fn main() { // the card's glow follows the cover art, which art::Art copies to this one path Palette::write().open(&art::palette_file(), 8); - // one island on every monitor; the card opens only on one of them - App::new().font("Fira Code").ipc("island", island::command).window_per_monitor(island::view).run(); + // one island on every monitor (the card opens only on one of them), and a workspace pill top left on each + App::new() + .font("Fira Code") + .ipc("island", island::command) + .window_per_monitor(island::view) + .window_per_monitor(workspaces::view) + .run(); } diff --git a/src/workspaces.rs b/src/workspaces.rs new file mode 100644 index 0000000..157516e --- /dev/null +++ b/src/workspaces.rs @@ -0,0 +1,154 @@ +use std::collections::HashMap; +use std::time::Duration; + +use amane::{ + Animation, Blend, Center, Color, Full, Horizontal, InputArea, Layer, LayerWindow, Monitor, Padding, Palette, 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}; + +// one round slot per workspace, inside the pill's inset; the pill sits LEFT px from the monitor's edge +const SLOT: f32 = 28.0; +const SLOT_GAP: f32 = 4.0; +const INSET: f32 = 5.0; +const LEFT: f32 = 12.0; + +// each hovered workspace's highlight by workspace id, fading between 0 and 1 +#[derive(Default)] +struct Hover(HashMap); + +impl Service for Hover { + fn new() -> Self { + Self::default() + } + + // changes only through input + fn listen() {} +} + +/* + * 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. + * Workspaces follows hyprland's events, so nothing polls + */ +pub fn view(monitor: &Monitor) -> LayerWindow { + 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 + .list() + .iter() + .filter(|workspace| workspace.output() == Some(monitor.name.as_str())) + .filter(|workspace| !workspace.name().is_some_and(|name| name.starts_with("special"))) + .collect(); + + let count = here.len() as f32; + let width = 2.0 * INSET + count * SLOT + (count - 1.0).max(0.0) * SLOT_GAP; + + let window = LayerWindow::new() + .width(LEFT + width + SHADOW) + .height(TOP_GAP + COMPACT_H + SHADOW) + .anchor_vertical(Vertical::Top) + .anchor_horizontal(Horizontal::Left) + // Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them + .layer(Layer::Overlay) + .space(Zone::Ignore) + .namespace("magic-workspaces"); + + if here.is_empty() { + return window.visible(false); + } + + let mut slots: Vec> = Vec::new(); + + for workspace in here { + let id = workspace.id(); + let lit = hover.0.get(&id).map_or(0.0, Animation::value); + + // hyprland numbers its workspaces; a name-only one shows its first letter + let label = match workspace.name() { + Some(name) if workspace.index() == 0 => name.chars().next().map_or(String::new(), |letter| letter.to_uppercase().collect()), + _ => 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 }; + + (Color::blend(CLEAR, CHIP_HOVER, lit), Color::blend(resting, TEXT, lit), Weight::Regular) + }; + + slots.push(Box::new( + Rectangle::new() + .width(SLOT) + .height(SLOT) + .radius(Full) + .fill(fill) + .align_child(Center, Center) + .scale(1.0 + 0.08 * lit) + .cursor(Pointer) + .on_hover(move |inside| hovering(id, inside)) + .on_click(move |_| Workspaces::focus(id)) + .child(Text::new(label).size(12.0).weight(weight).color(color)), + )); + } + + let colors = backdrop_colors(&palette); + + 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)) + .padding(INSET) + .align_child(Start, Center) + .child(Row::new(slots).gap(SLOT_GAP).align(Center)); + + // clicks next to the pill reach the windows underneath + let region = InputArea { + x: LEFT as i32, + y: TOP_GAP as i32, + width: width.ceil() as i32, + height: COMPACT_H as i32, + }; + + window.input_region(vec![region]).child( + Rectangle::new() + .width(Parent) + .height(Parent) + .padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: LEFT }) + .align_child(Start, Start) + .child(pill), + ) +} + +/* + * one workspace lit at a time: amane matches hover targets by their place in the frame, + * so a workspace that closes under the pointer never hears it leave + */ +fn hovering(id: i64, inside: bool) { + let mut hover = Hover::write(); + + if inside { + for (other, highlight) in &mut hover.0 { + if *other != id { + highlight.to(0.0); + } + } + } + + hover + .0 + .entry(id) + .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS))) + .to(if inside { 1.0 } else { 0.0 }); +}