Side pills: local AI chat, system monitor, home (clock, Munich weather, iCloud calendar), notifications daemon with popups and history
This commit is contained in:
+484
@@ -0,0 +1,484 @@
|
||||
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::{card as island_card, pill as island_pill};
|
||||
use crate::config::notifications::history;
|
||||
use crate::config::pills::{self as settings, layout};
|
||||
use crate::edge::{self, Edge};
|
||||
use crate::glyph::{Glyph, icon};
|
||||
use crate::island::{self, CLEAR, mix, phases, veiled};
|
||||
use crate::{ai, home, notices, stats, surface, theme};
|
||||
|
||||
/*
|
||||
* four small one-icon pills beside the island, each opening into a card the way the
|
||||
* island does: left of it the AI chat and the system monitor, right of it home and
|
||||
* notifications. one window per side and monitor (magic-pods-left|right) placed beside
|
||||
* the island's pill; while the island opens they move out of its way. one card at a
|
||||
* time across the shell: opening one collapses the island and the other way round
|
||||
*/
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Pod {
|
||||
Ai,
|
||||
Monitor,
|
||||
Home,
|
||||
Notifications,
|
||||
}
|
||||
|
||||
// left of the island, outermost first; right of it, innermost first
|
||||
const LEFT: [Pod; 2] = [Pod::Ai, Pod::Monitor];
|
||||
const RIGHT: [Pod; 2] = [Pod::Home, Pod::Notifications];
|
||||
|
||||
impl Pod {
|
||||
fn left(self) -> bool {
|
||||
matches!(self, Self::Ai | Self::Monitor)
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
// 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::signing_in())
|
||||
}
|
||||
|
||||
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> {
|
||||
[Self::Ai, Self::Monitor, Self::Home, Self::Notifications].into_iter().find(|pod| pod.word() == word)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
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(), 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)
|
||||
}
|
||||
|
||||
pub fn open(pod: Pod, monitor: &str) {
|
||||
island::collapse();
|
||||
|
||||
let mut pods = Pods::write();
|
||||
|
||||
// another card, or this one on another monitor: it grows from its own pill
|
||||
if pods.card.as_ref().is_none_or(|(open, on)| *open != pod || on != monitor) {
|
||||
pods.card = Some((pod, monitor.to_string()));
|
||||
pods.timeline = timeline();
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn left(monitor: &Monitor) -> LayerWindow {
|
||||
side(monitor, true)
|
||||
}
|
||||
|
||||
pub fn right(monitor: &Monitor) -> LayerWindow {
|
||||
side(monitor, false)
|
||||
}
|
||||
|
||||
fn side(monitor: &Monitor, left: bool) -> LayerWindow {
|
||||
theme::begin();
|
||||
|
||||
let here = monitor.name.as_str();
|
||||
let pods = if left { LEFT } else { RIGHT };
|
||||
let colors = theme::now().surfaces();
|
||||
|
||||
let state = Pods::read();
|
||||
|
||||
// this side's card on this monitor, while it's at all open
|
||||
let card = state.card.as_ref().filter(|(pod, on)| pod.left() == left && on == here).map(|(pod, _)| (*pod, state.timeline.value())).filter(|(_, t)| *t > 0.0);
|
||||
|
||||
// with the bar on a fullscreen monitor, unless its card is open; out of the island's way while it opens
|
||||
let slide = if card.is_some() { 0.0 } else { edge::hidden_by(Edge::read().shown(here), 0.0, edge::hide_h()) };
|
||||
let shift = (island::width_on(here) - island_pill::width()) / 2.0;
|
||||
|
||||
let (h, top, shadow, spacing) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow(), layout::spacing());
|
||||
let count = pods.len() as f32;
|
||||
let group_w = count * h + (count - 1.0) * spacing;
|
||||
|
||||
let (card_w, card_h) = card.map_or((0.0, 0.0), |(pod, _)| pod.size());
|
||||
|
||||
// room for the pills to move away from the open island, or for the card
|
||||
let room = (island_card::width() - island_pill::width()) / 2.0;
|
||||
let inner_w = (group_w + room).max(card_w);
|
||||
let window_h = top + h.max(card_h) + shadow;
|
||||
|
||||
// the group's edge nearest the island, in the window, and where the window starts on the monitor
|
||||
let near = if left { shadow + inner_w } else { shadow };
|
||||
let middle = monitor.width as f32 / 2.0;
|
||||
let beside = island_pill::width() / 2.0 + layout::gap();
|
||||
let start = if left { middle - beside - near } else { middle + beside - near };
|
||||
|
||||
let window = LayerWindow::new()
|
||||
.width(inner_w + 2.0 * shadow)
|
||||
.height(window_h)
|
||||
.anchor_vertical(Vertical::Top)
|
||||
.anchor_horizontal(Horizontal::Left)
|
||||
.margin(Margin { top: 0, right: 0, bottom: 0, left: start.round() as i32 })
|
||||
.layer(Layer::Overlay)
|
||||
.space(Zone::Ignore)
|
||||
.namespace(if left { "magic-pods-left" } else { "magic-pods-right" });
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// a pill's left edge in the window; `k` counts from the island outwards
|
||||
let pill_x = |pod: Pod| {
|
||||
let at = pods.iter().position(|other| *other == pod).unwrap_or(0) as f32;
|
||||
let k = if left { count - 1.0 - at } else { at };
|
||||
|
||||
if left { near - shift - (k + 1.0) * h - k * spacing } else { near + shift + k * (h + spacing) }
|
||||
};
|
||||
|
||||
let (grow, pill_shown, reveal) = card.map_or((0.0, 1.0, 0.0), |(_, t)| phases(t));
|
||||
|
||||
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
||||
let mut regions: Vec<InputArea> = Vec::new();
|
||||
|
||||
for pod in pods {
|
||||
// the open one becomes the card; the others fade while it grows over them
|
||||
if card.is_some_and(|(open, _)| open == pod) || grow >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let x = pill_x(pod);
|
||||
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
||||
let pill = pill(pod, lit, &colors, here);
|
||||
let pill = if grow > 0.0 { pill.opacity(1.0 - grow) } 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)));
|
||||
}
|
||||
|
||||
let typing = card.is_some_and(|(pod, _)| pod.types() && state.shown);
|
||||
|
||||
if let Some((pod, _)) = card {
|
||||
// from the pill to the card, its edge nearest the island staying put
|
||||
let from_x = pill_x(pod);
|
||||
let to_x = if left { near - shift - card_w } else { near + shift };
|
||||
|
||||
let width = mix(h, card_w, grow);
|
||||
let height = mix(h, card_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 face: Rectangle = if reveal > 0.0 {
|
||||
veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors)
|
||||
} else {
|
||||
// the pill's icon fading as the shape grows
|
||||
let tint = Color::blend(CLEAR, theme::now().text, 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(Start, Start).child(face);
|
||||
let shape = surface::shell(width, height, radius, &colors, lit * pill_shown, face).on_hover(over_card).translate(x, top + slide);
|
||||
|
||||
regions.push(InputArea { x: x as i32, y: (top + slide) 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| {
|
||||
if key == Key::Escape {
|
||||
close();
|
||||
}
|
||||
})
|
||||
} else {
|
||||
window
|
||||
};
|
||||
|
||||
window.child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent)))
|
||||
}
|
||||
|
||||
// a small pill: the surface with one icon; the bell carries the unread count
|
||||
fn pill(pod: Pod, lit: f32, colors: &[Color; 3], monitor: &str) -> Rectangle {
|
||||
let h = island_pill::height();
|
||||
let theme = theme::now();
|
||||
|
||||
let glyph = icon(pod.glyph(), layout::icon() * (1.0 + 0.08 * lit), Color::blend(theme.dim, theme.text, 0.6 + 0.4 * 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(theme.accent)
|
||||
.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} unread={}", pods.timeline.value(), notices::unread())
|
||||
}
|
||||
_ => String::from("usage: pods open|toggle <ai|monitor|home|notifications> [monitor] | close | ask <question> | 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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user