dfe3867529
Switching from a workspace with the bar shown to a fullscreen one slid the bar away while the workspace peek slid in from hidden, so the pill dipped and came back. A peek now starts from the part's current position, so a shown pill stays still for the peek and then leaves. The tray, which peeks don't show, slides away on its own. Tested on DP-2 (2 -> 3 and back), frame by frame.
439 lines
14 KiB
Rust
439 lines
14 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 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<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)>,
|
|
|
|
// 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("");
|
|
|
|
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<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
|
|
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 => 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>, String)> = None;
|
|
|
|
loop {
|
|
thread::sleep(Duration::from_millis(300));
|
|
|
|
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().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))
|
|
}
|