Files
amane-config/src/workspaces.rs
T
server2 a7c49a16c0 Ethernet details in the panel, smaller maps, no tray in workspace peeks
- control.rs: Menu::Ethernet; the panel morphs into the card's details
  (gathered off the drawing thread first) instead of a separate popup;
  prompt::copy now takes a callback so both use wl-copy the same way
- prompt.rs: the Details popup is gone
- map.rs: 920x460, at most 8 nodes, lighter nearness pull (simulated:
  no label overlaps up to 8 nodes while drifting)
- workspaces.rs: a peek on a workspace change shows the workspaces alone
2026-10-13 05:40:44 +02:00

239 lines
8.7 KiB
Rust

use std::collections::HashMap;
use std::process::Command;
use std::thread;
use std::time::Duration;
use amane::{
Animation, Blend, Center, Color, Full, Horizontal, InputArea, Layer, LayerWindow, Monitor, Padding, Parent, Pointer,
Rectangle, Row, Service, Shadow, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
};
use crate::edge::{self, Edge, HIDE_H, Part};
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded, mix};
use crate::{theme, tray};
// 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;
// between the workspaces' pill and the tray's
const TRAY_GAP: f32 = 8.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 wallpaper's colours like the island. the focused workspace takes the accent, the
* one shown on another monitor a softer accent, empty ones dim; a click switches to it.
* Workspaces follows hyprland's events, so nothing polls
*/
pub fn view(monitor: &Monitor) -> LayerWindow {
theme::begin();
let workspaces = Workspaces::read();
let hover = Hover::read();
// hidden on a fullscreen monitor until wanted, or for a moment on a workspace change (edge.rs)
let (slide, switched, peeking) = {
let edge = Edge::read();
(edge::hidden_by(edge.part(Part::Workspaces, &monitor.name), 0.0, HIDE_H), edge.switched(&monitor.name), edge.shown(&monitor.name) == 0.0)
};
// 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 = |window_w: f32| {
LayerWindow::new()
.width(window_w)
.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")
};
// empty and click-through rather than hidden: see the island's view for why
if here.is_empty() || slide <= -HIDE_H {
return window(LEFT + width + SHADOW).click_through().child(Rectangle::new().width(0.0).height(0.0));
}
// the tray's pill right of this one (tray.rs), when an app has an icon there; a peek at a workspace change shows the workspaces alone
let tray = if peeking { None } else { tray::pill(&monitor.name, LEFT + width + TRAY_GAP) };
let tray_w = tray.as_ref().map_or(0.0, |(_, tray_w)| TRAY_GAP + tray_w);
let window = window(LEFT + width + tray_w + SHADOW);
let mut slots: Vec<Box<dyn Widget>> = Vec::new();
/*
* the focused workspace's accent is one disc under the row, so a change slides it from
* the workspace before to this one (edge.rs times it); the slots themselves stay clear
*/
let theme = theme::now();
let slot_x = |index: usize| index as f32 * (SLOT + SLOT_GAP);
let focused_at = here.iter().position(|workspace| workspace.focused());
let came_from = switched.and_then(|(from, t)| here.iter().position(|workspace| workspace.id() == from).map(|at| (at, t)));
let highlight_x = match (focused_at, came_from) {
(Some(to), Some((from, t))) => {
// ease in and out, with a slight stretch in the middle like a drop of ink
let eased = t * t * (3.0 - 2.0 * t);
Some(mix(slot_x(from), slot_x(to), eased))
}
(Some(to), None) => Some(slot_x(to)),
_ => None,
};
let stretch = came_from.map_or(0.0, |(_, t)| (t * std::f32::consts::PI).sin() * 10.0);
if switched.is_some() {
amane::request_frame();
}
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() {
// the label turns as the disc arrives
let arrived = switched.map_or(1.0, |(_, t)| t);
(CLEAR, Color::blend(theme.text, theme.on_accent, arrived), Weight::Bold)
} else if workspace.active() {
(faded(theme.accent, 70), theme.accent, Weight::Bold)
} else {
let resting = if workspace.windows() > 0 { theme.text } else { theme.dim };
(Color::blend(CLEAR, CHIP_HOVER, lit), Color::blend(resting, theme.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 |_| focus(id))
.child(Text::new(label).size(12.0).weight(weight).color(color)),
));
}
let row_w = width - 2.0 * INSET;
// the disc under the focused slot, a little wider while it's on its way
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
if let Some(x) = highlight_x {
layers.push(Box::new(Rectangle::new().width(SLOT + stretch).height(SLOT).radius(Full).fill(theme.accent).translate(x - stretch / 2.0, 0.0)));
}
layers.push(Box::new(Row::new(slots).gap(SLOT_GAP).align(Center)));
let pill = Rectangle::new()
.width(width)
.height(COMPACT_H)
.radius(Full)
.fill(backdrop(&theme.surfaces(), 255))
.border(1.0, theme.outline)
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(INSET)
.align_child(Start, Center)
.on_hover(edge::over_bar)
.child(Stack::new(layers).width(row_w).height(SLOT));
// clicks next to the pills reach the windows underneath
let mut regions = vec![InputArea { x: LEFT as i32, y: TOP_GAP as i32, width: width.ceil() as i32, height: COMPACT_H as i32 }];
let mut pills: Vec<Box<dyn Widget>> = vec![Box::new(pill)];
if let Some((tray, tray_pill_w)) = tray {
regions.push(InputArea { x: (LEFT + width + TRAY_GAP) as i32, y: TOP_GAP as i32, width: tray_pill_w.ceil() as i32, height: COMPACT_H as i32 });
pills.push(Box::new(tray));
}
window.input_region(regions).child(
Rectangle::new()
.width(Parent)
.height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: LEFT })
.align_child(Start, Start)
.translate(0.0, slide)
.child(Row::new(pills).gap(TRAY_GAP).align(Start)),
)
}
/*
* switches to a workspace. amane's Workspaces::focus sends `dispatch workspace N`,
* which hyprland 0.56 with a lua config turns into hl.dispatch(workspace N) and
* rejects; this is the lua form the user's own keybinds use. on its own thread,
* so hyprctl never holds up drawing. named workspaces (negative ids) aren't
* switched: their lua form is unverified
*/
fn focus(id: i64) {
if id < 0 {
return;
}
thread::spawn(move || {
let _ = Command::new("hyprctl").arg("dispatch").arg(format!("hl.dsp.focus({{ workspace = {id} }})")).output();
});
}
/*
* 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 });
}