Files
amane-config/src/workspaces.rs
T
server2 5d322b940d Glass designs as settings: solid, frosted, liquid on every surface
appearance.surface.design picks the look (chips in the config window); each
design's opacity, rim and light are settings. surface::shell now draws the
island, idle pill, workspace and tray pills, tray menu, control centre and
popup, so a design applies everywhere. On see-through glass faces fade with
.opacity(). Shell text settings can list their allowed words (chips).
2026-10-15 03:05:06 +02:00

262 lines
11 KiB
Rust

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<i64, Animation>);
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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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 });
}