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:
@@ -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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user