Files
amane-config/src/edge.rs
T

461 lines
16 KiB
Rust

/*
* 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 hiding::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::config::bar::{hiding, peeks};
use crate::config::island::pill;
use crate::parse;
// how far a pill slides up to be out of sight, glow and all
pub fn hide_h() -> f32 {
pill::top_gap() + pill::height() + pill::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<String, i64>,
focused: Option<String>,
fullscreen: HashSet<String>,
// how far the whole bar is in on each monitor, 0 to 1, and where the pointer brought it back
shown: HashMap<String, Animation>,
revealed: Option<String>,
// 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<String, (i64, Instant)>,
// workspaces hyprland made a moment ago (by switching to a new one), by id
created: HashMap<i64, Instant>,
// the monitor whose top edge the pointer rests on, and whether it's over a part of the bar
at_edge: Option<String>,
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("");
// createworkspacev2>>ID,NAME
if event == "createworkspacev2"
&& let Some(id) = line.split(">>").nth(1).and_then(|data| data.split(',').next()).and_then(|id| id.parse::<i64>().ok())
{
let mut edge = Edge::write();
edge.created.retain(|_, at| at.elapsed().as_secs_f32() < peeks::switch_s());
edge.created.insert(id, Instant::now());
}
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 peeks::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() / peeks::pop_s().max(0.01);
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() / peeks::switch_s().max(0.01);
(t < 1.0).then_some((*from, t))
}
// how far a workspace hyprland just made has grown into the row, 0 to 1 over the switch; 1 for the rest
pub fn grown(&self, id: i64) -> f32 {
self.created.get(&id).map_or(1.0, |at| (at.elapsed().as_secs_f32() / peeks::switch_s().max(0.01)).min(1.0))
}
fn fullscreen_on(&self, monitor: &str) -> bool {
self.fullscreen.contains(monitor)
}
}
// hyprland's sockets for this session
fn socket(name: &str) -> Option<PathBuf> {
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 <request>` would ask it; empty if it can't be asked
pub 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<String> = 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<String> = 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 => peeks::island_ms(),
Part::Workspaces => peeks::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>, String)> = None;
loop {
thread::sleep(Duration::from_millis(peeks::media_check_ms()));
let (players, song) = {
let media = Media::read();
let players: Vec<String> = 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().find(|name| !known.contains(name));
let new_song = !before.is_empty() && song != *before;
// the one that changed: a new player, or the one whose song this is
let changed = new_player.cloned().or_else(|| new_song.then(|| song.split('\u{1f}').next().unwrap_or("").to_string()));
if let Some(player) = changed
&& !in_front(&player)
{
announce_media();
}
}
last = Some((players, song));
}
}
// whether the focused window is this player's app: then its song change is in plain sight
fn in_front(player: &str) -> bool {
let window = parse::json(&hyprctl("j/activewindow"));
let class = window.first().and_then(|window| window.get("class")).and_then(parse::Value::text).unwrap_or("");
parse::same_app(class, player)
}
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(hiding::dwell_ms()));
let mut edge = Edge::write();
if edge.moves == moves {
edge.revealed = Some(monitor);
settle(&mut edge);
}
});
}
}
// gone again after hiding::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(hiding::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(hiding::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(pill::top_gap() + pill::height())
.anchor_vertical(Vertical::Top)
.layer(Layer::Background)
.space(Zone::Reserve)
.namespace("magic-reserve")
.click_through()
.child(Rectangle::new().width(0.0).height(0.0))
}