Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90130add06 | ||
|
|
1b8fc66130 | ||
|
|
f79662bd5b | ||
|
|
e0d8a5479c | ||
|
|
344d318959 | ||
|
|
801eab9ab4 | ||
|
|
c0fff44bcf | ||
|
|
f55314f511 | ||
|
|
3de3709871 | ||
|
|
0457830aba | ||
|
|
689b53be0f | ||
|
|
f8c17025ce | ||
|
|
aadf765d87 | ||
|
|
756f38be4e | ||
|
|
7cb2368b23 | ||
|
|
8cbd211da0 | ||
|
|
e2ef1a9d52 |
@@ -113,7 +113,7 @@ Whether the open card takes the song cover's colours instead (`appearance.colors
|
||||
|
||||
## Side pills
|
||||
|
||||
Two small one-icon pills sit left of the island and two right of it, on every monitor; they move outward while the island opens. A click morphs a pill into its card. Only one card is open across the shell: opening the island closes a card, and opening a card closes the island. Esc, the cross or a click elsewhere closes it.
|
||||
Two small one-icon pills sit left of the island and two right of it, on every monitor, each in its own colour (`pills.colors.*`; Home's follows the sky). A click grows a pill into a big card in the middle of the top of the screen; meanwhile the row becomes a carousel: the other pills and the island's pill line up two left and two right of the card in their usual order (around Home: AI and System left, the island and the bell right), and clicking one switches straight to its card (the island's pill closes the card, then opens the island). A closing card shrinks back into its own pill's place. Only one card is open across the shell. Esc, the cross or a click elsewhere closes it.
|
||||
|
||||
- **AI** (sparkle): a chat with an Ollama model on your own servers, `gemma4:e4b` at `http://localhost:11434` by default. `pills.ai.servers` takes several, comma-separated, in order: the model chip in the header lists every server's models (each once, with its server when there are several) and switches at once, and a question goes to the first server that has the chosen model. Answers stream in through curl. When Ollama doesn't answer, the shell starts the OpenRC service with `doas rc-service ollama start` (`pills.ai.start_service`). Stop ends an answer and New chat starts over. Nothing goes to a cloud service, only to the servers listed.
|
||||
- **System** (pulse): CPU, the integrated GPU's clock, the external GPU's load (with its video memory and the programs on it, formerly in the control centre), memory, swap, network and disk read/write as graphs over the last minute, plus the hottest sensors with icons, in cool/warm/hot colours (`pills.monitor.warm|hot`). Everything is read from `/proc` and `/sys`, so there is no process list. Processes opens `ghostty --class=amane.monitor -e btm` on the special workspace `monitor`; a second press shows or hides it.
|
||||
@@ -134,6 +134,8 @@ The config window edits the same file: Settings at the foot of the control centr
|
||||
|
||||
`appearance.surface.design` picks what the island, the pills, the tray menu, the control centre and its popups are made of: `solid` (the wallpaper's gradient with an accent glow), `frosted` (a thin tint, a hairline white rim, a sheen along the top, a soft shadow) or `liquid` (clear glass, denser toward the bottom right, a light-catching edge, the accent's glow). Each design's opacity, rim and light are settings of their own (`appearance.solid|frosted|liquid.*`); the look itself is `src/surface.rs`, drawn by one shader (`src/surface.wgsl`: the shadow, the fill and the sheen or lit edge in one pass; the rim stays a border). On see-through glass a face fades with `.opacity()` instead of a veil, a GPU layer only while it fades.
|
||||
|
||||
Screenshots of each design, from when they were made: `docs/design/` (`base-solid-card.png`, `base-glass-0.5-card.png` for solid below 1, `frosted-0.62-card.png`, `liquid-0.4-card.png`, and a board for each glass design: the card three times side by side, with the pills below).
|
||||
|
||||
### Performance
|
||||
|
||||
Amane draws rectangles, borders, letters and pictures as cheap quads, and gradients, shadows and `Canvas` paths with vello, which renders over the whole window and then lays the result on top. vello keeps a result between frames only when its shapes didn't change and hold no gradient, so anything drawn with it on every frame costs a pass over the whole window. What redraws every frame is therefore kept off vello: the surfaces are a shader, the pill's bars are rounded rectangles, the candle ring and the equalizer's curve are shaders (`src/ring.wgsl`, `src/curve.wgsl`). A shader's file is written to `$XDG_RUNTIME_DIR/magic-island/` when it is first drawn. A shader must not contain the word `time`, or amane redraws its window on every frame.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 328 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 880 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 381 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
+100
-18
@@ -1,7 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{LazyLock, Mutex, PoisonError, RwLock};
|
||||
use std::thread::LocalKey;
|
||||
|
||||
use amane::Color;
|
||||
|
||||
@@ -181,7 +184,11 @@ macro_rules! settings {
|
||||
$(
|
||||
#[allow(dead_code)]
|
||||
pub fn $name() -> $ty {
|
||||
super::super::get(concat!(stringify!($module), ".", stringify!($group), ".", stringify!($name)), || $default)
|
||||
std::thread_local! {
|
||||
static SLOT: std::cell::RefCell<(u64, Option<$ty>)> = const { std::cell::RefCell::new((0, None)) };
|
||||
}
|
||||
|
||||
super::super::cached(&SLOT, concat!(stringify!($module), ".", stringify!($group), ".", stringify!($name)), || $default)
|
||||
}
|
||||
)*
|
||||
} )*
|
||||
@@ -504,25 +511,38 @@ settings! {
|
||||
layout {
|
||||
gap: f32 = 10.0, 0..=200; "Space between the island's pill and the small pills beside it"
|
||||
spacing: f32 = 8.0, 0..=100; "Space between the small pills on one side"
|
||||
icon: f32 = 16.0, 6..=40; "Size of the small pills' icons"
|
||||
icon: f32 = 18.0, 6..=40; "Size of the small pills' icons"
|
||||
open_ms: u64 = 420, 0..=5000; "How long a small pill takes to open into its card, and to close"
|
||||
radius: f32 = 22.0, 0..=60; "Corner radius of the opened cards"
|
||||
pad: f32 = 18.0, 0..=60; "Padding inside the opened cards"
|
||||
reveal_at: f32 = 0.2, 0..=1; "How much of a closing card's growth is left when the pills beside it start to show again behind it (0 = only once it's closed)"
|
||||
radius: f32 = 28.0, 0..=60; "Corner radius of the opened cards"
|
||||
pad: f32 = 24.0, 0..=60; "Padding inside the opened cards"
|
||||
}
|
||||
colors {
|
||||
ai: Color = Color::rgb(160, 128, 255); "The AI pill's own colour, mixed into its pill and card"
|
||||
monitor: Color = Color::rgb(64, 208, 190); "The system monitor pill's own colour"
|
||||
notifications: Color = Color::rgb(255, 128, 104); "The notification pill's own colour"
|
||||
home_clear_day: Color = Color::rgb(255, 190, 92); "The home pill's colour under a clear or partly cloudy day sky"
|
||||
home_clear_night: Color = Color::rgb(104, 118, 230); "The home pill's colour on a clear night"
|
||||
home_cloudy: Color = Color::rgb(150, 170, 200); "The home pill's colour under clouds (and before the weather is known)"
|
||||
home_fog: Color = Color::rgb(176, 176, 190); "The home pill's colour in fog"
|
||||
home_rain: Color = Color::rgb(84, 156, 244); "The home pill's colour in rain"
|
||||
home_snow: Color = Color::rgb(206, 232, 255); "The home pill's colour in snow"
|
||||
home_storm: Color = Color::rgb(160, 104, 230); "The home pill's colour in a storm"
|
||||
pill_mix: f32 = 0.55, 0..=1; "How much of its own colour a small pill takes (0 = the wallpaper's only)"
|
||||
card_mix: f32 = 0.28, 0..=1; "How much of its pill's colour an open card takes"
|
||||
}
|
||||
ai {
|
||||
width: f32 = 440.0, 280..=1200; "Width of the AI card"
|
||||
height: f32 = 520.0, 200..=1200; "Height of the AI card"
|
||||
width: f32 = 760.0, 280..=1400; "Width of the AI card"
|
||||
height: f32 = 680.0, 200..=1200; "Height of the AI card"
|
||||
servers: String = String::from("http://localhost:11434"); "The Ollama servers the AI card talks to, comma-separated, in order: the model list shows all their models, a question goes to the first that has the chosen one"
|
||||
model: String = String::from("gemma4:e4b"); "The Ollama model to chat with, with its tag (ollama list shows them)"
|
||||
start_service: bool = true; "Start the local ollama service (doas rc-service ollama start) when it isn't answering"
|
||||
system: String = String::from("You are a helpful assistant inside a Linux desktop shell. Answer briefly and plainly."); "What the model is told before every chat"
|
||||
}
|
||||
monitor {
|
||||
width: f32 = 420.0, 280..=1200; "Width of the system monitor card"
|
||||
width: f32 = 620.0, 280..=1400; "Width of the system monitor card"
|
||||
interval_ms: u64 = 1000, 200..=10000; "How often the system monitor reads the system"
|
||||
history: usize = 60, 10..=600; "How many readings the graphs show"
|
||||
graph_height: f32 = 34.0, 10..=200; "Height of each graph"
|
||||
graph_height: f32 = 46.0, 10..=200; "Height of each graph"
|
||||
temperatures: usize = 6, 0..=20; "How many temperature sensors to show, always in the same order: CPU, iGPU, eGPU, NVMe, memory, board, then the rest"
|
||||
warm: f32 = 65.0, 20..=120; "From this temperature (°C) a sensor shows in the warm colour"
|
||||
hot: f32 = 85.0, 20..=130; "From this temperature (°C) a sensor shows in the hot colour"
|
||||
@@ -535,7 +555,7 @@ settings! {
|
||||
workspace: String = String::from("processes"); "The special workspace the process list opens on"
|
||||
}
|
||||
home {
|
||||
width: f32 = 820.0, 300..=1600; "Width of the home card. It opens rightwards from beside the island, so on a 1920 px wide monitor about 820 fits"
|
||||
width: f32 = 880.0, 300..=1600; "Width of the home card. It opens in the middle with the AI, system and island pills on its left, which on a 1920 px wide monitor leaves room for about 900 beside the workspace pill"
|
||||
clock_size: f32 = 64.0, 20..=200; "Size of the big clock"
|
||||
clock_font: String = String::new(); "The big clock's font family, any installed one (fc-list : family lists them); empty takes the shell's font"
|
||||
clock_weight: u64 = 700, 100..=900; "The big clock's weight, 100 (thin) to 900 (black); a font without that weight shows its nearest one"
|
||||
@@ -596,15 +616,18 @@ settings! {
|
||||
blocked: String = String::new(); "Apps whose notifications are dropped at once, comma-separated (Block app on a notification adds it)"
|
||||
}
|
||||
history {
|
||||
width: f32 = 420.0, 280..=1200; "Width of the notification card"
|
||||
height: f32 = 560.0, 200..=1400; "Height of the notification card"
|
||||
width: f32 = 620.0, 280..=1400; "Width of the notification card"
|
||||
height: f32 = 700.0, 200..=1400; "Height of the notification card"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// what config.toml set, by path; a missing or bad setting takes its default
|
||||
// what config.toml set, by path; a missing or bad setting takes its default. written only through change()
|
||||
static VALUES: LazyLock<RwLock<HashMap<String, Value>>> = LazyLock::new(Default::default);
|
||||
|
||||
// how many times VALUES changed; an accessor's cached answer from an older count is read again
|
||||
static GENERATION: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
// what was wrong in the file when it was last read, for the config window
|
||||
static ERRORS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
@@ -619,11 +642,43 @@ fn written() -> std::sync::MutexGuard<'static, String> {
|
||||
WRITTEN.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
// ponytail: a lock and a hash lookup per call, a few hundred a frame; a snapshot struct if a profile ever shows it
|
||||
fn get<T: Setting>(path: &str, default: impl FnOnce() -> T) -> T {
|
||||
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).and_then(T::read).unwrap_or_else(default)
|
||||
}
|
||||
|
||||
// an accessor's last answer on this thread, with the GENERATION it was read at
|
||||
type Slot<T> = LocalKey<RefCell<(u64, Option<T>)>>;
|
||||
|
||||
/*
|
||||
* what every accessor (settings!) returns: its last answer while VALUES hasn't changed
|
||||
* since, else a fresh get(). a few hundred reads a frame with a card open, each a lock,
|
||||
* a hash of the path and a conversion (a colour parsed from its text) at 160 to 570 ns
|
||||
* unoptimised; a cached one is an atomic load and a copy, 13 ns. per thread, so it takes
|
||||
* no lock. a read racing a change may return the old value once, as get() could
|
||||
*/
|
||||
fn cached<T: Setting + Clone>(slot: &'static Slot<T>, path: &str, default: impl FnOnce() -> T) -> T {
|
||||
let now = GENERATION.load(Ordering::Acquire);
|
||||
|
||||
if let Some(value) = slot.with_borrow(|(seen, value)| value.clone().filter(|_| *seen == now)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let value = get(path, default);
|
||||
|
||||
slot.set((now, Some(value.clone())));
|
||||
|
||||
value
|
||||
}
|
||||
|
||||
// every change to VALUES: made, then counted (the write lock is gone by then), so the accessors read again
|
||||
fn change<R>(edit: impl FnOnce(&mut HashMap<String, Value>) -> R) -> R {
|
||||
let done = edit(&mut VALUES.write().unwrap_or_else(PoisonError::into_inner));
|
||||
|
||||
GENERATION.fetch_add(1, Ordering::Release);
|
||||
|
||||
done
|
||||
}
|
||||
|
||||
pub fn file() -> PathBuf {
|
||||
theme::config_file("config.toml")
|
||||
}
|
||||
@@ -670,8 +725,8 @@ pub fn reread() -> bool {
|
||||
// shell.conf's four settings, moved to their places in config.toml
|
||||
fn migrate() {
|
||||
let old = fs::read_to_string(theme::config_file("shell.conf")).unwrap_or_default();
|
||||
let mut values = VALUES.write().unwrap_or_else(PoisonError::into_inner);
|
||||
|
||||
change(|values| {
|
||||
for (key, text) in parse::settings(&old) {
|
||||
let path = match key.as_str() {
|
||||
"cover_colors" => "appearance.colors.cover_colors",
|
||||
@@ -687,6 +742,7 @@ fn migrate() {
|
||||
values.insert(path.to_string(), value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// the file's text, read anew: what it sets replaces everything set before
|
||||
@@ -703,7 +759,7 @@ pub fn apply_text(text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
*VALUES.write().unwrap_or_else(PoisonError::into_inner) = values;
|
||||
change(|old| *old = values);
|
||||
*ERRORS.lock().unwrap_or_else(PoisonError::into_inner) = errors;
|
||||
|
||||
hypr::sync_soon();
|
||||
@@ -803,7 +859,7 @@ pub fn set(path: &str, value: Value) -> Result<(), String> {
|
||||
|
||||
let mut written = written();
|
||||
|
||||
VALUES.write().unwrap_or_else(PoisonError::into_inner).insert(path.to_string(), value);
|
||||
change(|values| values.insert(path.to_string(), value));
|
||||
|
||||
save(&mut written)
|
||||
}
|
||||
@@ -812,7 +868,7 @@ pub fn set(path: &str, value: Value) -> Result<(), String> {
|
||||
pub fn reset(path: &str) -> Result<(), String> {
|
||||
let mut written = written();
|
||||
|
||||
VALUES.write().unwrap_or_else(PoisonError::into_inner).remove(path);
|
||||
change(|values| values.remove(path));
|
||||
|
||||
save(&mut written)
|
||||
}
|
||||
@@ -991,3 +1047,29 @@ pub fn label(name: &str) -> String {
|
||||
|
||||
title(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// the cached accessors follow every change, on this thread and on others
|
||||
#[test]
|
||||
fn accessors_follow_changes() {
|
||||
let (width, family, accent) = (island::pill::width(), appearance::font::family(), appearance::fallback::accent());
|
||||
|
||||
change(|values| {
|
||||
values.insert("island.pill.width".into(), Value::Float(321.0));
|
||||
values.insert("appearance.font.family".into(), Value::Text("Iosevka".into()));
|
||||
values.insert("appearance.fallback.accent".into(), Value::Text("#102030".into()));
|
||||
});
|
||||
|
||||
assert_eq!(island::pill::width(), 321.0);
|
||||
assert_eq!(appearance::font::family(), "Iosevka");
|
||||
assert_eq!(appearance::fallback::accent(), Color::rgb(16, 32, 48));
|
||||
assert_eq!(std::thread::spawn(island::pill::width).join().unwrap(), 321.0);
|
||||
|
||||
change(|values| values.clear());
|
||||
|
||||
assert_eq!((island::pill::width(), appearance::font::family(), appearance::fallback::accent()), (width, family, accent));
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -349,7 +349,11 @@ fn sync_settings() -> (String, String, String, bool) {
|
||||
(agenda_settings::account(), agenda_settings::server(), agenda_settings::contacts_server(), agenda_settings::birthdays())
|
||||
}
|
||||
|
||||
// whether the card has a form to type into: then it keeps the keyboard and stays open
|
||||
// the sky now and whether it's day, once the weather is known (the home pill takes its colour from it)
|
||||
pub fn sky() -> Option<(parse::Sky, bool)> {
|
||||
Forecast::read().weather.as_ref().map(|weather| (parse::sky(weather.code), weather.is_day))
|
||||
}
|
||||
|
||||
// the event form closes without saving
|
||||
fn cancel() {
|
||||
Agenda::write().form = None;
|
||||
@@ -366,6 +370,7 @@ pub fn escape() -> bool {
|
||||
open
|
||||
}
|
||||
|
||||
// whether the card has a form to type into: then it keeps the keyboard and stays open
|
||||
pub fn typing() -> bool {
|
||||
Agenda::read().form.is_some()
|
||||
}
|
||||
|
||||
+101
-16
@@ -225,8 +225,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
.space(Zone::Ignore)
|
||||
.namespace("magic-island");
|
||||
|
||||
// out of sight: empty and click-through (never hidden, see below), and nothing else read, so a playing player doesn't redraw it
|
||||
if slide <= -edge::hide_h() {
|
||||
/*
|
||||
* out of sight: empty and click-through (never hidden, see below), and nothing else
|
||||
* read, so a playing player doesn't redraw it. also while a side pill's card is open in
|
||||
* the middle: the pods' window draws this pill in the row beside it meanwhile
|
||||
*/
|
||||
if slide <= -edge::hide_h() || crate::pods::holds_island(here) {
|
||||
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||
}
|
||||
|
||||
@@ -297,9 +301,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
|
||||
veiled(card(&scene), width, height, radius, reveal, &colors)
|
||||
}
|
||||
Some(player) if pill > 0.0 => veiled(compact(player, playing(player, &island), &art.path, levels), width, height, radius, pill, &colors),
|
||||
Some(player) if pill > 0.0 => veiled(compact(player, playing(player, &island), &art.path, levels, c::pill::width()), width, height, radius, pill, &colors),
|
||||
None if reveal > 0.0 => veiled(crate::last::card(), width, height, radius, reveal, &colors),
|
||||
None if pill > 0.0 => veiled(idle(), width, height, radius, pill, &colors),
|
||||
None if pill > 0.0 => veiled(idle_face(c::pill::width()), width, height, radius, pill, &colors),
|
||||
// mid-grow: the empty shape alone, the cheapest frames of the animation
|
||||
_ => Rectangle::new().width(0.0).height(0.0),
|
||||
};
|
||||
@@ -356,8 +360,74 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* the island's pill as the side pills' carousel draws it while one of their cards is
|
||||
* open in the middle (the island's own window leaves it to them meanwhile, see
|
||||
* pods::holds_island). `small` shrinks it from the pill (0) to its least (1): the song's
|
||||
* cover, or its live bars when there's no cover, or a note with nothing playing. the
|
||||
* pill's face fades out over the first half, the small one in over the second
|
||||
*/
|
||||
pub fn pill_face(monitor: &str, small: f32) -> Rectangle {
|
||||
let media = Media::read();
|
||||
let island = Island::read();
|
||||
let colors = theme::now().surfaces();
|
||||
let art = Art::read();
|
||||
let h = c::pill::height();
|
||||
|
||||
let player = shown(&media, island.selected.as_deref());
|
||||
let small_w = small_width_for(player.is_some(), &art.path);
|
||||
let width = mix(c::pill::width(), small_w, small);
|
||||
|
||||
let live = player.is_some() && focused_monitor().as_deref().is_none_or(|name| name == monitor);
|
||||
let spectrum = live.then(Spectrum::read);
|
||||
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
|
||||
|
||||
if !levels.is_empty() {
|
||||
amane::request_frame();
|
||||
}
|
||||
|
||||
/*
|
||||
* the pill's face fades out, then the small one in: they swap halfway, or with nothing
|
||||
* playing as soon as the shape gets too narrow for the words, so these never vanish while seen
|
||||
*/
|
||||
let turn = if player.is_some() { 0.5 } else { ((c::pill::width() - idle_room()) / (c::pill::width() - small_w).max(1.0)).clamp(0.05, 0.5) };
|
||||
|
||||
let face = if small < turn {
|
||||
let big = match player {
|
||||
Some(player) => compact(player, playing(player, &island), &art.path, levels, width),
|
||||
None => idle_face(width),
|
||||
};
|
||||
|
||||
veiled(big, width, h, h / 2.0, 1.0 - small / turn, &colors)
|
||||
} else {
|
||||
let little: Box<dyn Widget> = match player {
|
||||
Some(_) if !art.path.is_empty() => Box::new(cover(&art.path, h - 10.0, 1.5, false)),
|
||||
Some(player) => Box::new(pill_levels(playing(player, &island), levels)),
|
||||
None => Box::new(icon(Glyph::Note, IDLE_NOTE, theme::now().accent)),
|
||||
};
|
||||
|
||||
veiled(Rectangle::new().width(width).height(h).align_child(Center, Center).child(Stack::new(vec![little])), width, h, h / 2.0, (small - turn) / (1.0 - turn), &colors)
|
||||
};
|
||||
|
||||
surface::shell(width, h, h / 2.0, &colors, 0.0, face)
|
||||
}
|
||||
|
||||
// the island's pill at its least in the carousel: as wide as tall with a cover or nothing playing, else room for its bars
|
||||
pub fn small_width() -> f32 {
|
||||
let media = Media::read();
|
||||
let island = Island::read();
|
||||
|
||||
small_width_for(shown(&media, island.selected.as_deref()).is_some(), &Art::read().path)
|
||||
}
|
||||
|
||||
fn small_width_for(playing_something: bool, art: &str) -> f32 {
|
||||
let h = c::pill::height();
|
||||
|
||||
if !playing_something || !art.is_empty() { h } else { (c::pill::bars_width() + 24.0).max(h) }
|
||||
}
|
||||
|
||||
// a click on the island of `monitor`: closes an open dropdown, else opens the card there
|
||||
fn tap(monitor: &str) {
|
||||
pub fn tap(monitor: &str) {
|
||||
let mut island = Island::write();
|
||||
|
||||
if island.menu.take().is_none() {
|
||||
@@ -587,21 +657,36 @@ pub fn faded(color: Color, alpha: u8) -> Color {
|
||||
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
||||
}
|
||||
|
||||
// the pill while no player is open: a music note and "Nothing playing"; the words only
|
||||
// where they fit with half the pill's height to spare at each end, else the note alone, so it never nears the rim
|
||||
fn idle() -> Rectangle {
|
||||
let (width, h, note) = (c::pill::width(), c::pill::height(), 15.0);
|
||||
let words = Text::new("Nothing playing").size(text::body()).color(theme::now().dim);
|
||||
let fits = matches!(words.width(), amane::Size::Fixed(words_w) if note + 8.0 + words_w + h <= width);
|
||||
/*
|
||||
* a note and "Nothing playing", centred. the words only where they fit with half the
|
||||
* pill's height to spare at each end, else the note alone: an elided text takes the whole
|
||||
* width, which put the note against the rim, and a pill narrower than the words did too
|
||||
*/
|
||||
fn idle_face(width: f32) -> Rectangle {
|
||||
let note = icon(Glyph::Note, IDLE_NOTE, theme::now().accent);
|
||||
let row = if idle_room() <= width { Row::new(children![note, idle_words()]) } else { Row::new(children![note]) };
|
||||
|
||||
let row = if fits { Row::new(children![icon(Glyph::Note, note, theme::now().accent), words]) } else { Row::new(children![icon(Glyph::Note, note, theme::now().accent)]) };
|
||||
Rectangle::new().width(width).height(h).align_child(Center, Center).child(row.gap(8.0).align(Center))
|
||||
Rectangle::new().width(width).height(c::pill::height()).align_child(Center, Center).child(row.gap(8.0).align(Center))
|
||||
}
|
||||
|
||||
// the pill: cover, title, the player's live levels
|
||||
fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle {
|
||||
const IDLE_NOTE: f32 = 15.0;
|
||||
|
||||
fn idle_words() -> Text {
|
||||
Text::new("Nothing playing").size(text::body()).color(theme::now().dim)
|
||||
}
|
||||
|
||||
// the width the note and the words need, with half the pill's height to spare at each end
|
||||
fn idle_room() -> f32 {
|
||||
match idle_words().width() {
|
||||
amane::Size::Fixed(words) => IDLE_NOTE + 8.0 + words + c::pill::height(),
|
||||
amane::Size::Parent => f32::MAX,
|
||||
}
|
||||
}
|
||||
|
||||
// the pill, `width` wide: cover, title, the player's live levels
|
||||
fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32], width: f32) -> Rectangle {
|
||||
Rectangle::new()
|
||||
.width(c::pill::width())
|
||||
.width(width)
|
||||
.height(c::pill::height())
|
||||
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 })
|
||||
.child(
|
||||
|
||||
+3
-3
@@ -47,9 +47,9 @@ fn main() {
|
||||
.window_per_monitor(edge::spacer)
|
||||
.window_per_monitor(edge::view)
|
||||
.window_per_monitor(island::view)
|
||||
// the small pills beside the island, and their cards
|
||||
.window_per_monitor(pods::left)
|
||||
.window_per_monitor(pods::right)
|
||||
// the small pills beside the island, and their cards in the middle
|
||||
.window_per_monitor(pods::row_view)
|
||||
.window_per_monitor(pods::card_view)
|
||||
.window_per_monitor(workspaces::view)
|
||||
.window_per_monitor(control::view)
|
||||
.window_per_monitor(control::menu_view)
|
||||
|
||||
+379
-112
@@ -8,20 +8,27 @@ use amane::{
|
||||
};
|
||||
|
||||
use crate::config::appearance::{motion, overlays, text};
|
||||
use crate::config::island::{card as island_card, pill as island_pill};
|
||||
use crate::config::island::pill as island_pill;
|
||||
use crate::config::notifications::history;
|
||||
use crate::config::pills::{self as settings, layout};
|
||||
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 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
|
||||
* 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 {
|
||||
@@ -31,15 +38,18 @@ pub enum Pod {
|
||||
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];
|
||||
// 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 left(self) -> bool {
|
||||
matches!(self, Self::Ai | Self::Monitor)
|
||||
}
|
||||
|
||||
fn glyph(self) -> Glyph {
|
||||
match self {
|
||||
Self::Ai => Glyph::Sparkle,
|
||||
@@ -59,6 +69,24 @@ impl Pod {
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
@@ -74,16 +102,25 @@ impl Pod {
|
||||
}
|
||||
|
||||
fn from_word(word: &str) -> Option<Self> {
|
||||
[Self::Ai, Self::Monitor, Self::Home, Self::Notifications].into_iter().find(|pod| pod.word() == word)
|
||||
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
|
||||
@@ -93,7 +130,7 @@ pub struct Pods {
|
||||
|
||||
impl Service for Pods {
|
||||
fn new() -> Self {
|
||||
Self { card: None, shown: false, timeline: timeline(), hover: HashMap::new(), inside: false, moves: 0 }
|
||||
Self { card: None, shown: false, timeline: timeline(), previous: None, switch: switching(1.0), hover: HashMap::new(), inside: false, moves: 0 }
|
||||
}
|
||||
|
||||
// changes only through input
|
||||
@@ -104,15 +141,34 @@ 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();
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
@@ -152,6 +208,20 @@ pub fn open_card() -> Option<Pod> {
|
||||
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);
|
||||
@@ -195,138 +265,334 @@ fn over_card(inside: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn left(monitor: &Monitor) -> LayerWindow {
|
||||
side(monitor, true)
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn right(monitor: &Monitor) -> LayerWindow {
|
||||
side(monitor, false)
|
||||
// an item's width in the carousel
|
||||
fn item_width(item: Item) -> f32 {
|
||||
match item {
|
||||
Item::Pod(_) => island_pill::height(),
|
||||
// beside an open card the island's pill shrinks to its cover or its bars
|
||||
Item::Island => island::small_width(),
|
||||
}
|
||||
}
|
||||
|
||||
fn side(monitor: &Monitor, left: bool) -> LayerWindow {
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
fn index(item: Item) -> usize {
|
||||
ROW.iter().position(|other| *other == item).unwrap_or(0)
|
||||
}
|
||||
|
||||
/*
|
||||
* the two items on each side of `open`'s card: the other four in the row's order, the
|
||||
* first two on its left and the last two on its right, so the left-most stays left-most
|
||||
* (around Home: AI and System on its left, the island and the bell on its right; around
|
||||
* AI: System and the island on its left, Home and the bell on its right)
|
||||
*/
|
||||
fn beside(open: Pod) -> ([Item; 2], [Item; 2]) {
|
||||
let others: Vec<Item> = ROW.into_iter().filter(|item| *item != Item::Pod(open)).collect();
|
||||
|
||||
([others[0], others[1]], [others[2], others[3]])
|
||||
}
|
||||
|
||||
// an item's left edge in the carousel around `open`'s card, `card_w` wide, in the middle, and its side of the card (-1, 0 for the card's own, 1)
|
||||
fn carousel(item: Item, open: Pod, card_w: f32, middle: f32) -> (f32, i8) {
|
||||
// the card's own pill, while it fades into the card or out of it, sits in the card's middle
|
||||
if item == Item::Pod(open) {
|
||||
return (middle - item_width(item) / 2.0, 0);
|
||||
}
|
||||
|
||||
let (spacing, gap) = (layout::spacing(), layout::gap());
|
||||
let (left, right) = beside(open);
|
||||
|
||||
match left.iter().position(|other| *other == item) {
|
||||
// leftwards from the card, past the one between
|
||||
Some(k) => {
|
||||
let between: f32 = left[k + 1..].iter().map(|other| item_width(*other) + spacing).sum();
|
||||
|
||||
(middle - card_w / 2.0 - gap - between - item_width(item), -1)
|
||||
}
|
||||
None => {
|
||||
let k = right.iter().position(|other| *other == item).unwrap_or(0);
|
||||
let between: f32 = right[..k].iter().map(|other| item_width(*other) + spacing).sum();
|
||||
|
||||
(middle + card_w / 2.0 + gap + between, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* an item's side of the middle in the closed row, where every card opens: AI and System
|
||||
* left, the island in it (0, so it slides out to whichever side it goes), Home and the
|
||||
* bell right. no item changes sides on opening, so none crosses the card
|
||||
*/
|
||||
fn closed_side(item: Item) -> i8 {
|
||||
(index(item) as i8 - index(Item::Island) as i8).signum()
|
||||
}
|
||||
|
||||
/*
|
||||
* from one place to another by `k`, with how much it shows: sliding when it stays on its
|
||||
* side of the card; changing sides it fades out where it is over the first half and in at
|
||||
* its new place over the second, so it never crosses the card
|
||||
*/
|
||||
fn travel(from: (f32, i8), to: (f32, i8), k: f32) -> (f32, f32) {
|
||||
if from.1 == to.1 || from.1 == 0 || to.1 == 0 {
|
||||
(mix(from.0, to.0, k), 1.0)
|
||||
} else if k < 0.5 {
|
||||
(from.0, 1.0 - 2.0 * k)
|
||||
} else {
|
||||
(to.0, 2.0 * k - 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 row's move into the carousel: it starts only after the hand-over (see holds_island), so the island's pill never shows twice
|
||||
row_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 row_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), row_grow }
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
// an item's left edge on the monitor and how much it shows: between the closed row and the carousel by `row_grow`, between two carousels by `switch`
|
||||
fn place(&self, item: Item) -> (f32, f32) {
|
||||
let closed = closed_x(item, self.middle, self.island_w);
|
||||
|
||||
let Some((open, _)) = self.card else { return (closed, 1.0) };
|
||||
|
||||
let now = carousel(item, open, open.size().0, self.middle);
|
||||
|
||||
let (open_x, open_shown, open_side) = match self.previous {
|
||||
Some(before) => {
|
||||
let from = carousel(item, before, before.size().0, self.middle);
|
||||
let (x, shown) = travel(from, now, self.switch);
|
||||
|
||||
(x, shown, if self.switch < 0.5 { from.1 } else { now.1 })
|
||||
}
|
||||
None => (now.0, 1.0, now.1),
|
||||
};
|
||||
|
||||
let (x, shown) = travel((closed, closed_side(item)), (open_x, open_side), self.row_grow);
|
||||
|
||||
(x, shown * open_shown)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 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 pods = if left { LEFT } else { RIGHT };
|
||||
let colors = theme::now().surfaces();
|
||||
|
||||
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());
|
||||
|
||||
// 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 this side's widest
|
||||
* card. the width never follows the open card: a narrower window would move with
|
||||
* its margin, and Hyprland applies size and position on different frames, so the
|
||||
* pills jumped when a card closed
|
||||
*/
|
||||
let room = (island_card::width() - island_pill::width()) / 2.0;
|
||||
let widest = pods.iter().map(|pod| pod.size().0).fold(0.0, f32::max);
|
||||
let inner_w = (group_w + room).max(widest);
|
||||
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 };
|
||||
// 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(inner_w + 2.0 * shadow)
|
||||
.height(window_h)
|
||||
.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: start.round() as i32 })
|
||||
.margin(Margin { top: 0, right: 0, bottom: 0, left: 0 })
|
||||
.layer(Layer::Overlay)
|
||||
.space(Zone::Ignore)
|
||||
.namespace(if left { "magic-pods-left" } else { "magic-pods-right" });
|
||||
.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));
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
/*
|
||||
* the pills beside an open card are gone; a closing card shrinks back into its own
|
||||
* pill's place, and they come back behind it only over the last `reveal_at` of its
|
||||
* shrinking, so they don't pop up around a big card
|
||||
*/
|
||||
let reveal_at = layout::reveal_at();
|
||||
let others = if grow <= 0.0 {
|
||||
1.0
|
||||
} else if reveal_at <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let k = (1.0 - grow / reveal_at).clamp(0.0, 1.0);
|
||||
|
||||
k * k * (3.0 - 2.0 * k)
|
||||
};
|
||||
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 pod in pods {
|
||||
// the open one is the card, drawn last, over the others
|
||||
if card.is_some_and(|(open, _)| open == pod) || others <= 0.0 {
|
||||
for item in ROW {
|
||||
let (x, seen) = scene.place(item);
|
||||
|
||||
if seen <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
match item {
|
||||
Item::Island => {
|
||||
// the island's own window draws its pill while nothing is open here
|
||||
if card.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// it shrinks to its cover (or its bars) as it moves aside
|
||||
let small = scene.row_grow;
|
||||
let width = mix(island_pill::width(), island::small_width(), small);
|
||||
let on = here.to_string();
|
||||
let pill = island::pill_face(here, small).cursor(Pointer).on_hover(edge::over_bar).on_click(move |_| island_tapped(on.clone()));
|
||||
let pill = if seen < 1.0 { pill.opacity(seen) } else { pill };
|
||||
|
||||
regions.push(InputArea { x: x as i32, y: top as i32, width: 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,
|
||||
} * seen;
|
||||
|
||||
if shown <= 0.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 others < 1.0 { pill.opacity(others) } else { pill };
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let typing = card.is_some_and(|(pod, _)| pod.types() && state.shown);
|
||||
window.input_region(regions).child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent)))
|
||||
}
|
||||
|
||||
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 };
|
||||
/*
|
||||
* 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 width = mix(h, card_w, grow);
|
||||
let height = mix(h, card_h, grow);
|
||||
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 = if reveal > 0.0 {
|
||||
veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors)
|
||||
} else {
|
||||
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, theme::now().text, pill_shown);
|
||||
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(Start, Start).child(face);
|
||||
let shape = surface::shell(width, height, radius, &colors, lit * pill_shown, face).on_hover(over_card).translate(x, top + slide);
|
||||
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 + slide) as i32, width: width.ceil() as i32, height: height.ceil() as i32 });
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -346,12 +612,14 @@ fn side(monitor: &Monitor, left: bool) -> LayerWindow {
|
||||
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 {
|
||||
// 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(theme.dim, theme.text, 0.6 + 0.4 * lit));
|
||||
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 };
|
||||
@@ -365,7 +633,7 @@ fn pill(pod: Pod, lit: f32, colors: &[Color; 3], monitor: &str) -> Rectangle {
|
||||
.width(badge)
|
||||
.height(badge)
|
||||
.radius(Full)
|
||||
.fill(theme.accent)
|
||||
.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)),
|
||||
@@ -375,7 +643,7 @@ fn pill(pod: Pod, lit: f32, colors: &[Color; 3], monitor: &str) -> Rectangle {
|
||||
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))
|
||||
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 {
|
||||
@@ -494,9 +762,9 @@ pub fn command(arguments: &[String]) -> String {
|
||||
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())
|
||||
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> | notices list|dismiss <id>|clear | status"),
|
||||
_ => String::from("usage: pods open|toggle <ai|monitor|home|notifications> [monitor] | close | ask <question> | processes | notices list|dismiss <id>|clear | status"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,4 +772,3 @@ pub fn command(arguments: &[String]) -> String {
|
||||
pub fn rule(width: f32) -> Rectangle {
|
||||
Rectangle::new().width(width).height(1.0).fill(overlays::track())
|
||||
}
|
||||
|
||||
|
||||
@@ -293,6 +293,14 @@ pub fn height() -> f32 {
|
||||
2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + (parts - 1.0) * 10.0
|
||||
}
|
||||
|
||||
// the card's height at its tallest: an external gpu with its whole list of programs
|
||||
pub fn tallest() -> f32 {
|
||||
let graphs = 7.0 * (label_height() + settings::graph_height()) + 6.0 * 10.0;
|
||||
let egpu = (1 + settings::programs()) as f32 * program_row_height();
|
||||
|
||||
2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + 5.0 * 10.0
|
||||
}
|
||||
|
||||
// bytes a second in words
|
||||
fn rate(bytes: f32) -> String {
|
||||
match bytes {
|
||||
|
||||
Reference in New Issue
Block a user