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.
- **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
+14 -14
View File
@@ -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)
}
+8 -2
View File
@@ -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();
}
+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 });
}