/* * when the bar shows. on a monitor whose workspace shows a fullscreen window, the island, * the workspaces and the control centre's pill stay out of sight until the pointer rests * on that monitor's top edge for DWELL_MS; everywhere else they're always there. some * events bring one part in for a moment even then (a peek): the workspaces when the * workspace changes, the island when the song changes or a player appears; never the * control centre. a workspace change also slides the workspaces' highlight across, and a * new song makes the island pop, shown or peeking. * * hyprland says what changed on its event socket and answers questions on its other one, * so nothing here polls hyprland; the media watch only reads the Media service's state. * the edge strip is one pixel along the top, an Overlay layer like the bar, there only * on fullscreen monitors (where it takes the window's top pixel row) */ use std::collections::{HashMap, HashSet}; use std::f32::consts::PI; use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; use std::path::PathBuf; use std::time::{Duration, Instant}; use std::{env, thread}; use amane::{Animation, Full, Layer, LayerWindow, Media, Monitor, Parent, Rectangle, Service, Vertical, Zone}; use crate::island::{COMPACT_H, SHADOW, TOP_GAP}; use crate::parse; const DWELL_MS: u64 = 300; const HIDE_MS: u64 = 700; const SLIDE_MS: u64 = 220; // how long a peek stays const PEEK_WORKSPACES_MS: u64 = 1600; const PEEK_ISLAND_MS: u64 = 3500; // the island's pop on a new song, and the workspaces' highlight sliding over, in seconds const POP_S: f32 = 0.7; const SWITCH_S: f32 = 0.35; // how far a pill slides up to be out of sight, glow and all pub const HIDE_H: f32 = TOP_GAP + COMPACT_H + SHADOW; // the parts an event may bring in alone #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub enum Part { Island, Workspaces, } #[derive(Default)] pub struct Edge { // the workspace each monitor shows, the focused monitor, and the monitors showing a fullscreen window showing: HashMap, focused: Option, fullscreen: HashSet, // how far the whole bar is in on each monitor, 0 to 1, and where the pointer brought it back shown: HashMap, revealed: Option, // a part brought in by an event: its timeline and when that began peeks: HashMap<(Part, String), (Animation, Instant)>, // when the island last popped, and each monitor's workspace before its last change pops: HashMap<(Part, String), Instant>, switched: HashMap, // the monitor whose top edge the pointer rests on, and whether it's over a part of the bar at_edge: Option, over_bar: bool, // bumped by every arrival and departure, so a timer an earlier one started does nothing moves: u32, } impl Service for Edge { fn new() -> Self { Self::default() } // hyprland's events, for as long as the shell runs; a lost socket is tried again fn listen() { thread::spawn(watch_media); loop { if let Some(events) = socket(".socket2.sock").and_then(|path| UnixStream::connect(path).ok()) { refresh(false); for line in BufReader::new(events).lines() { let Ok(line) = line else { break; }; let event = line.split(">>").next().unwrap_or(""); if matches!( event.trim_end_matches("v2"), "fullscreen" | "workspace" | "focusedmon" | "openwindow" | "closewindow" | "movewindow" | "moveworkspace" | "activespecial" | "monitoradded" | "monitorremoved" ) { refresh(true); } } } thread::sleep(Duration::from_secs(3)); } } } impl Edge { // how far the whole bar is in on this monitor: all the way unless it shows a fullscreen window pub fn shown(&self, monitor: &str) -> f32 { self.shown.get(monitor).map_or(1.0, Animation::value) } // the same for a part an event may bring in alone pub fn part(&self, part: Part, monitor: &str) -> f32 { let peek = self.peeks.get(&(part, monitor.to_string())).map_or(0.0, |(peek, _)| peek.value()); self.shown(monitor).max(peek) } // 0 to 1 and back over POP_S after a new song; the view asks for frames while it's above 0 pub fn pop(&self, part: Part, monitor: &str) -> f32 { let Some(at) = self.pops.get(&(part, monitor.to_string())) else { return 0.0; }; let t = at.elapsed().as_secs_f32() / POP_S; if t < 1.0 { (t * PI).sin() * (1.0 - t * 0.5) } else { 0.0 } } // a workspace change on this monitor still sliding: the workspace it came from, and how far along, 0 to 1 pub fn switched(&self, monitor: &str) -> Option<(i64, f32)> { let (from, at) = self.switched.get(monitor)?; let t = at.elapsed().as_secs_f32() / SWITCH_S; (t < 1.0).then_some((*from, t)) } fn fullscreen_on(&self, monitor: &str) -> bool { self.fullscreen.contains(monitor) } } // hyprland's sockets for this session fn socket(name: &str) -> Option { let runtime = env::var("XDG_RUNTIME_DIR").ok()?; let signature = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; Some(PathBuf::from(runtime).join("hypr").join(signature).join(name)) } // a question to hyprland, as `hyprctl ` would ask it; empty if it can't be asked fn hyprctl(request: &str) -> String { let Some(mut stream) = socket(".socket.sock").and_then(|path| UnixStream::connect(path).ok()) else { return String::new(); }; let mut answer = String::new(); if stream.write_all(request.as_bytes()).is_ok() { let _ = stream.read_to_string(&mut answer); } answer } /* * where every monitor is now: its workspace and whether that shows a fullscreen window. * `announce`: a workspace that changed slides the highlight over and, on a hidden bar, * brings the workspaces in for a moment */ fn refresh(announce: bool) { let monitors = parse::hypr_monitors(&hyprctl("monitors")); if monitors.is_empty() { return; } let fullscreen_workspaces = parse::hypr_fullscreen_workspaces(&hyprctl("clients")); let fullscreen: HashSet = monitors.iter().filter(|(_, workspace, _)| fullscreen_workspaces.contains(workspace)).map(|(name, _, _)| name.clone()).collect(); let focused = monitors.iter().find(|(_, _, focused)| *focused).map(|(name, _, _)| name.clone()); let same = { let edge = Edge::read(); edge.fullscreen == fullscreen && edge.focused == focused && monitors.iter().all(|(name, workspace, _)| edge.showing.get(name) == Some(workspace)) }; if same { return; } let mut edge = Edge::write(); for (name, workspace, _) in &monitors { let before = edge.showing.insert(name.clone(), *workspace); if announce && let Some(before) = before && before != *workspace { edge.switched.insert(name.clone(), (before, Instant::now())); if fullscreen.contains(name) { peek(&mut edge, Part::Workspaces, name); } } } edge.showing.retain(|name, _| monitors.iter().any(|(known, _, _)| known == name)); edge.focused = focused; edge.fullscreen = fullscreen; // the strip goes with the fullscreen window: the pointer can't be on it any more if edge.at_edge.as_ref().is_some_and(|monitor| !edge.fullscreen.contains(monitor)) { edge.at_edge = None; } settle(&mut edge); } // every monitor's bar towards where it belongs: in unless fullscreen, or brought back by the pointer fn settle(edge: &mut Edge) { let monitors: Vec = edge.showing.keys().cloned().collect(); for monitor in monitors { let wanted = !edge.fullscreen_on(&monitor) || edge.revealed.as_deref() == Some(monitor.as_str()); edge.shown.entry(monitor).or_insert_with(|| sliding(1.0)).to(if wanted { 1.0 } else { 0.0 }); } } // one part in for a while; a newer peek of the same part keeps it longer fn peek(edge: &mut Edge, part: Part, monitor: &str) { let key = (part, monitor.to_string()); let began = Instant::now(); // from wherever the part is now: one already showing (the bar on its way out after a switch to a fullscreen workspace) stays put instead of dipping let now = edge.part(part, monitor); let entry = edge.peeks.entry(key.clone()).or_insert_with(|| (sliding(now), began)); if entry.0.value() < now { entry.0 = sliding(now); } entry.0.to(1.0); entry.1 = began; let hold = match part { Part::Island => PEEK_ISLAND_MS, Part::Workspaces => PEEK_WORKSPACES_MS, }; thread::spawn(move || { thread::sleep(Duration::from_millis(hold)); let mut edge = Edge::write(); // under the pointer it stays, until the pointer leaves (hide_soon) if edge.over_bar { return; } if let Some((peeking, at)) = edge.peeks.get_mut(&key) && *at == began { peeking.to(0.0); } }); } /* * a new song or a new player: the island pops on the focused monitor, and comes in for * a moment there if the bar is hidden. the watch reads Media's state a few times a * second; that's memory, not a player */ fn watch_media() { let mut last: Option<(Vec, String)> = None; loop { thread::sleep(Duration::from_millis(300)); let (players, song) = { let media = Media::read(); let players: Vec = media.players().iter().map(|player| player.name().to_string()).collect(); let song = media.active().filter(|player| !player.title().is_empty()).map(|player| format!("{}\u{1f}{}\u{1f}{}", player.name(), player.title(), player.artist())); (players, song) }; // a player between songs has no title for a moment: wait for the next one let Some(song) = song.or_else(|| last.as_ref().map(|(_, song)| song.clone())) else { last = Some((players, String::new())); continue; }; if let Some((known, before)) = &last { let new_player = players.iter().any(|name| !known.contains(name)); let new_song = !before.is_empty() && song != *before; if new_player || new_song { announce_media(); } } last = Some((players, song)); } } fn announce_media() { let mut edge = Edge::write(); let Some(monitor) = edge.focused.clone() else { return; }; edge.pops.insert((Part::Island, monitor.clone()), Instant::now()); if edge.fullscreen_on(&monitor) && edge.revealed.as_deref() != Some(monitor.as_str()) { peek(&mut edge, Part::Island, &monitor); } } // the bar's parts call this from their on_hover pub fn over_bar(inside: bool) { let moves = { let mut edge = Edge::write(); edge.over_bar = inside; edge.moves += 1; edge.moves }; if !inside { hide_soon(moves); } } fn at_edge(monitor: String) -> impl Fn(bool) + 'static { move |inside| { let moves = { let mut edge = Edge::write(); edge.at_edge = inside.then(|| monitor.clone()); edge.moves += 1; edge.moves }; if !inside { hide_soon(moves); return; } let monitor = monitor.clone(); // still resting there after the dwell: this monitor's bar comes back thread::spawn(move || { thread::sleep(Duration::from_millis(DWELL_MS)); let mut edge = Edge::write(); if edge.moves == moves { edge.revealed = Some(monitor); settle(&mut edge); } }); } } // gone again after HIDE_MS off the bar and the edge, unless the pointer came back meanwhile; peeks with it fn hide_soon(moves: u32) { thread::spawn(move || { thread::sleep(Duration::from_millis(HIDE_MS)); let mut edge = Edge::write(); if edge.moves == moves && edge.at_edge.is_none() && !edge.over_bar { edge.revealed = None; for (peeking, _) in edge.peeks.values_mut() { peeking.to(0.0); } settle(&mut edge); } }); } fn sliding(at: f32) -> Animation { Animation::new(at).duration(Duration::from_millis(SLIDE_MS)) } /* * how far a part of the bar slides up out of the monitor: all of `height` at 0, none * at 1. `open` is the part's own card or panel, which keeps it in while it's open */ pub fn hidden_by(shown: f32, open: f32, height: f32) -> f32 { -height * (1.0 - shown.max(open)) } // the strip along the top of a fullscreen monitor; registered before the bar, so the bar lies over it pub fn view(monitor: &Monitor) -> LayerWindow { let window = LayerWindow::new().width(Full).height(1.0).anchor_vertical(Vertical::Top).layer(Layer::Overlay).space(Zone::Ignore).namespace("magic-edge"); // never hidden (see the island's view): empty and click-through where nothing is fullscreen if !Edge::read().fullscreen_on(&monitor.name) { return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); } window.child(Rectangle::new().width(Parent).height(Parent).on_hover(at_edge(monitor.name.clone()))) } /* * keeps windows out from under the bar: an empty strip as tall as the pills reach, * reserving its height on every monitor. Hyprland lets tiled and maximised windows * respect it and fullscreen ones cover it, which is just when the bar stays and when * it hides; it goes with the shell. the bar's own windows ignore what's reserved, so * they stay at the top edge. reads nothing, so it never redraws */ pub fn spacer(_: &Monitor) -> LayerWindow { LayerWindow::new() .width(Full) .height(TOP_GAP + COMPACT_H) .anchor_vertical(Vertical::Top) .layer(Layer::Background) .space(Zone::Reserve) .namespace("magic-reserve") .click_through() .child(Rectangle::new().width(0.0).height(0.0)) }