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