17 Commits
Author SHA1 Message Date
server2 90130add06 Merge branch 'config-cache' 2026-10-20 15:37:42 +02:00
server2 1b8fc66130 Merge side-pills-center: the four side pills in the carousel, their settings, the idle card, the performance shaders, the AI servers and the config window's type-to-search 2026-10-20 15:37:42 +02:00
server2 f79662bd5b Config window: keys typed with no field selected, as right after opening it, go into the search 2026-10-20 15:07:31 +02:00
server2 e0d8a5479c AI pill: several Ollama servers (pills.ai.servers, comma-separated, in order): the model list shows all their models, a question goes to the first server that has the chosen one; a local ollama is started only when no other server has it 2026-10-20 15:07:11 +02:00
server2 344d318959 Config window: keys typed with no field selected, as right after opening it, go into the search 2026-10-20 15:07:00 +02:00
server2 801eab9ab4 Config: each accessor caches its value per thread until the settings change
A read was a lock, a hash of the path and a conversion (a colour parsed from its text),
160 to 570 ns in the unoptimised shell, a few hundred a frame with a card open. Now an
atomic load and a copy while nothing changed. Every write to the settings goes through
change(), which bumps the generation the caches compare against
2026-10-20 14:46:08 +02:00
server2 c0fff44bcf Carousel: the other four pills in the row's order, two left and two right of the card, so the left-most stay left-most; none changes sides on opening 2026-10-20 14:30:39 +02:00
server2 f55314f511 docs/design: the solid, frosted and liquid screenshots from their old branches 2026-10-19 13:15:26 +02:00
server2 3de3709871 Performance: surfaces, the candle ring and the equalizer curve as shaders, the pill's bars as quads; tools/perf.sh
A playing pill cost the iGPU about 2.9 ms and the CPU 3.7 ms a frame, 60 frames a
second: two vello passes over the whole island window for the surface's shadow and
gradients (vello keeps no gradient between frames) and one for the bars. Now 0.4 ms
GPU and 1.2 ms CPU. The open card went from 5.2 to 2.6 ms GPU and 6.2 to 3.4 ms CPU
a frame. surface.wgsl draws every design's shadow, fill and inner gradient in one
pass, with vello's own blurred-rectangle formula
2026-10-19 13:00:46 +02:00
server2 0457830aba Performance: surfaces, the candle ring and the equalizer curve as shaders, the pill's bars as quads; tools/perf.sh
A playing pill cost the iGPU about 2.9 ms and the CPU 3.7 ms a frame, 60 frames a
second: two vello passes over the whole island window for the surface's shadow and
gradients (vello keeps no gradient between frames) and one for the bars. Now 0.4 ms
GPU and 1.2 ms CPU. The open card went from 5.2 to 2.6 ms GPU and 6.2 to 3.4 ms CPU
a frame. surface.wgsl draws every design's shadow, fill and inner gradient in one
pass, with vello's own blurred-rectangle formula
2026-10-19 12:46:27 +02:00
server2 689b53be0f The island opens with nothing playing too: the last song played (kept across restarts) with Play, which starts its player, and chips that start players 2026-10-19 11:56:30 +02:00
server2 f8c17025ce The island opens with nothing playing too: the last song played (kept across restarts) with Play, which starts its player, and chips that start players 2026-10-19 11:55:50 +02:00
server2 aadf765d87 Idle pill: the words only where they fit with room at both ends, else the note alone, so it never nears the rim 2026-10-19 11:42:21 +02:00
server2 756f38be4e Idle pill: the note no longer sits against the rim (the elided words took the whole width); words only where they fit, and they fade before the carousel makes the island too narrow for them 2026-10-19 11:41:45 +02:00
server2 7cb2368b23 Carousel: always two items on each side of an open card, in the row's order going round (items changing sides fade instead of crossing); the island shrinks to its cover, or its bars 2026-10-16 02:56:15 +02:00
server2 8cbd211da0 Model list arrow flips when open; temperatures in a fixed order; bigger avatar; clock font, weight and digit width as settings; Cancel at the top of the event form, Esc cancels it 2026-10-16 02:52:11 +02:00
server2 e2ef1a9d52 Side pills, centred: cards open big in the middle as a carousel with the other pills and the island beside them, switch directly; each pill in its own colour; row and card in separate windows 2026-10-15 23:44:37 +02:00
13 changed files with 620 additions and 171 deletions
+3 -1
View File
@@ -113,7 +113,7 @@ Whether the open card takes the song cover's colours instead (`appearance.colors
## Side pills ## 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. - **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. - **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. `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 ### 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. 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
View File
@@ -1,7 +1,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::cell::RefCell;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex, PoisonError, RwLock}; use std::sync::{LazyLock, Mutex, PoisonError, RwLock};
use std::thread::LocalKey;
use amane::Color; use amane::Color;
@@ -181,7 +184,11 @@ macro_rules! settings {
$( $(
#[allow(dead_code)] #[allow(dead_code)]
pub fn $name() -> $ty { 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 { layout {
gap: f32 = 10.0, 0..=200; "Space between the island's pill and the small pills beside it" 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" 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" 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" radius: f32 = 28.0, 0..=60; "Corner radius of the opened cards"
pad: f32 = 18.0, 0..=60; "Padding inside the opened cards" pad: f32 = 24.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)" }
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 { ai {
width: f32 = 440.0, 280..=1200; "Width of the AI card" width: f32 = 760.0, 280..=1400; "Width of the AI card"
height: f32 = 520.0, 200..=1200; "Height 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" 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)" 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" 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" 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 { 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" 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" 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" 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" 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" 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" workspace: String = String::from("processes"); "The special workspace the process list opens on"
} }
home { 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_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_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" 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)" blocked: String = String::new(); "Apps whose notifications are dropped at once, comma-separated (Block app on a notification adds it)"
} }
history { history {
width: f32 = 420.0, 280..=1200; "Width of the notification card" width: f32 = 620.0, 280..=1400; "Width of the notification card"
height: f32 = 560.0, 200..=1400; "Height 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); 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 // what was wrong in the file when it was last read, for the config window
static ERRORS: Mutex<Vec<String>> = Mutex::new(Vec::new()); 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) 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 { 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) 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 { pub fn file() -> PathBuf {
theme::config_file("config.toml") 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 // shell.conf's four settings, moved to their places in config.toml
fn migrate() { fn migrate() {
let old = fs::read_to_string(theme::config_file("shell.conf")).unwrap_or_default(); 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) { for (key, text) in parse::settings(&old) {
let path = match key.as_str() { let path = match key.as_str() {
"cover_colors" => "appearance.colors.cover_colors", "cover_colors" => "appearance.colors.cover_colors",
@@ -687,6 +742,7 @@ fn migrate() {
values.insert(path.to_string(), value); values.insert(path.to_string(), value);
} }
} }
});
} }
// the file's text, read anew: what it sets replaces everything set before // 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; *ERRORS.lock().unwrap_or_else(PoisonError::into_inner) = errors;
hypr::sync_soon(); hypr::sync_soon();
@@ -803,7 +859,7 @@ pub fn set(path: &str, value: Value) -> Result<(), String> {
let mut written = written(); 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) save(&mut written)
} }
@@ -812,7 +868,7 @@ pub fn set(path: &str, value: Value) -> Result<(), String> {
pub fn reset(path: &str) -> Result<(), String> { pub fn reset(path: &str) -> Result<(), String> {
let mut written = written(); let mut written = written();
VALUES.write().unwrap_or_else(PoisonError::into_inner).remove(path); change(|values| values.remove(path));
save(&mut written) save(&mut written)
} }
@@ -991,3 +1047,29 @@ pub fn label(name: &str) -> String {
title(name) 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
View File
@@ -349,7 +349,11 @@ fn sync_settings() -> (String, String, String, bool) {
(agenda_settings::account(), agenda_settings::server(), agenda_settings::contacts_server(), agenda_settings::birthdays()) (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 // the event form closes without saving
fn cancel() { fn cancel() {
Agenda::write().form = None; Agenda::write().form = None;
@@ -366,6 +370,7 @@ pub fn escape() -> bool {
open open
} }
// whether the card has a form to type into: then it keeps the keyboard and stays open
pub fn typing() -> bool { pub fn typing() -> bool {
Agenda::read().form.is_some() Agenda::read().form.is_some()
} }
+101 -16
View File
@@ -225,8 +225,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.space(Zone::Ignore) .space(Zone::Ignore)
.namespace("magic-island"); .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)); 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) 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 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 // mid-grow: the empty shape alone, the cheapest frames of the animation
_ => Rectangle::new().width(0.0).height(0.0), _ => 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 // 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(); let mut island = Island::write();
if island.menu.take().is_none() { 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) 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 * a note and "Nothing playing", centred. the words only where they fit with half the
fn idle() -> Rectangle { * pill's height to spare at each end, else the note alone: an elided text takes the whole
let (width, h, note) = (c::pill::width(), c::pill::height(), 15.0); * width, which put the note against the rim, and a pill narrower than the words did too
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); 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(c::pill::height()).align_child(Center, Center).child(row.gap(8.0).align(Center))
Rectangle::new().width(width).height(h).align_child(Center, Center).child(row.gap(8.0).align(Center))
} }
// the pill: cover, title, the player's live levels const IDLE_NOTE: f32 = 15.0;
fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle {
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() Rectangle::new()
.width(c::pill::width()) .width(width)
.height(c::pill::height()) .height(c::pill::height())
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 }) .padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 })
.child( .child(
+3 -3
View File
@@ -47,9 +47,9 @@ fn main() {
.window_per_monitor(edge::spacer) .window_per_monitor(edge::spacer)
.window_per_monitor(edge::view) .window_per_monitor(edge::view)
.window_per_monitor(island::view) .window_per_monitor(island::view)
// the small pills beside the island, and their cards // the small pills beside the island, and their cards in the middle
.window_per_monitor(pods::left) .window_per_monitor(pods::row_view)
.window_per_monitor(pods::right) .window_per_monitor(pods::card_view)
.window_per_monitor(workspaces::view) .window_per_monitor(workspaces::view)
.window_per_monitor(control::view) .window_per_monitor(control::view)
.window_per_monitor(control::menu_view) .window_per_monitor(control::menu_view)
+379 -112
View File
@@ -8,20 +8,27 @@ use amane::{
}; };
use crate::config::appearance::{motion, overlays, text}; 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::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::edge::{self, Edge};
use crate::glyph::{Glyph, icon}; use crate::glyph::{Glyph, icon};
use crate::island::{self, CLEAR, mix, phases, veiled}; use crate::island::{self, CLEAR, mix, phases, veiled};
use crate::parse::Sky;
use crate::{ai, home, notices, stats, surface, theme}; use crate::{ai, home, notices, stats, surface, theme};
/* /*
* four small one-icon pills beside the island, each opening into a card the way the * four small one-icon pills beside the island, each opening into a big card in the
* island does: left of it the AI chat and the system monitor, right of it home and * middle of the top of the screen. while a card is open the row becomes a carousel:
* notifications. one window per side and monitor (magic-pods-left|right) placed beside * every other pill and the island's pill line up left and right of the card in their
* the island's pill; while the island opens they move out of its way. one card at a * usual order ([AI][System] island [Home][Bell]), and clicking one switches to its card.
* time across the shell: opening one collapses the island and the other way round * 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)] #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Pod { pub enum Pod {
@@ -31,15 +38,18 @@ pub enum Pod {
Notifications, Notifications,
} }
// left of the island, outermost first; right of it, innermost first // the row, left to right
const LEFT: [Pod; 2] = [Pod::Ai, Pod::Monitor]; #[derive(Clone, Copy, PartialEq, Eq)]
const RIGHT: [Pod; 2] = [Pod::Home, Pod::Notifications]; enum Item {
Pod(Pod),
impl Pod { Island,
fn left(self) -> bool {
matches!(self, Self::Ai | Self::Monitor)
} }
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 { fn glyph(self) -> Glyph {
match self { match self {
Self::Ai => Glyph::Sparkle, 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 // whether its card takes typing: then it has the keyboard and stays open when the pointer leaves
fn types(self) -> bool { fn types(self) -> bool {
self == Self::Ai || (self == Self::Home && home::typing()) self == Self::Ai || (self == Self::Home && home::typing())
@@ -74,16 +102,25 @@ impl Pod {
} }
fn from_word(word: &str) -> Option<Self> { 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 { pub struct Pods {
// the card open or closing, and the monitor it's on; `shown` whether it's opening or open // the card open or closing, and the monitor it's on; `shown` whether it's opening or open
card: Option<(Pod, String)>, card: Option<(Pod, String)>,
shown: bool, shown: bool,
timeline: Animation, 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>, hover: HashMap<Pod, Animation>,
// the pointer over the card, and a count every arrival and departure bumps, so a late close does nothing // 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 { impl Service for Pods {
fn new() -> Self { 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 // 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) 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) { pub fn open(pod: Pod, monitor: &str) {
island::collapse(); island::collapse();
let mut pods = Pods::write(); let mut pods = Pods::write();
// another card, or this one on another monitor: it grows from its own pill let open_here = pods.shown && pods.timeline.value() > 0.5;
if pods.card.as_ref().is_none_or(|(open, on)| *open != pod || on != monitor) { 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.card = Some((pod, monitor.to_string()));
pods.timeline = timeline(); pods.timeline = timeline();
pods.previous = None;
pods.switch = switching(1.0);
}
} }
pods.shown = true; pods.shown = true;
@@ -152,6 +208,20 @@ pub fn open_card() -> Option<Pod> {
pods.card.as_ref().filter(|_| pods.shown).map(|(pod, _)| *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 { fn hovering(pod: Pod) -> impl Fn(bool) + 'static {
move |inside| { move |inside| {
edge::over_bar(inside); edge::over_bar(inside);
@@ -195,138 +265,334 @@ fn over_card(inside: bool) {
}); });
} }
pub fn left(monitor: &Monitor) -> LayerWindow { // the island's pill in the carousel: the card closes back into place, then the island opens
side(monitor, true) 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 { // an item's width in the carousel
side(monitor, false) 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(); theme::begin();
let here = monitor.name.as_str(); let here = monitor.name.as_str();
let pods = if left { LEFT } else { RIGHT }; let wallpaper = theme::now().surfaces();
let colors = theme::now().surfaces();
let state = Pods::read(); 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 // with the bar on a fullscreen monitor, unless a card is 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); let slide = if scene.card.is_some() { 0.0 } else { edge::hidden_by(Edge::read().shown(here), 0.0, edge::hide_h()) };
// 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 };
let window = LayerWindow::new() let window = LayerWindow::new()
.width(inner_w + 2.0 * shadow) .width(monitor.width as f32)
.height(window_h) .height(top + h + shadow)
.anchor_vertical(Vertical::Top) .anchor_vertical(Vertical::Top)
.anchor_horizontal(Horizontal::Left) .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) .layer(Layer::Overlay)
.space(Zone::Ignore) .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 // empty and click-through rather than hidden: see the island's view for why
if slide <= -edge::hide_h() { if slide <= -edge::hide_h() {
return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); 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 (card, previous, switch) = (scene.card, scene.previous, scene.switch);
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 mut layers: Vec<Box<dyn Widget>> = Vec::new(); let mut layers: Vec<Box<dyn Widget>> = Vec::new();
let mut regions: Vec<InputArea> = Vec::new(); let mut regions: Vec<InputArea> = Vec::new();
for pod in pods { for item in ROW {
// the open one is the card, drawn last, over the others let (x, seen) = scene.place(item);
if card.is_some_and(|(open, _)| open == pod) || others <= 0.0 {
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; continue;
} }
let x = pill_x(pod);
let lit = state.hover.get(&pod).map_or(0.0, Animation::value); let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
let pill = pill(pod, lit, &colors, here); let pill = pill(pod, lit, &wallpaper, here);
let pill = if others < 1.0 { pill.opacity(others) } else { pill }; 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 }); 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))); 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 * the open card, in the middle: a window as big as the biggest card while one is open
let from_x = pill_x(pod); * here, and an empty pixel otherwise. it changes size only while it shows nothing (the
let to_x = if left { near - shift - card_w } else { near + shift }; * 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 wallpaper = theme::now().surfaces();
let height = mix(h, card_h, grow); 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 x = mix(from_x, to_x, grow);
let radius = mix(h / 2.0, layout::radius(), grow); let radius = mix(h / 2.0, layout::radius(), grow);
let lit = state.hover.get(&pod).map_or(0.0, Animation::value); 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 { let face: Rectangle = match previous {
veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors) // the card before fades out over the first half of the switch, this one in over the second
} else { 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 // 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)) 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 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 + slide); 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)); 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))) 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 // a small pill in its own colour: the surface with one icon; the bell carries the unread count
fn pill(pod: Pod, lit: f32, colors: &[Color; 3], monitor: &str) -> Rectangle { fn pill(pod: Pod, lit: f32, wallpaper: &[Color; 3], monitor: &str) -> Rectangle {
let h = island_pill::height(); let h = island_pill::height();
let theme = theme::now(); 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 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 }; 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) .width(badge)
.height(badge) .height(badge)
.radius(Full) .radius(Full)
.fill(theme.accent) .fill(hue)
.align_child(Center, Center) .align_child(Center, Center)
.translate(h - badge - 3.0, 3.0) .translate(h - badge - 3.0, 3.0)
.child(Text::new(words).size(text::tiny() - 1.0).weight(Weight::Bold).color(theme.on_accent)), .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 content = Rectangle::new().width(h).height(h).child(Stack::new(inside).width(h).height(h));
let on = monitor.to_string(); 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 { 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 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()); 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 { pub fn rule(width: f32) -> Rectangle {
Rectangle::new().width(width).height(1.0).fill(overlays::track()) Rectangle::new().width(width).height(1.0).fill(overlays::track())
} }
+8
View File
@@ -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 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 // bytes a second in words
fn rate(bytes: f32) -> String { fn rate(bytes: f32) -> String {
match bytes { match bytes {