713 lines
26 KiB
Rust
713 lines
26 KiB
Rust
use std::collections::HashMap;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
use amane::{
|
|
Animation, Blend, Center, Color, Easing, Full, Horizontal, InputArea, Key, Keyboard, Layer, LayerWindow, Margin, Monitor, Parent, Pointer, Rectangle, Row,
|
|
Service, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Zone,
|
|
};
|
|
|
|
use crate::config::appearance::{motion, overlays, text};
|
|
use crate::config::island::pill as island_pill;
|
|
use crate::config::notifications::history;
|
|
use crate::config::pills::{self as settings, colors as hues, layout};
|
|
use crate::edge::{self, Edge};
|
|
use crate::glyph::{Glyph, icon};
|
|
use crate::island::{self, CLEAR, mix, phases, veiled};
|
|
use crate::parse::Sky;
|
|
use crate::{ai, home, notices, stats, surface, theme};
|
|
|
|
/*
|
|
* four small one-icon pills beside the island, each opening into a big card in the
|
|
* middle of the top of the screen. while a card is open the row becomes a carousel:
|
|
* every other pill and the island's pill line up left and right of the card in their
|
|
* usual order ([AI][System] island [Home][Bell]), and clicking one switches to its card.
|
|
* a closing card shrinks back into its own pill's place.
|
|
*
|
|
* one window per monitor (magic-pods), as wide as the monitor and anchored to its left
|
|
* edge, so it never moves: only its height changes, at the top edge. while a card is
|
|
* open it also draws the island's pill (island::pill_face), which the island's own
|
|
* window leaves out meanwhile (holds_island); the two overlap for a moment at each
|
|
* hand-over, at the same place, so the pill never blinks
|
|
*/
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
|
pub enum Pod {
|
|
Ai,
|
|
Monitor,
|
|
Home,
|
|
Notifications,
|
|
}
|
|
|
|
// the row, left to right
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum Item {
|
|
Pod(Pod),
|
|
Island,
|
|
}
|
|
|
|
const ROW: [Item; 5] = [Item::Pod(Pod::Ai), Item::Pod(Pod::Monitor), Item::Island, Item::Pod(Pod::Home), Item::Pod(Pod::Notifications)];
|
|
|
|
const PODS: [Pod; 4] = [Pod::Ai, Pod::Monitor, Pod::Home, Pod::Notifications];
|
|
|
|
impl Pod {
|
|
fn glyph(self) -> Glyph {
|
|
match self {
|
|
Self::Ai => Glyph::Sparkle,
|
|
Self::Monitor => Glyph::Pulse,
|
|
Self::Home => Glyph::House,
|
|
Self::Notifications => Glyph::Bell,
|
|
}
|
|
}
|
|
|
|
// the open card's size
|
|
fn size(self) -> (f32, f32) {
|
|
match self {
|
|
Self::Ai => (settings::ai::width(), settings::ai::height()),
|
|
Self::Monitor => (settings::monitor::width(), stats::height()),
|
|
Self::Home => (settings::home::width(), home::height()),
|
|
Self::Notifications => (history::width(), history::height()),
|
|
}
|
|
}
|
|
|
|
// its own colour; home's follows the sky
|
|
fn hue(self) -> Color {
|
|
match self {
|
|
Self::Ai => hues::ai(),
|
|
Self::Monitor => hues::monitor(),
|
|
Self::Notifications => hues::notifications(),
|
|
Self::Home => match home::sky() {
|
|
Some((Sky::Clear | Sky::PartlyCloudy, true)) => hues::home_clear_day(),
|
|
Some((Sky::Clear | Sky::PartlyCloudy, false)) => hues::home_clear_night(),
|
|
Some((Sky::Cloudy, _)) | None => hues::home_cloudy(),
|
|
Some((Sky::Fog, _)) => hues::home_fog(),
|
|
Some((Sky::Rain, _)) => hues::home_rain(),
|
|
Some((Sky::Snow, _)) => hues::home_snow(),
|
|
Some((Sky::Storm, _)) => hues::home_storm(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// whether its card takes typing: then it has the keyboard and stays open when the pointer leaves
|
|
fn types(self) -> bool {
|
|
self == Self::Ai || (self == Self::Home && home::typing())
|
|
}
|
|
|
|
fn word(self) -> &'static str {
|
|
match self {
|
|
Self::Ai => "ai",
|
|
Self::Monitor => "monitor",
|
|
Self::Home => "home",
|
|
Self::Notifications => "notifications",
|
|
}
|
|
}
|
|
|
|
fn from_word(word: &str) -> Option<Self> {
|
|
PODS.into_iter().find(|pod| pod.word() == word)
|
|
}
|
|
}
|
|
|
|
// the wallpaper's surfaces with some of a colour mixed in, keeping their see-through
|
|
fn tinted(colors: &[Color; 3], hue: Color, amount: f32) -> [Color; 3] {
|
|
colors.map(|color| Color::blend(color, Color::rgba(hue.red(), hue.green(), hue.blue(), color.alpha()), amount))
|
|
}
|
|
|
|
pub struct Pods {
|
|
// the card open or closing, and the monitor it's on; `shown` whether it's opening or open
|
|
card: Option<(Pod, String)>,
|
|
shown: bool,
|
|
timeline: Animation,
|
|
|
|
// a switch from one open card straight to another: the one before, and the switch's progress
|
|
previous: Option<Pod>,
|
|
switch: Animation,
|
|
|
|
hover: HashMap<Pod, Animation>,
|
|
|
|
// the pointer over the card, and a count every arrival and departure bumps, so a late close does nothing
|
|
inside: bool,
|
|
moves: u32,
|
|
}
|
|
|
|
impl Service for Pods {
|
|
fn new() -> Self {
|
|
Self { card: None, shown: false, timeline: timeline(), previous: None, switch: switching(1.0), hover: HashMap::new(), inside: false, moves: 0 }
|
|
}
|
|
|
|
// changes only through input
|
|
fn listen() {}
|
|
}
|
|
|
|
fn timeline() -> Animation {
|
|
Animation::new(0.0).duration(Duration::from_millis(layout::open_ms())).easing(Easing::Linear)
|
|
}
|
|
|
|
fn switching(from: f32) -> Animation {
|
|
Animation::new(from).duration(Duration::from_millis(layout::open_ms())).easing(Easing::InOut)
|
|
}
|
|
|
|
pub fn open(pod: Pod, monitor: &str) {
|
|
island::collapse();
|
|
|
|
let mut pods = Pods::write();
|
|
|
|
let open_here = pods.shown && pods.timeline.value() > 0.5;
|
|
let current = pods.card.as_ref().filter(|(_, on)| on == monitor).map(|(open, _)| *open);
|
|
|
|
match current {
|
|
Some(open) if open == pod => {}
|
|
// another card while one is open here: the open one turns into it
|
|
Some(open) if open_here => {
|
|
pods.previous = Some(open);
|
|
pods.switch = switching(0.0);
|
|
pods.switch.to(1.0);
|
|
pods.card = Some((pod, monitor.to_string()));
|
|
}
|
|
// another card, or one on another monitor: it grows from its own pill
|
|
_ => {
|
|
pods.card = Some((pod, monitor.to_string()));
|
|
pods.timeline = timeline();
|
|
pods.previous = None;
|
|
pods.switch = switching(1.0);
|
|
}
|
|
}
|
|
|
|
pods.shown = true;
|
|
pods.inside = true;
|
|
pods.timeline.to(1.0);
|
|
|
|
drop(pods);
|
|
|
|
if pod == Pod::Notifications {
|
|
notices::mark_read();
|
|
}
|
|
}
|
|
|
|
pub fn close() {
|
|
let mut pods = Pods::write();
|
|
|
|
if pods.shown {
|
|
pods.shown = false;
|
|
pods.timeline.to(0.0);
|
|
}
|
|
}
|
|
|
|
fn toggle(pod: Pod, monitor: &str) {
|
|
let open_here = {
|
|
let pods = Pods::read();
|
|
|
|
pods.shown && pods.card.as_ref().is_some_and(|(open, on)| *open == pod && on == monitor)
|
|
};
|
|
|
|
if open_here { close() } else { open(pod, monitor) }
|
|
}
|
|
|
|
// the open card's pod, if any
|
|
pub fn open_card() -> Option<Pod> {
|
|
let pods = Pods::read();
|
|
|
|
pods.card.as_ref().filter(|_| pods.shown).map(|(pod, _)| *pod)
|
|
}
|
|
|
|
/*
|
|
* whether this window draws the island's pill on `monitor` now: a little past the start
|
|
* of opening and until a little before the end of closing. this window draws it from
|
|
* the first frame to the last, so the two overlap at each hand-over, at the same place
|
|
*/
|
|
pub fn holds_island(monitor: &str) -> bool {
|
|
let pods = Pods::read();
|
|
|
|
pods.card.as_ref().is_some_and(|(_, on)| on == monitor) && pods.timeline.value() > HAND_OVER
|
|
}
|
|
|
|
// a share of the open timeline (420 ms by default: 0.08 is about two frames)
|
|
const HAND_OVER: f32 = 0.08;
|
|
|
|
fn hovering(pod: Pod) -> impl Fn(bool) + 'static {
|
|
move |inside| {
|
|
edge::over_bar(inside);
|
|
|
|
Pods::write()
|
|
.hover
|
|
.entry(pod)
|
|
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms())))
|
|
.to(if inside { 1.0 } else { 0.0 });
|
|
}
|
|
}
|
|
|
|
// the pointer on or off a card; leaving one that takes no typing closes it a moment later
|
|
fn over_card(inside: bool) {
|
|
edge::over_bar(inside);
|
|
|
|
let moves = {
|
|
let mut pods = Pods::write();
|
|
|
|
pods.inside = inside;
|
|
pods.moves += 1;
|
|
pods.moves
|
|
};
|
|
|
|
if inside {
|
|
return;
|
|
}
|
|
|
|
thread::spawn(move || {
|
|
thread::sleep(Duration::from_millis(400));
|
|
|
|
let leave = {
|
|
let pods = Pods::read();
|
|
|
|
pods.moves == moves && !pods.inside && pods.card.as_ref().is_some_and(|(pod, _)| !pod.types())
|
|
};
|
|
|
|
if leave {
|
|
close();
|
|
}
|
|
});
|
|
}
|
|
|
|
// the island's pill in the carousel: the card closes back into place, then the island opens
|
|
fn island_tapped(monitor: String) {
|
|
close();
|
|
|
|
thread::spawn(move || {
|
|
thread::sleep(Duration::from_millis(layout::open_ms()));
|
|
|
|
island::tap(&monitor);
|
|
});
|
|
}
|
|
|
|
fn item_width(item: Item) -> f32 {
|
|
match item {
|
|
Item::Pod(_) => island_pill::height(),
|
|
Item::Island => island_pill::width(),
|
|
}
|
|
}
|
|
|
|
// an item's left edge in the row with nothing open, around the island as wide as it is now
|
|
fn closed_x(item: Item, middle: f32, island_w: f32) -> f32 {
|
|
let (h, spacing, gap) = (island_pill::height(), layout::spacing(), layout::gap());
|
|
|
|
match item {
|
|
Item::Island => middle - island_pill::width() / 2.0,
|
|
Item::Pod(Pod::Monitor) => middle - island_w / 2.0 - gap - h,
|
|
Item::Pod(Pod::Ai) => middle - island_w / 2.0 - gap - 2.0 * h - spacing,
|
|
Item::Pod(Pod::Home) => middle + island_w / 2.0 + gap,
|
|
Item::Pod(Pod::Notifications) => middle + island_w / 2.0 + gap + h + spacing,
|
|
}
|
|
}
|
|
|
|
// an item's left edge in the carousel around `open`'s card, `card_w` wide, in the middle
|
|
fn carousel_x(item: Item, open: Pod, card_w: f32, middle: f32) -> f32 {
|
|
let at = |wanted: Item| ROW.iter().position(|other| *other == wanted).unwrap_or(0);
|
|
let (index, centre) = (at(item), at(Item::Pod(open)));
|
|
let (spacing, gap) = (layout::spacing(), layout::gap());
|
|
|
|
if index == centre {
|
|
return middle - card_w / 2.0;
|
|
}
|
|
|
|
if index < centre {
|
|
// leftwards from the card, past the items between
|
|
let between: f32 = ROW[index + 1..centre].iter().map(|other| item_width(*other) + spacing).sum();
|
|
|
|
middle - card_w / 2.0 - gap - between - item_width(item)
|
|
} else {
|
|
let between: f32 = ROW[centre + 1..index].iter().map(|other| item_width(*other) + spacing).sum();
|
|
|
|
middle + card_w / 2.0 + gap + between
|
|
}
|
|
}
|
|
|
|
// what both windows need to know about this monitor's card this frame
|
|
struct Scene {
|
|
// the pod and its timeline, while it's at all open here
|
|
card: Option<(Pod, f32)>,
|
|
grow: f32,
|
|
pill_shown: f32,
|
|
reveal: f32,
|
|
switch: f32,
|
|
previous: Option<Pod>,
|
|
middle: f32,
|
|
island_w: f32,
|
|
// the island's pill starts moving only after the hand-over (see holds_island), so it never shows twice
|
|
island_grow: f32,
|
|
}
|
|
|
|
fn scene(state: &Pods, monitor: &Monitor) -> Scene {
|
|
let here = monitor.name.as_str();
|
|
let card = state.card.as_ref().filter(|(_, on)| on == here).map(|(pod, _)| (*pod, state.timeline.value())).filter(|(_, t)| *t > 0.0);
|
|
let (grow, pill_shown, reveal) = card.map_or((0.0, 1.0, 0.0), |(_, t)| phases(t));
|
|
|
|
// read only here: a running animation redraws every window that reads it
|
|
let switch = if card.is_some() { state.switch.value() } else { 1.0 };
|
|
let previous = state.previous.filter(|_| switch < 1.0 && card.is_some());
|
|
|
|
let island_grow = card.map_or(0.0, |(_, t)| phases(((t - 2.0 * HAND_OVER) / (1.0 - 2.0 * HAND_OVER)).max(0.0)).0);
|
|
|
|
Scene { card, grow, pill_shown, reveal, switch, previous, middle: monitor.width as f32 / 2.0, island_w: island::width_on(here), island_grow }
|
|
}
|
|
|
|
impl Scene {
|
|
// an item's left edge on the monitor: between the closed row and the carousel by `grow`, between two carousels by `switch`
|
|
fn place(&self, item: Item) -> f32 {
|
|
let closed = closed_x(item, self.middle, self.island_w);
|
|
|
|
let Some((open, _)) = self.card else { return closed };
|
|
|
|
let now = carousel_x(item, open, open.size().0, self.middle);
|
|
let open_x = match self.previous {
|
|
Some(before) => mix(carousel_x(item, before, before.size().0, self.middle), now, self.switch),
|
|
None => now,
|
|
};
|
|
|
|
mix(closed, open_x, if item == Item::Island { self.island_grow } else { self.grow })
|
|
}
|
|
}
|
|
|
|
/*
|
|
* the row of pills: as wide as the monitor and as tall as a pill, never changing. amane
|
|
* repaints a whole window every frame and hyprland then recomposites (and re-blurs)
|
|
* everything under it, so a window as tall as the cards ran the animations at 15 fps;
|
|
* the cards have their own window (card_view)
|
|
*/
|
|
pub fn row_view(monitor: &Monitor) -> LayerWindow {
|
|
theme::begin();
|
|
|
|
let here = monitor.name.as_str();
|
|
let wallpaper = theme::now().surfaces();
|
|
let state = Pods::read();
|
|
let scene = scene(&state, monitor);
|
|
let (h, top, shadow) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow());
|
|
|
|
// with the bar on a fullscreen monitor, unless a card is open
|
|
let slide = if scene.card.is_some() { 0.0 } else { edge::hidden_by(Edge::read().shown(here), 0.0, edge::hide_h()) };
|
|
|
|
let window = LayerWindow::new()
|
|
.width(monitor.width as f32)
|
|
.height(top + h + shadow)
|
|
.anchor_vertical(Vertical::Top)
|
|
.anchor_horizontal(Horizontal::Left)
|
|
.margin(Margin { top: 0, right: 0, bottom: 0, left: 0 })
|
|
.layer(Layer::Overlay)
|
|
.space(Zone::Ignore)
|
|
.namespace("magic-pods");
|
|
|
|
// empty and click-through rather than hidden: see the island's view for why
|
|
if slide <= -edge::hide_h() {
|
|
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
|
}
|
|
|
|
let (card, previous, switch) = (scene.card, scene.previous, scene.switch);
|
|
|
|
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
|
let mut regions: Vec<InputArea> = Vec::new();
|
|
|
|
for item in ROW {
|
|
let x = scene.place(item);
|
|
|
|
match item {
|
|
Item::Island => {
|
|
// the island's own window draws its pill while nothing is open here
|
|
if card.is_none() {
|
|
continue;
|
|
}
|
|
|
|
let on = here.to_string();
|
|
let pill = island::pill_face(here).cursor(Pointer).on_hover(edge::over_bar).on_click(move |_| island_tapped(on.clone()));
|
|
|
|
regions.push(InputArea { x: x as i32, y: top as i32, width: island_pill::width().ceil() as i32, height: h.ceil() as i32 });
|
|
layers.push(Box::new(pill.translate(x, top)));
|
|
}
|
|
Item::Pod(pod) => {
|
|
// the open one is the card, drawn last. in a switch, the one switched to slides into
|
|
// the card and fades over the first half; the one switched away from fades back in as a pill
|
|
let shown = match previous {
|
|
Some(_) if card.is_some_and(|(open, _)| open == pod) => 1.0 - 2.0 * switch,
|
|
// its card is in the card window, which starts drawing at HAND_OVER; the pill stays a little longer under it
|
|
_ if card.is_some_and(|(open, t)| open == pod && t > 2.0 * HAND_OVER) => continue,
|
|
Some(before) if before == pod => switch,
|
|
_ => 1.0,
|
|
};
|
|
|
|
if shown <= 0.0 {
|
|
continue;
|
|
}
|
|
|
|
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
|
let pill = pill(pod, lit, &wallpaper, here);
|
|
let pill = if shown < 1.0 { pill.opacity(shown) } else { pill };
|
|
|
|
regions.push(InputArea { x: x as i32, y: (top + slide) as i32, width: h.ceil() as i32, height: h.ceil() as i32 });
|
|
layers.push(Box::new(pill.translate(x, top + slide)));
|
|
}
|
|
}
|
|
}
|
|
|
|
window.input_region(regions).child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent)))
|
|
}
|
|
|
|
/*
|
|
* the open card, in the middle: a window as big as the biggest card while one is open
|
|
* here, and an empty pixel otherwise. it changes size only while it shows nothing (the
|
|
* card starts and ends as its pill, which the row window draws under it meanwhile), so
|
|
* hyprland's squashed first frame at a new size is never seen
|
|
*/
|
|
pub fn card_view(monitor: &Monitor) -> LayerWindow {
|
|
theme::begin();
|
|
|
|
let wallpaper = theme::now().surfaces();
|
|
let state = Pods::read();
|
|
let scene = scene(&state, monitor);
|
|
let (h, top, shadow) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow());
|
|
|
|
let window = |width: f32, height: f32| LayerWindow::new().width(width).height(height).anchor_vertical(Vertical::Top).layer(Layer::Overlay).space(Zone::Ignore).namespace("magic-pods-card");
|
|
|
|
let Some((pod, t)) = scene.card else {
|
|
return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0));
|
|
};
|
|
|
|
let widest = PODS.iter().map(|pod| pod.size().0).fold(h, f32::max);
|
|
let tallest = [settings::ai::height(), stats::tallest(), home::height(), history::height()].into_iter().fold(h, f32::max);
|
|
let window_w = (widest + 2.0 * shadow).round();
|
|
|
|
// centred by hyprland: its left edge on the monitor
|
|
let left = ((monitor.width as f32 - window_w) / 2.0).floor();
|
|
let window = window(window_w, top + tallest + shadow);
|
|
|
|
// the first frames at the new size show at the old place, so they stay empty (the row draws the pill meanwhile)
|
|
if t <= HAND_OVER {
|
|
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
|
}
|
|
|
|
let (grow, pill_shown, reveal, switch, previous, middle) = (scene.grow, scene.pill_shown, scene.reveal, scene.switch, scene.previous, scene.middle);
|
|
|
|
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
|
let mut regions: Vec<InputArea> = Vec::new();
|
|
|
|
let typing = pod.types() && state.shown;
|
|
|
|
{
|
|
let (card_w, card_h) = pod.size();
|
|
|
|
// a switch morphs the card from the one before: its size, its colour, and its face crossing over
|
|
let (full_w, full_h, hue) = match previous {
|
|
Some(before) => {
|
|
let (before_w, before_h) = before.size();
|
|
|
|
(mix(before_w, card_w, switch), mix(before_h, card_h, switch), Color::blend(before.hue(), pod.hue(), switch))
|
|
}
|
|
None => (card_w, card_h, pod.hue()),
|
|
};
|
|
|
|
// from its pill's place in the closed row to the middle, and back into that place; in the window
|
|
let from_x = closed_x(Item::Pod(pod), middle, scene.island_w) - left;
|
|
let to_x = middle - full_w / 2.0 - left;
|
|
|
|
let width = mix(h, full_w, grow);
|
|
let height = mix(h, full_h, grow);
|
|
let x = mix(from_x, to_x, grow);
|
|
let radius = mix(h / 2.0, layout::radius(), grow);
|
|
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
|
let colors = tinted(&wallpaper, hue, mix(hues::pill_mix(), hues::card_mix(), grow));
|
|
|
|
let face: Rectangle = match previous {
|
|
// the card before fades out over the first half of the switch, this one in over the second
|
|
Some(before) if switch < 0.5 => {
|
|
let (before_w, before_h) = before.size();
|
|
|
|
veiled(content(before, before_w, before_h), width, height, radius, 1.0 - 2.0 * switch, &colors)
|
|
}
|
|
Some(_) => veiled(content(pod, card_w, card_h), width, height, radius, 2.0 * switch - 1.0, &colors),
|
|
None if reveal > 0.0 => veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors),
|
|
None => {
|
|
// the pill's icon fading as the shape grows
|
|
let tint = Color::blend(CLEAR, pod.hue(), pill_shown);
|
|
|
|
Rectangle::new().width(h).height(h).align_child(Center, Center).child(icon(pod.glyph(), layout::icon(), tint))
|
|
}
|
|
};
|
|
|
|
let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face);
|
|
let shape = surface::shell(width, height, radius, &colors, lit * pill_shown, face).on_hover(over_card).translate(x, top);
|
|
|
|
regions.push(InputArea { x: x as i32, y: top as i32, width: width.ceil() as i32, height: height.ceil() as i32 });
|
|
layers.push(Box::new(shape));
|
|
}
|
|
|
|
let window = window.input_region(regions);
|
|
|
|
let window = if typing {
|
|
window.keyboard(Keyboard::OnDemand).on_key(|key| {
|
|
// Esc closes the home card's event form first, then the card
|
|
if key == Key::Escape && !home::escape() {
|
|
close();
|
|
}
|
|
})
|
|
} else {
|
|
window
|
|
};
|
|
|
|
window.child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent)))
|
|
}
|
|
|
|
// a small pill in its own colour: the surface with one icon; the bell carries the unread count
|
|
fn pill(pod: Pod, lit: f32, wallpaper: &[Color; 3], monitor: &str) -> Rectangle {
|
|
let h = island_pill::height();
|
|
let theme = theme::now();
|
|
let hue = pod.hue();
|
|
let colors = tinted(wallpaper, hue, hues::pill_mix());
|
|
|
|
let glyph = icon(pod.glyph(), layout::icon() * (1.0 + 0.08 * lit), Color::blend(hue, theme.text, 0.25 + 0.5 * lit));
|
|
let mut inside: Vec<Box<dyn Widget>> = vec![Box::new(Rectangle::new().width(h).height(h).align_child(Center, Center).child(glyph))];
|
|
|
|
let unread = if pod == Pod::Notifications { notices::unread() } else { 0 };
|
|
|
|
if unread > 0 {
|
|
let badge = text::tiny() + 6.0;
|
|
let words = if unread > 9 { String::from("9+") } else { unread.to_string() };
|
|
|
|
inside.push(Box::new(
|
|
Rectangle::new()
|
|
.width(badge)
|
|
.height(badge)
|
|
.radius(Full)
|
|
.fill(hue)
|
|
.align_child(Center, Center)
|
|
.translate(h - badge - 3.0, 3.0)
|
|
.child(Text::new(words).size(text::tiny() - 1.0).weight(Weight::Bold).color(theme.on_accent)),
|
|
));
|
|
}
|
|
|
|
let content = Rectangle::new().width(h).height(h).child(Stack::new(inside).width(h).height(h));
|
|
let on = monitor.to_string();
|
|
|
|
surface::shell(h, h, h / 2.0, &colors, lit, content).cursor(Pointer).on_hover(hovering(pod)).on_click(move |_| toggle(pod, &on))
|
|
}
|
|
|
|
fn content(pod: Pod, width: f32, height: f32) -> Rectangle {
|
|
let pad = layout::pad();
|
|
let (inner_w, inner_h) = (width - 2.0 * pad, height - 2.0 * pad);
|
|
|
|
let body = match pod {
|
|
Pod::Ai => ai::card(inner_w, inner_h),
|
|
Pod::Monitor => stats::card(inner_w, inner_h),
|
|
Pod::Home => home::card(inner_w, inner_h),
|
|
Pod::Notifications => notices::card(inner_w, inner_h),
|
|
};
|
|
|
|
Rectangle::new().width(width).height(height).padding(pad).child(body)
|
|
}
|
|
|
|
/*
|
|
* a card's first row: its title, the card's own buttons, then a cross that closes it.
|
|
* the cards share it so they read as one family
|
|
*/
|
|
pub fn header(title: &str, buttons: Vec<Box<dyn Widget>>, width: f32) -> Rectangle {
|
|
let theme = theme::now();
|
|
|
|
let mut right: Vec<Box<dyn Widget>> = buttons;
|
|
right.push(Box::new(button(Glyph::Cross, close)));
|
|
|
|
Rectangle::new().width(width).height(header_height()).child(
|
|
Row::new(vec![
|
|
Box::new(Text::new(title).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
|
|
Box::new(Row::new(right).gap(6.0).align(Center)),
|
|
])
|
|
.width(width)
|
|
.justify(SpaceBetween)
|
|
.align(Center),
|
|
)
|
|
}
|
|
|
|
pub fn header_height() -> f32 {
|
|
30.0
|
|
}
|
|
|
|
// a small round icon button
|
|
pub fn button(glyph: Glyph, pressed: impl Fn() + 'static) -> Rectangle {
|
|
let theme = theme::now();
|
|
|
|
Rectangle::new()
|
|
.width(26.0)
|
|
.height(26.0)
|
|
.radius(Full)
|
|
.fill(overlays::chip())
|
|
.align_child(Center, Center)
|
|
.cursor(Pointer)
|
|
.on_click(move |_| pressed())
|
|
.child(icon(glyph, 14.0, theme.text))
|
|
}
|
|
|
|
// a chip with words on it, for actions like "Clear all" or "Processes"
|
|
pub fn chip(words: &str, glyph: Option<Glyph>, pressed: impl Fn() + 'static) -> Rectangle {
|
|
let theme = theme::now();
|
|
let letters = words.chars().count() as f32;
|
|
let width = letters * text::small() * crate::config::appearance::font::letter_width() + if glyph.is_some() { 40.0 } else { 22.0 };
|
|
|
|
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
|
|
|
|
if let Some(glyph) = glyph {
|
|
parts.push(Box::new(icon(glyph, 13.0, theme.text)));
|
|
}
|
|
|
|
parts.push(Box::new(Text::new(words).size(text::small()).color(theme.text)));
|
|
|
|
Rectangle::new()
|
|
.width(width)
|
|
.height(26.0)
|
|
.radius(Full)
|
|
.fill(overlays::chip())
|
|
.align_child(Center, Center)
|
|
.cursor(Pointer)
|
|
.on_click(move |_| pressed())
|
|
.child(Row::new(parts).gap(6.0).align(Center))
|
|
}
|
|
|
|
// `amane ipc call pods open|toggle ai|monitor|home|notifications [monitor]`, `close`, `status`; the focused monitor by default
|
|
pub fn command(arguments: &[String]) -> String {
|
|
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
|
|
let focused = island::focused_monitor().unwrap_or_default();
|
|
|
|
match words.as_slice() {
|
|
[verb @ ("open" | "toggle"), name, rest @ ..] => match Pod::from_word(name) {
|
|
Some(pod) => {
|
|
let on = rest.first().map_or(focused, |monitor| monitor.to_string());
|
|
|
|
if *verb == "open" { open(pod, &on) } else { toggle(pod, &on) }
|
|
|
|
String::from("ok")
|
|
}
|
|
None => String::from("pods: ai, monitor, home or notifications"),
|
|
},
|
|
["close"] => {
|
|
close();
|
|
|
|
String::from("ok")
|
|
}
|
|
["ask", question @ ..] if !question.is_empty() => {
|
|
ai::ask(question.join(" "));
|
|
|
|
String::from("ok")
|
|
}
|
|
["notices", rest @ ..] => notices::command(rest),
|
|
["processes"] => {
|
|
stats::processes();
|
|
|
|
String::from("ok")
|
|
}
|
|
["status"] | [] => {
|
|
let pods = Pods::read();
|
|
let card = pods.card.as_ref().filter(|_| pods.shown).map_or("none", |(pod, _)| pod.word());
|
|
let on = pods.card.as_ref().map_or("", |(_, on)| on.as_str());
|
|
|
|
format!("card={card} monitor={on} timeline={:.2} switch={:.2} unread={}", pods.timeline.value(), pods.switch.value(), notices::unread())
|
|
}
|
|
_ => String::from("usage: pods open|toggle <ai|monitor|home|notifications> [monitor] | close | ask <question> | processes | notices list|dismiss <id>|clear | status"),
|
|
}
|
|
}
|
|
|
|
// a thin divider across a card
|
|
pub fn rule(width: f32) -> Rectangle {
|
|
Rectangle::new().width(width).height(1.0).fill(overlays::track())
|
|
}
|