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
+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 });
}