5d322b940d
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).
262 lines
11 KiB
Rust
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 });
|
|
}
|