workspaces: a pill top left on every monitor with its workspaces

- one window per monitor (namespace magic-workspaces, Overlay like the
  island), showing that monitor's hyprland workspaces in the island's
  look: cover gradient, pink glow
- focused workspace pink, shown-elsewhere soft pink, empty ones dim;
  hover eases in (one at a time), a click switches workspace
- special workspaces are skipped; name-only ones show their first letter
- event-driven through Workspaces: redraws only on hyprland events and
  hover
This commit is contained in:
server2
2026-10-08 02:09:25 +02:00
parent ddaef1f805
commit 9329d250f6
4 changed files with 178 additions and 16 deletions
+2
View File
@@ -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. - **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. - **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.
## Layout ## 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/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/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/parse.rs pure text parsing with unit tests, no amane dependency src/parse.rs pure text parsing with unit tests, no amane dependency
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
+14 -14
View File
@@ -25,7 +25,7 @@ const PILL_GONE: f32 = 0.18;
const SHAPE_DONE: f32 = 0.6; const SHAPE_DONE: f32 = 0.6;
// hover highlights ease in and out over this long; the pill widens by PILL_HOVER_GROW // 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; const PILL_HOVER_GROW: f32 = 10.0;
// how much thicker, in px, the transport icons get when hovered // how much thicker, in px, the transport icons get when hovered
@@ -33,14 +33,14 @@ const BOLD: f32 = 2.0;
// sizes, in pixels // sizes, in pixels
const COMPACT_W: f32 = 220.0; 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_W: f32 = 560.0;
const WIDE_RADIUS: f32 = 28.0; const WIDE_RADIUS: f32 = 28.0;
const PAD: f32 = 22.0; const PAD: f32 = 22.0;
const INNER_W: f32 = WIDE_W - 2.0 * PAD; const INNER_W: f32 = WIDE_W - 2.0 * PAD;
const GAP: f32 = 16.0; const GAP: f32 = 16.0;
const TOP_GAP: f32 = 6.0; pub const TOP_GAP: f32 = 6.0;
const SHADOW: f32 = 34.0; pub const SHADOW: f32 = 34.0;
// the cd: its box, the cover, the hole, and how far the candles reach past it // the cd: its box, the cover, the hole, and how far the candles reach past it
const DISC_BOX: f32 = 204.0; 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) // 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;
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 PINK_DEEP: Color = Color::rgb(150, 82, 122);
const INK: Color = Color::rgb(40, 18, 32); pub const INK: Color = Color::rgb(40, 18, 32);
const TEXT: Color = Color::rgb(241, 236, 246); pub const TEXT: Color = Color::rgb(241, 236, 246);
const DIM: Color = Color::rgba(205, 192, 218, 160); 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
const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46); pub const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46);
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);
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 SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18);
const MENU_BG: Color = Color::rgb(35, 26, 44); 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 // 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 first = palette.dominant();
let second = palette.colors().get(1).copied().unwrap_or(first); 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 * 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 * 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))]) 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) 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) Color::rgba(color.red(), color.green(), color.blue(), alpha)
} }
+8 -2
View File
@@ -3,6 +3,7 @@ mod island;
mod parse; mod parse;
mod sound; mod sound;
mod spectrum; mod spectrum;
mod workspaces;
use amane::{App, Palette, Service}; 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 // the card's glow follows the cover art, which art::Art copies to this one path
Palette::write().open(&art::palette_file(), 8); Palette::write().open(&art::palette_file(), 8);
// one island on every monitor; the card opens only on one of them // 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).run(); App::new()
.font("Fira Code")
.ipc("island", island::command)
.window_per_monitor(island::view)
.window_per_monitor(workspaces::view)
.run();
} }
+154
View File
@@ -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<i64, Animation>);
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<Box<dyn Widget>> = 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 });
}