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, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone, }; use crate::config::appearance::{motion, overlays, text}; use crate::config::island::pill as island_pill; use crate::config::workspaces::pill; use crate::edge::{self, Edge, Part}; use crate::island::{CLEAR, faded, mix}; use crate::{surface, theme, tray}; // one round slot per workspace, inside the pill's inset, the pill pill::left() px from the monitor's edge, // the tray pill::tray_gap() after it (config.rs: workspaces.*) // 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); the tray only goes with the whole bar let (slide, tray_slide, switched, peeking) = { let edge = Edge::read(); let shown = edge.shown(&monitor.name); (edge::hidden_by(edge.part(Part::Workspaces, &monitor.name), 0.0, edge::hide_h()), edge::hidden_by(shown, 0.0, edge::hide_h()), edge.switched(&monitor.name), shown == 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 step = pill::slot() + pill::slot_gap(); // ease in and out, with a slight stretch in the middle like a drop of ink let eased = |t: f32| t * t * (3.0 - 2.0 * t); /* * hyprland removes an empty workspace the moment it's left. its slot stays for the * slide, fading and closing up, so the row and the pill narrow with the disc's move * instead of jumping first: (where it was, its label, how much of it is left, 1 to 0) */ let gone = switched.filter(|(from, _)| *from > 0 && here.iter().all(|workspace| workspace.id() != *from)).map(|(from, t)| { let at = here.iter().filter(|workspace| i64::from(workspace.index()) < from).count(); (at, from.to_string(), 1.0 - eased(t)) }); let left = gone.as_ref().map_or(0.0, |(_, _, left)| *left); let width = 2.0 * pill::inset() + count * pill::slot() + (count - 1.0).max(0.0) * pill::slot_gap() + left * step; // the window keeps the ghost's full room while it closes, so it isn't resized every frame let room = if gone.is_some() { width + (1.0 - left) * step } else { width }; let window = |window_w: f32| { LayerWindow::new() .width(window_w) .height(island_pill::top_gap() + island_pill::height() + island_pill::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 <= -edge::hide_h() { return window(pill::left() + width + island_pill::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, pill::left() + width + pill::tray_gap()) }; let tray_w = tray.as_ref().map_or(0.0, |(_, tray_w)| pill::tray_gap() + tray_w); let window = window(pill::left() + room + tray_w + island_pill::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. * slots are placed by x in one stack, after a closing ghost by what's left of it */ let theme = theme::now(); let slot_x = |index: usize| index as f32 * step + gone.as_ref().filter(|(at, _, _)| index >= *at).map_or(0.0, |_| left * step); let focused_at = here.iter().position(|workspace| workspace.focused()); let came_from = switched.and_then(|(from, t)| match &gone { Some((at, _, _)) => Some((*at as f32 * step, t)), None => here.iter().position(|workspace| workspace.id() == from).map(|at| (slot_x(at), t)), }); let highlight_x = match (focused_at, came_from) { (Some(to), Some((from_x, t))) => Some(mix(from_x, slot_x(to), eased(t))), (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(); } // the ghost: its number shrinking into the gap it leaves if let Some((at, label, left)) = &gone { slots.push(Box::new( Rectangle::new() .width(pill::slot()) .height(pill::slot()) .align_child(Center, Center) .scale(*left) .translate(*at as f32 * step - (1.0 - left) * pill::slot() / 2.0, 0.0) .child(Text::new(label.as_str()).size(text::body()).color(Color::blend(CLEAR, theme.dim, *left))), )); } for (at, workspace) in here.iter().enumerate() { 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, overlays::chip_hover(), lit), Color::blend(resting, theme.text, lit), Weight::Regular) }; slots.push(Box::new( Rectangle::new() .width(pill::slot()) .height(pill::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)) .translate(slot_x(at), 0.0) .child(Text::new(label).size(text::body()).weight(weight).color(color)), )); } let row_w = width - 2.0 * pill::inset(); // the disc under the focused slot, a little wider while it's on its way, then the slots over it let mut layers: Vec> = Vec::new(); if let Some(x) = highlight_x { layers.push(Box::new(Rectangle::new().width(pill::slot() + stretch).height(pill::slot()).radius(Full).fill(theme.accent).translate(x - stretch / 2.0, 0.0))); } layers.extend(slots); let content = Rectangle::new().width(width).height(island_pill::height()).padding(pill::inset()).align_child(Start, Center).child(Stack::new(layers).width(row_w).height(pill::slot())); let pill = surface::shell(width, island_pill::height(), island_pill::height() / 2.0, &theme.surfaces(), 0.0, content).on_hover(edge::over_bar); // clicks next to the pills reach the windows underneath let mut regions = vec![InputArea { x: pill::left() as i32, y: island_pill::top_gap() as i32, width: width.ceil() as i32, height: island_pill::height() as i32 }]; let mut pills: Vec> = vec![Box::new(pill.translate(0.0, slide))]; // a peek keeps the workspaces in while the bar leaves: the tray slides away on its own if let Some((tray, tray_pill_w)) = tray { regions.push(InputArea { x: (pill::left() + width + pill::tray_gap()) as i32, y: island_pill::top_gap() as i32, width: tray_pill_w.ceil() as i32, height: island_pill::height() as i32 }); pills.push(Box::new(tray.translate(0.0, tray_slide))); } window.input_region(regions).child( Rectangle::new() .width(Parent) .height(Parent) .padding(Padding { top: island_pill::top_gap(), right: 0.0, bottom: 0.0, left: pill::left() }) .align_child(Start, Start) .child(Row::new(pills).gap(pill::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(motion::hover_ms()))) .to(if inside { 1.0 } else { 0.0 }); }