6 Commits

Author SHA1 Message Date
server2 9407ee3506 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:54 +02:00
server2 84b01f7c56 Config window: keys typed with no field selected, as right after opening it, go into the search 2026-10-20 15:07:27 +02:00
server2 9b4334835a 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:35 +02:00
server2 49a0bae039 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 bccdb86ebb 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:11 +02:00
server2 ed0eb532db 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:51:51 +02:00
17 changed files with 1143 additions and 224 deletions
+15 -7
View File
@@ -2,7 +2,7 @@
A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card.
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing".
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing". It opens too, into the last song played (kept across restarts) with Play, which starts its player and asks it to play, and chips that start the players of `island.idle.players` (desktop file names, `gtk-launch`).
- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next.
- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays.
- **Output switcher:** moves only the shown player's audio to another device.
@@ -115,7 +115,7 @@ Whether the open card takes the song cover's colours instead (`appearance.colors
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.
- **AI** (sparkle): a chat with a local Ollama model, `gemma4:e4b` at `http://localhost:11434` by default; the model chip in its header lists the installed models and switches at once. 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 leaves the machine.
- **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.
- **Home** (house): `~/.face` as the avatar (a person glyph until the file exists), a greeting, the date, a big clock, and Munich's weather now and for four days from open-meteo (no key), over a photo of the current weather (`~/.config/amane/weather/<kind>-<day|night>.jpg`, downloaded once from Wikimedia Commons, credits in `weather/CREDITS.md`; swap any file, `pills.home.photo_opacity` tones it down). A big month grid marks events (dots in their calendars' colours), birthdays from your iCloud contacts (a cake) and the public holidays of `pills.calendar.holidays` (BY by default); click a day for its list. Events can be created, edited and deleted there (a series: title, place and calendar only). For iCloud, set `pills.calendar.account` to your Apple ID and store an [app-specific password](https://account.apple.com) in the keyring: `secret-tool store --label='Magic Island calendar' service amane-calendar account <Apple ID>` (gnome-keyring or another Secret Service has to run). The password never goes into `config.toml`. Other CalDAV/CardDAV servers work through `pills.calendar.server` and `contacts_server`.
- **Notifications** (bell, with the unread count): Amane is the notification daemon (`org.freedesktop.Notifications`), so dunst must not run; it is commented out in `~/.config/hypr/autocmds.lua` (the old file is beside it as `autocmds.lua.before-amane-notifications`). Popups appear at the top right of the focused monitor with the app's icon or image, summary, body (simple markup) and action buttons. They time out by urgency (critical ones stay) and wait while the pointer is over them. A click runs the default action. The card lists the history, newest first; each entry's cross deletes it, and Clear all deletes everything. Mute app keeps an app's notifications in the history without popups; Block app drops them (`notifications.apps.muted|blocked`, undone from chips at the top of the card). Do not disturb turns all popups off.
@@ -128,11 +128,17 @@ Left out on purpose: phone stats (iOS shares nothing Linux can read without extr
Every setting lives in `~/.config/amane/config.toml`: sizes, timings, text sizes, colours, poll intervals, equalizer presets and bands, the hotspot profile, the extra Sound output. The shell writes the file with every setting and its explanation the first time it starts (moving `cover_colors`, `transition_ms`, `hotspot` and `extra_output` over from an old `shell.conf`, which it no longer reads). Saving the file applies it at once; a line left out takes its default, and a line the shell can't use is listed at the top of the config window. The font and the cava bar count take effect on restart.
The config window edits the same file: Settings at the foot of the control centre, or `amane ipc call config toggle`. It is a normal window with a page per part of the shell, a page per Hyprland category, and a search over everything. Numbers with a range get a slider and a field, switches toggle, colours show a swatch; a typed value applies on Enter. Reset brings back the default.
The config window edits the same file: Settings at the foot of the control centre, or `amane ipc call config toggle`. It is a normal window with a page per part of the shell, a page per Hyprland category, and a search over everything; typing with no field selected (as right after opening it) goes into the search. Numbers with a range get a slider and a field, switches toggle, colours show a swatch; a typed value applies on Enter. Reset brings back the default.
### Glass
`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`. 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.
### 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.
`tools/perf.sh PID [SECONDS]` (on the desktop) prints a process's CPU (and its children's), GPU per engine (DRM fdinfo) and memory.
Amane can only blur what the shell draws, so the frosting comes from Hyprland: a layer rule for the shell's windows (the namespace match is a full match). `ignore_alpha` leaves pixels at or below that opacity unblurred, which keeps the clear margin around the pills sharp; keep it below the design's thinnest part, or that part shows the desktop unblurred (liquid at its default 0.4 is 0.22 in its top left corner).
@@ -177,14 +183,15 @@ src/png.rs a stdlib png writer, for tray icons sent as pixels
src/system.rs what the control centre shows: iwd, wired cards, openvpn, power, the external GPU
src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one
src/theme.rs the shell's colours: the matugen theme file, the cross-fade; watches config.toml
src/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs
src/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs as values for surface.wgsl
src/*.wgsl shaders: surface (every surface), ring (the candles), curve (the equalizer's line)
src/config.rs every setting, declared once (typed accessors + the window's list), config.toml read and written
src/hypr.rs Hyprland's options for the config window: hyprctl descriptions/getoption, hl.config, amane.lua
src/settings.rs the config window, `config` IPC
src/parse.rs pure text parsing with unit tests, no amane dependency
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq)
tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), see AGENTS.md
tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), perf.sh (CPU, GPU, memory of a process), see AGENTS.md
```
## Requirements
@@ -238,8 +245,9 @@ bind = SUPER, M, exec, amane ipc call island toggle
- `~/.config/amane/config.toml`: every setting (see [Settings](#settings)); rewritten by the config window.
- `~/.config/hypr/amane.lua`: the Hyprland options set in the config window, as `hl.config`; rewritten by the shell.
- `$XDG_STATE_HOME/amane/` (`~/.local/state/amane/`): `last-played` (the song the island last heard, `key = value`) and a copy of its cover, for the card shown while nothing plays.
- `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`.
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout.
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`, the shaders (`*.wgsl`). On tmpfs, gone at logout.
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
## Known limits
+120 -39
View File
@@ -14,10 +14,11 @@ use crate::parse::{self, Chunk};
use crate::{pods, theme};
/*
* the AI card: a chat with a local model through ollama's http api (curl, streamed line
* by line), never a cloud service. ollama isn't kept running: the first question starts
* its openrc service when it doesn't answer (doas, the same fixed rc-service rule the
* control centre uses)
* the AI card: a chat with a model on your own ollama servers (pills.ai.servers) through
* their http api (curl, streamed line by line), never a cloud service. a question goes to
* the first server that has the chosen model. a local ollama isn't kept running: it's
* started (doas, the same fixed rc-service rule the control centre uses) when it doesn't
* answer and no other server has the model
*/
#[derive(Clone, Copy, PartialEq)]
pub enum Who {
@@ -36,8 +37,8 @@ pub struct Ai {
typed: String,
// bumped by Stop and New chat, so an answer still streaming stops writing
round: u32,
// the models ollama has, while the model list is open (empty while it loads)
models: Option<Vec<String>>,
// (server, model) for every model the servers have, while the model list is open (empty while it loads)
models: Option<Vec<(String, String)>>,
}
impl Service for Ai {
@@ -114,11 +115,15 @@ fn new_chat() {
ai.back = 0.0;
}
// on the answering thread: start ollama if needed, then stream the reply into the chat
// on the answering thread: find the model's server (starting a local ollama if needed), then stream the reply into the chat
fn answer(round: u32) -> Result<(), String> {
let server = settings::server().trim_end_matches('/').to_string();
let model = settings::model();
wake(&server, |words| note(round, words))?;
let server = models(Some(&model), |words| note(round, words))?
.into_iter()
.find(|(_, name)| *name == model)
.map(|(server, _)| server)
.ok_or_else(|| format!("None of pills.ai.servers has {model}"))?;
let body = request();
@@ -170,18 +175,97 @@ fn answer(round: u32) -> Result<(), String> {
Ok(())
}
// starts the local ollama service when it doesn't answer, telling `say` what's going on
// pills.ai.servers, in order, without trailing slashes
fn servers() -> Vec<String> {
settings::servers().split(',').map(|server| server.trim().trim_end_matches('/').to_string()).filter(|server| !server.is_empty()).collect()
}
fn local(server: &str) -> bool {
server.contains("://localhost") || server.contains("://127.0.0.1")
}
// the models a server has; none when it doesn't answer
fn models_on(server: &str) -> Option<Vec<String>> {
let done = Command::new("curl").args(["-s", "--connect-timeout", "1", "-m", "5", &format!("{server}/api/tags")]).output().ok()?;
if !done.status.success() {
return None;
}
let tags = parse::json(&String::from_utf8_lossy(&done.stdout));
let names = tags.first().and_then(|tags| tags.get("models")).map(|models| match models {
parse::Value::List(models) => models.iter().filter_map(|model| model.get("name").and_then(parse::Value::text).map(str::to_string)).collect(),
_ => Vec::new(),
});
Some(names.unwrap_or_default())
}
/*
* (server, model) for the servers' models, in the servers' order, each model once with the
* first server that has it; with `wanted`, only until a server has that model. a local
* server that doesn't answer is started (pills.ai.start_service) when the others don't have
* `wanted`, or for the whole list; `say` tells what's going on
*/
fn models(wanted: Option<&str>, say: impl Fn(&str)) -> Result<Vec<(String, String)>, String> {
let servers = servers();
if servers.is_empty() {
return Err(String::from("pills.ai.servers names no server"));
}
let mut started = false;
loop {
let mut found: Vec<(String, String)> = Vec::new();
let mut silent_local = None;
let mut answered = false;
for server in &servers {
let Some(names) = models_on(server) else {
if local(server) && silent_local.is_none() {
silent_local = Some(server.clone());
}
continue;
};
answered = true;
for name in names {
if !found.iter().any(|(_, known)| *known == name) {
found.push((server.clone(), name));
}
}
if wanted.is_some_and(|wanted| found.iter().any(|(_, name)| name == wanted)) {
return Ok(found);
}
}
if let Some(server) = silent_local.filter(|_| !started && settings::start_service()) {
wake(&server, &say)?;
started = true;
continue;
}
return match (answered, found.is_empty()) {
(false, _) => Err(format!("No Ollama server answers ({})", servers.join(", "))),
(true, true) => Err(String::from("The servers have no models (ollama pull <model>)")),
(true, false) => Ok(found),
};
}
}
// starts the local ollama service, telling `say` what's going on, and waits until it answers
fn wake(server: &str, say: impl Fn(&str)) -> Result<(), String> {
if answers(server) {
return Ok(());
}
let local = server.contains("://localhost") || server.contains("://127.0.0.1");
if !(settings::start_service() && local) {
return Err(format!("Ollama doesn't answer at {server}"));
}
say("Starting ollama…");
let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output();
@@ -213,24 +297,7 @@ fn pick() {
drop(ai);
thread::spawn(|| {
let server = settings::server().trim_end_matches('/').to_string();
let say = |words: &str| Ai::write().note = (!words.is_empty()).then(|| words.to_string());
let names = wake(&server, say).and_then(|()| {
let done = Command::new("curl").args(["-s", "-m", "5", &format!("{server}/api/tags")]).output().map_err(|error| format!("curl: {error}"))?;
let tags = parse::json(&String::from_utf8_lossy(&done.stdout));
let names: Vec<String> = tags
.first()
.and_then(|tags| tags.get("models"))
.map(|models| match models {
parse::Value::List(models) => models.iter().filter_map(|model| model.get("name").and_then(parse::Value::text).map(str::to_string)).collect(),
_ => Vec::new(),
})
.unwrap_or_default();
if names.is_empty() { Err(String::from("Ollama has no models (ollama pull <model>)")) } else { Ok(names) }
});
let names = models(None, |words| Ai::write().note = (!words.is_empty()).then(|| words.to_string()));
let mut ai = Ai::write();
@@ -321,7 +388,9 @@ pub fn card(width: f32, height: f32) -> Rectangle {
let theme = theme::now();
// the model, as a chip that opens the list of the others
let mut buttons: Vec<Box<dyn Widget>> = vec![Box::new(pods::chip(&settings::model(), Some(Glyph::Chevron), pick))];
// the model, as a chip that opens the list of the others; its arrow points up while the list is open
let arrow = if ai.models.is_some() { Glyph::ChevronUp } else { Glyph::Chevron };
let mut buttons: Vec<Box<dyn Widget>> = vec![Box::new(pods::chip(&settings::model(), Some(arrow), pick))];
if ai.busy {
buttons.push(Box::new(pods::chip("Stop", None, stop)));
@@ -364,12 +433,17 @@ pub fn card(width: f32, height: f32) -> Rectangle {
let chat: Box<dyn Widget> = if let Some(models) = &ai.models {
let current = settings::model();
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(Text::new(if models.is_empty() { "Asking ollama for its models…" } else { "Chat with" }).size(text::small()).color(theme.dim))];
let several = servers().len() > 1;
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(Text::new(if models.is_empty() { "Asking the servers for their models…" } else { "Chat with" }).size(text::small()).color(theme.dim))];
for name in models {
for (server, name) in models {
let chosen = *name == current;
let picked = name.clone();
// with more than one server, where the model is, without the scheme
let host = if several { server.split("://").last().unwrap_or(server) } else { "" };
let host_w = host.chars().count() as f32 * text::small() * crate::config::appearance::font::letter_width();
rows.push(Box::new(
Rectangle::new()
.width(width)
@@ -380,13 +454,20 @@ pub fn card(width: f32, height: f32) -> Rectangle {
.align_child(Start, Center)
.cursor(amane::Pointer)
.on_click(move |_| choose(picked.clone()))
.child(Text::new(name.as_str()).size(text::body()).color(theme.text).elide()),
.child(
Row::new(vec![
Box::new(Rectangle::new().width(width - 24.0 - host_w - 12.0).height(34.0).align_child(Start, Center).child(Text::new(name.as_str()).size(text::body()).color(theme.text).elide())) as Box<dyn Widget>,
Box::new(Text::new(host).size(text::small()).color(theme.dim)),
])
.gap(12.0)
.align(Center),
),
));
}
Box::new(Rectangle::new().width(width).height(chat_h).clip().child(Column::new(rows).gap(6.0)))
} else if bubbles.is_empty() {
Box::new(Rectangle::new().width(width).height(chat_h).align_child(Center, Center).child(Text::new(format!("Ask {} anything; it runs on this computer", settings::model())).size(text::small()).color(theme.dim)))
Box::new(Rectangle::new().width(width).height(chat_h).align_child(Center, Center).child(Text::new(if servers().iter().all(|server| local(server)) { format!("Ask {} anything; it runs on this computer", settings::model()) } else { format!("Ask {} anything; it runs on your own servers", settings::model()) }).size(text::small()).color(theme.dim)))
} else {
// the column's bottom on the area's bottom, moved down by the scroll back; what's above is cut
let back = ai.back.min((total - chat_h).max(0.0));
+9
View File
@@ -42,6 +42,15 @@ impl Service for Art {
*Self::write() = Self { url, path };
}
// the song playing, kept for the card shown while nothing plays
let (url, path) = {
let art = Self::read();
(art.url.clone(), art.path.clone())
};
crate::last::watch(&url, &path);
// 4 checks a second: after next/previous the new cover follows the title quickly
thread::sleep(Duration::from_millis(timing::cover_check_ms()));
}
+11 -3
View File
@@ -329,6 +329,11 @@ settings! {
row_letters: usize = 30, 4..=200; "Letters of a device name a menu row shows before cutting the middle"
chip_letters: usize = 13, 4..=200; "Letters of a device name the output chip shows before cutting the middle"
}
idle {
players: String = String::from("cider, spotify, tidal-hifi"); "Players the card offers to start while nothing plays: their desktop file names (cider for cider.desktop), comma-separated"
wait_s: u64 = 30, 1..=300; "How long a player started from the card gets to start playing before the card gives up"
check_ms: u64 = 500, 100..=5000; "How often a player started from the card is looked for and asked to play"
}
}
sound {
@@ -508,7 +513,7 @@ settings! {
ai {
width: f32 = 440.0, 280..=1200; "Width of the AI card"
height: f32 = 520.0, 200..=1200; "Height of the AI card"
server: String = String::from("http://localhost:11434"); "The Ollama server the AI card talks to"
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"
@@ -518,7 +523,7 @@ settings! {
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"
temperatures: usize = 6, 0..=20; "How many temperature sensors to show, hottest first"
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"
cool_color: Color = Color::rgb(120, 200, 255); "Colour of a sensor below the warm temperature"
@@ -532,9 +537,12 @@ settings! {
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"
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"
clock_letter_width: f32 = 0.0, 0..=2; "Width of one of the clock's digits as a share of its size, so the weather beside it makes room; 0 takes the shell font's"
seconds: bool = false; "Show seconds on the big clock"
avatar: String = String::from("~/.face"); "The picture shown as the user's avatar; a drawn person without one"
avatar_size: f32 = 48.0, 24..=120; "Size of the avatar; the greeting row is as tall"
avatar_size: f32 = 84.0, 24..=200; "Size of the avatar; the greeting row is as tall"
gap: f32 = 12.0, 0..=60; "Space between the home card's parts"
city: String = String::from("Munich"); "The weather's place, as shown"
latitude: f32 = 48.137, -90.0..=90.0; "The weather's latitude"
+79
View File
@@ -0,0 +1,79 @@
// the equalizer's curve: a line through the sliders' knobs, eased between neighbours,
// shivering up and down with the music, a faint wide stroke under a thin bright one.
// island.rs fills the values:
// 0: x of the first point, width the points span, steps, bands
// 1: levels given, glow width, line width, unused
// 2: glow colour, 3: line colour (plain, 0 to 1)
// 4-6: the knobs' heights, one per band
// 7-15: the live levels, low to high, 0 to 1
const PI: f32 = 3.14159265359;
fn knob(band: u32) -> f32 {
return values[4u + band / 4u][band % 4u];
}
fn loudness(along: f32) -> f32 {
let count = u32(values[1].x);
if count == 0u {
return 0.0;
}
let index = min(u32(round(along * f32(count - 1u))), count - 1u);
return values[7u + index / 4u][index % 4u];
}
// the curve's point number `step`, as island.rs placed it on the old canvas
fn point(step: u32) -> vec2<f32> {
let steps = values[0].z;
let bands = u32(values[0].w);
let along = f32(step) / steps;
let place = along * f32(bands - 1u);
let band = min(u32(floor(place)), bands - 2u);
let eased = (1.0 - cos((place - f32(band)) * PI)) / 2.0;
let shiver = loudness(along) * 9.0 * select(0.6, -1.0, step % 2u == 0u);
return vec2<f32>(values[0].x + along * values[0].y, mix(knob(band), knob(band + 1u), eased) + shiver);
}
fn segment(p: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
let ab = b - a;
let t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-5), 0.0, 1.0);
return length(p - a - ab * t);
}
fn stroke(d: f32, width: f32, pixel: f32) -> f32 {
return clamp((width / 2.0 - d) / pixel + 0.5, 0.0, 1.0);
}
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let p = uv * size;
let pixel = max(fwidth(p.x), 1e-4);
let steps = u32(values[0].z);
// the segment under this pixel and its neighbours: a stroke can't reach further
let here = i32(floor((p.x - values[0].x) / max(values[0].y, 1e-5) * values[0].z));
var d = 1e9;
for (var offset = -1; offset <= 1; offset++) {
let first = clamp(here + offset, 0, i32(steps) - 1);
d = min(d, segment(p, point(u32(first)), point(u32(first) + 1u)));
}
let glow = vec4<f32>(values[2].rgb * values[2].a, values[2].a) * stroke(d, values[1].y, pixel);
let line = vec4<f32>(values[3].rgb * values[3].a, values[3].a) * stroke(d, values[1].z, pixel);
let color = line + glow * (1.0 - line.a);
if color.a <= 0.0 {
return vec4<f32>(0.0);
}
return vec4<f32>(color.rgb / color.a, color.a);
}
+2
View File
@@ -12,6 +12,7 @@ pub enum Glyph {
Muted,
Bluetooth,
Chevron,
ChevronUp,
// pointing left, for a menu's way back
Back,
Note,
@@ -138,6 +139,7 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
.cap(Cap::Round)
],
Glyph::Chevron => shapes![Path::new().move_to(x(0.12), y(0.32)).line_to(x(0.5), y(0.7)).line_to(x(0.88), y(0.32)).stroke(1.5, color).cap(Cap::Round)],
Glyph::ChevronUp => shapes![Path::new().move_to(x(0.12), y(0.68)).line_to(x(0.5), y(0.3)).line_to(x(0.88), y(0.68)).stroke(1.5, color).cap(Cap::Round)],
Glyph::Back => shapes![Path::new().move_to(x(0.64), y(0.14)).line_to(x(0.3), y(0.5)).line_to(x(0.64), y(0.86)).stroke(thin.max(1.6), color).cap(Cap::Round)],
// an eighth note: round head, stem, a curved flag
Glyph::Note => shapes![
+36 -11
View File
@@ -350,6 +350,22 @@ fn sync_settings() -> (String, String, String, bool) {
}
// whether the card has a form to type into: then it keeps the keyboard and stays open
// the event form closes without saving
fn cancel() {
Agenda::write().form = None;
}
// Esc in the card: closes the event form if it's open (then true), else leaves it to the card
pub fn escape() -> bool {
let open = Agenda::read().form.is_some();
if open {
cancel();
}
open
}
pub fn typing() -> bool {
Agenda::read().form.is_some()
}
@@ -1197,18 +1213,18 @@ fn top_row(clock: &Clock, width: f32, shapes: &mut Vec<Box<dyn Shape>>) -> Box<d
// the big clock, and the weather beside it; `y` is where the row starts in the card
fn middle_row(clock: &Clock, forecast: &Forecast, width: f32, today: i64, y: f32, shapes: &mut Vec<Box<dyn Shape>>) -> Box<dyn Widget> {
let theme = theme::now();
let gap = settings::gap();
let seconds_today = clock.now.rem_euclid(86_400);
let (hour, minute, second) = (seconds_today / 3_600, seconds_today % 3_600 / 60, seconds_today % 60);
let time = if settings::seconds() { format!("{hour:02}:{minute:02}:{second:02}") } else { format!("{hour:02}:{minute:02}") };
let clock_w = (settings::clock_size() * font::letter_width() * time.chars().count() as f32).min(width / 2.0);
let letter = if settings::clock_letter_width() > 0.0 { settings::clock_letter_width() } else { font::letter_width() };
let clock_w = (settings::clock_size() * letter * time.chars().count() as f32).min(width / 2.0);
let weather_w = (width - clock_w - gap).max(0.0);
let height = middle_h();
let big = Rectangle::new().width(clock_w).height(height).align_child(Start, Center).child(Text::new(time).size(settings::clock_size()).weight(Weight::Bold).color(theme.text).tight());
let big = Rectangle::new().width(clock_w).height(height).align_child(Start, Center).child(clock_text(time));
let weather = weather(forecast, weather_w, today, (clock_w + gap, y + (height - weather_h()) / 2.0), shapes);
Box::new(
@@ -1219,6 +1235,14 @@ fn middle_row(clock: &Clock, forecast: &Forecast, width: f32, today: i64, y: f32
)
}
// the big clock's digits in its own font and weight
fn clock_text(time: String) -> Text {
let digits = Text::new(time).size(settings::clock_size()).weight(settings::clock_weight().min(1000) as u16).color(theme::now().text).tight();
let family = settings::clock_font();
if family.trim().is_empty() { digits } else { digits.font(family.trim()) }
}
// the weather now over the coming days, at `origin` in the card
fn weather(forecast: &Forecast, width: f32, today: i64, origin: (f32, f32), shapes: &mut Vec<Box<dyn Shape>>) -> Rectangle {
let theme = theme::now();
@@ -1744,6 +1768,7 @@ fn event_form(agenda: &Agenda, form: &Form, width: f32, origin: (f32, f32), shap
};
let mut top: Vec<Box<dyn Widget>> = vec![Box::new(Text::new(heading).size(text::heading()).weight(Weight::Bold).color(theme.text))];
let mut actions: Vec<Box<dyn Widget>> = Vec::new();
if form.editing.is_some() {
let words = match (form.confirm, series) {
@@ -1753,11 +1778,16 @@ fn event_form(agenda: &Agenda, form: &Form, width: f32, origin: (f32, f32), shap
};
let button = button_size();
let at = (origin.0 + width - choice_width(words, button / 2.0 + 6.0), origin.1 + (pods::header_height() - button) / 2.0);
let cancel_w = choice_width("Cancel", 0.0);
let at = (origin.0 + width - cancel_w - gap - choice_width(words, button / 2.0 + 6.0), origin.1 + (pods::header_height() - button) / 2.0);
top.push(Box::new(icon_chip(words, form.confirm, Glyph::Trash, at, shapes, remove)));
actions.push(Box::new(icon_chip(words, form.confirm, Glyph::Trash, at, shapes, remove)));
}
// Cancel at the top, where it's always in sight (Esc does the same)
actions.push(Box::new(pods::chip("Cancel", None, cancel)));
top.push(Box::new(Row::new(actions).gap(gap).align(Center)));
let mut parts: Vec<Box<dyn Widget>> = vec![
Box::new(Rectangle::new().width(width).height(pods::header_height()).child(Row::new(top).width(width).justify(SpaceBetween).align(Center))),
Box::new(field(title, width)),
@@ -1834,12 +1864,7 @@ fn event_form(agenda: &Agenda, form: &Form, width: f32, origin: (f32, f32), shap
parts.push(Box::new(wrapped(problem.clone(), overlays::low_battery(), width)));
}
let buttons: Vec<Box<dyn Widget>> = vec![
Box::new(choice(if form.saving { "Saving…" } else { "Save" }, true, None, save)),
Box::new(pods::chip("Cancel", None, || Agenda::write().form = None)),
];
parts.push(Box::new(Row::new(buttons).gap(gap).align(Center)));
parts.push(Box::new(choice(if form.saving { "Saving…" } else { "Save" }, true, None, save)));
Box::new(Rectangle::new().width(width).height(calendar_h()).child(Column::new(parts).gap(gap)))
}
+96 -119
View File
@@ -1,13 +1,13 @@
use std::collections::HashMap;
use std::f32::consts::{PI, TAU};
use std::path::PathBuf;
use std::sync::LazyLock;
use std::thread;
use std::time::{Duration, Instant};
use amane::{
Animation, Argument, Blend, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient, Image,
InputArea, Layer, LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path, Pointer,
Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Start, Text, Vertical,
Weight, Widget, Workspaces, Zone, children, shapes,
Animation, Argument, Blend, Bus, Center, Color, Column, Easing, End, Full, Gradient, Image, InputArea, Layer,
LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Pointer, Rectangle, Row, Scroll, ScrollArea,
Service, Shadow, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children,
};
use crate::art::Art;
@@ -245,23 +245,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let spectrum = live.then(Spectrum::read);
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
let Some(player) = shown(&media, island.selected.as_deref()) else {
/*
* nothing playing: a quiet pill, so the island is always there. never
* visible(false): hyprland 0.56 configures a layer surface before its first
* commit, so a window that starts hidden gets a size anyway, amane logs "no
* child to draw" and waits for frame callbacks an undrawn surface never gets
*/
return window.click_through().child(
Rectangle::new()
.width(Parent)
.height(Parent)
.padding(Padding { top: c::pill::top_gap(), right: 0.0, bottom: 0.0, left: 0.0 })
.align_child(Center, Start)
.translate(0.0, slide)
.child(idle(&theme::now().surfaces())),
);
};
// nothing playing: a quiet pill, which opens into the last song's card (last.rs)
let player = shown(&media, island.selected.as_deref());
let (grow, pill, reveal) = phases(open);
@@ -280,7 +265,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
}
let width = mix(c::pill::width() + c::animation::hover_grow() * (lift + 2.0 * pop), c::card::width(), grow);
let height = mix(c::pill::height(), wide_h(), grow);
let height = mix(c::pill::height(), if player.is_some() { wide_h() } else { crate::last::height() }, grow);
let radius = mix(c::pill::height() / 2.0, c::card::radius(), grow);
/*
@@ -290,7 +275,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
*/
let wallpaper = theme::now().surfaces();
let colors = if cover_colors {
let colors = if cover_colors && player.is_some() {
let cover = backdrop_colors(&Palette::read());
std::array::from_fn(|stop| Color::blend(wallpaper[stop], cover[stop], reveal))
@@ -299,22 +284,24 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
};
// at most one face at a time, each faded by a veil instead of a gpu layer
let face = if reveal > 0.0 {
let scene = Scene {
player,
players: media.players(),
island: &island,
sound: &sound,
art: &art.path,
levels,
};
let face = match player {
Some(player) if reveal > 0.0 => {
let scene = Scene {
player,
players: media.players(),
island: &island,
sound: &sound,
art: &art.path,
levels,
};
veiled(card(&scene), width, height, radius, reveal, &colors)
} else if pill > 0.0 {
veiled(compact(player, playing(player, &island), &art.path, levels), width, height, radius, pill, &colors)
} else {
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),
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),
// 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),
};
// the face centred along the top: while the shape grows it is narrower than the shape
@@ -600,15 +587,15 @@ pub fn faded(color: Color, alpha: u8) -> Color {
Color::rgba(color.red(), color.green(), color.blue(), alpha)
}
// the island while no player is open: a music note and "Nothing playing", on the pill's surface
fn idle(colors: &[Color; 3]) -> Rectangle {
let content = Rectangle::new()
.width(c::pill::width())
.height(c::pill::height())
.align_child(Center, Center)
.child(Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(text::body()).color(theme::now().dim)]).gap(8.0).align(Center));
// 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);
surface::shell(c::pill::width(), c::pill::height(), c::pill::height() / 2.0, colors, 0.0, content)
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))
}
// the pill: cover, title, the player's live levels
@@ -683,46 +670,35 @@ fn stepped(volume: u8, scroll: Scroll) -> u8 {
(i32::from(volume) + step).clamp(0, 100) as u8
}
static RING: LazyLock<PathBuf> = LazyLock::new(|| surface::shader_file("ring.wgsl", include_str!("ring.wgsl")));
static CURVE: LazyLock<PathBuf> = LazyLock::new(|| surface::shader_file("curve.wgsl", include_str!("curve.wgsl")));
/*
* every candle goes into one path per look (glow, body, flame) instead of a
* shape each: 120 shapes cost this iGPU a third of its frame rate, 3 cost nothing
* the candles, drawn by ring.wgsl: as stroked paths they cost a vello pass over the whole
* island window on every frame of the music. the shader gets half the ring's levels (the
* other half mirrors them), at most 44 of them, eased the way the paths were
*/
fn ring(levels: &[f32]) -> Canvas {
let center = c::disc::size() / 2.0;
let inner = c::disc::cover() / 2.0 + 7.0;
fn ring(levels: &[f32]) -> Rectangle {
let candles = c::disc::candles();
let half = (candles / 2).max(1);
let given = half.min(44);
let mut glow = Path::new();
let mut body = Path::new();
let mut flames = Path::new();
let theme = theme::now();
let size = c::disc::size();
for index in 0..c::disc::candles() {
// a gentle curve lifts the quiet bands, so the ring isn't all bass
let level = mirrored(levels, index).powf(0.75);
let eased: Vec<f32> = (0..given).map(|slot| mirrored(levels, slot * half / given).powf(0.75)).collect();
let (sin, cos) = (index as f32 / c::disc::candles() as f32 * TAU).sin_cos();
let mut values = vec![
[size / 2.0, c::disc::cover() / 2.0 + 7.0, candles as f32, given as f32],
[c::disc::candle_reach(), 9.0, 4.5, 4.0],
surface::plain(faded(theme.accent, 50)),
surface::plain(faded(theme.accent, 215)),
surface::plain(theme.flame),
];
let reach = inner + 3.0 + level * c::disc::candle_reach();
values.extend(surface::rows(&eased));
let (x0, y0) = (center + sin * inner, center - cos * inner);
let (x1, y1) = (center + sin * reach, center - cos * reach);
body = body.move_to(x0, y0).line_to(x1, y1);
// the loud ones glow, and burn at the tip
if level > 0.25 {
glow = glow.move_to(x0, y0).line_to(x1, y1);
}
if level > 0.45 {
flames = flames.move_to(x1 - sin * 0.5, y1 + cos * 0.5).line_to(x1, y1);
}
}
Canvas::new().width(c::disc::size()).height(c::disc::size()).shapes(shapes![
glow.stroke(9.0, faded(theme::now().accent, 50)).cap(Cap::Round),
body.stroke(4.5, faded(theme::now().accent, 215)).cap(Cap::Round),
flames.stroke(4.0, theme::now().flame).cap(Cap::Round),
])
Rectangle::new().width(size).height(size).shader(RING.as_path()).shader_values(values)
}
// low frequencies at the top, rising down both sides, so the ring is symmetric
@@ -1170,40 +1146,34 @@ fn equalizer(scene: &Scene) -> Column {
}
// the curve through the knobs, shivering with the player's sound
fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas {
let column = inner_w() / crate::config::sound::equalizer::bands().len() as f32;
/*
* drawn by curve.wgsl, for the same reason as the ring: the points are worked out there,
* from the knobs' heights and at most 36 live levels
*/
fn curve(gains: &[f32; 10], levels: &[f32]) -> Rectangle {
let bands = crate::config::sound::equalizer::bands().len().clamp(2, gains.len());
let column = inner_w() / bands as f32;
let points: Vec<(f32, f32)> = (0..=c::equalizer::curve_steps())
.map(|step| {
let along = step as f32 / c::equalizer::curve_steps() as f32;
let given = levels.len().min(36);
let sampled: Vec<f32> = (0..given).map(|slot| levels[if given > 1 { slot * (levels.len() - 1) / (given - 1) } else { 0 }]).collect();
// cosine blend between neighbouring knobs keeps the curve round
let place = along * (crate::config::sound::equalizer::bands().len() - 1) as f32;
let band = (place.floor() as usize).min(crate::config::sound::equalizer::bands().len() - 2);
let eased = (1.0 - ((place - band as f32) * PI).cos()) / 2.0;
let knobs: Vec<f32> = gains[..bands].iter().map(|&gain| knob_y(gain)).collect();
let y = mix(knob_y(gains[band]), knob_y(gains[band + 1]), eased);
let mut values = vec![
[column / 2.0, inner_w() - column, c::equalizer::curve_steps() as f32, bands as f32],
[given as f32, 7.0, 2.0, 0.0],
surface::plain(faded(theme::now().accent, 45)),
surface::plain(faded(theme::now().accent, 235)),
];
let level = if levels.is_empty() { 0.0 } else { levels[((along * (levels.len() - 1) as f32).round() as usize).min(levels.len() - 1)] };
// three rows of knobs whatever their number, so the levels always start at row 7
let mut knob_rows = surface::rows(&knobs);
let shiver = level * 9.0 * if step % 2 == 0 { -1.0 } else { 0.6 };
knob_rows.resize(3, [0.0; 4]);
values.extend(knob_rows);
values.extend(surface::rows(&sampled));
(column / 2.0 + along * (inner_w() - column), y + shiver)
})
.collect();
let stroke = |width: f32, alpha: u8| {
let mut path = Path::new().move_to(points[0].0, points[0].1);
for (x, y) in &points[1..] {
path = path.line_to(*x, *y);
}
path.stroke(width, faded(theme::now().accent, alpha)).cap(Cap::Round)
};
// a wide faint stroke under a thin bright one reads as a glow
Canvas::new().width(inner_w()).height(c::equalizer::slider_height()).shapes(shapes![stroke(7.0, 45), stroke(2.0, 235)])
Rectangle::new().width(inner_w()).height(c::equalizer::slider_height()).shader(CURVE.as_path()).shader_values(values)
}
fn presets(scene: &Scene) -> Column {
@@ -1337,7 +1307,7 @@ fn title(player: &MediaPlayer) -> &str {
}
// a round cover with a pink rim, optionally with a cd hole; a soft gradient while there is none or it decodes
fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
pub fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
let frame = Rectangle::new()
.width(size)
.height(size)
@@ -1364,20 +1334,27 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
/*
* the pill's visualiser: seven bars from the shown player's own sound, glowing when
* loud; a pause sign while paused, and still bars while the capture starts or the
* player has no stream to listen to
* player has no stream to listen to. the bars are rounded rectangles, which amane draws
* as quads: as stroked paths on a canvas they cost a vello pass over the whole island
* window on every frame of the music
*/
fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
fn pill_levels(playing: bool, levels: &[f32]) -> Rectangle {
let (bars, width, height) = (c::pill::bars(), c::pill::bars_width(), c::pill::bars_height());
if !playing {
return icon(Glyph::Pause, 13.0, theme::now().dim);
return Rectangle::new().width(13.0).height(13.0).child(icon(Glyph::Pause, 13.0, theme::now().dim));
}
// lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line
let middle = height / 2.0;
let mut glow = Path::new();
let mut body = Path::new();
// a round-capped line `thick` wide from y0 to y1 at x: a pill shape reaching past both ends by half its width
let line = |x: f32, y0: f32, y1: f32, thick: f32, color: Color| {
Rectangle::new().width(thick).height(y1 - y0 + thick).radius(Full).fill(color).translate(x - thick / 2.0, y0 - thick / 2.0)
};
let mut glow: Vec<Box<dyn Widget>> = Vec::new();
let mut body: Vec<Box<dyn Widget>> = Vec::new();
for index in 0..bars {
let level = if levels.is_empty() {
@@ -1394,17 +1371,17 @@ fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
let half = 1.5 + level * (middle - 3.0);
let x = 3.0 + index as f32 * (width - 6.0) / bars.saturating_sub(1).max(1) as f32;
body = body.move_to(x, middle - half).line_to(x, middle + half);
body.push(Box::new(line(x, middle - half, middle + half, 3.2, theme::now().accent)));
if level > 0.3 {
glow = glow.move_to(x, middle - half).line_to(x, middle + half);
glow.push(Box::new(line(x, middle - half, middle + half, 7.0, faded(theme::now().accent, 55))));
}
}
Canvas::new().width(width).height(height).shapes(shapes![
glow.stroke(7.0, faded(theme::now().accent, 55)).cap(Cap::Round),
body.stroke(3.2, theme::now().accent).cap(Cap::Round),
])
// every glow under every bar, as the two strokes were
glow.extend(body);
Rectangle::new().width(width).height(height).child(Stack::new(glow).width(width).height(height))
}
// the loudest level in one of `groups` equal slices of the spectrum, low to high
+314
View File
@@ -0,0 +1,314 @@
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use std::{env, fs};
use amane::{Bus, Center, Column, Full, Media, Pointer, Rectangle, Row, Service, Start, Text, Weight, Widget};
use crate::config::appearance::{font, overlays, text};
use crate::config::island::{card, controls, disc, idle};
use crate::glyph::{Glyph, icon};
use crate::island::{self, Island};
use crate::{parse, theme};
/*
* the island's card while nothing plays: the song it last heard (kept in
* $XDG_STATE_HOME/amane across restarts, its cover copied beside it) with a button that
* starts its player and asks it to play, and chips that start the players of
* island.idle.players. art.rs's thread calls watch() with every check it makes, so this
* needs no thread of its own
*/
#[derive(Clone, Default, PartialEq)]
struct Song {
// the player's mpris bus name and the name it goes by
player: String,
identity: String,
title: String,
artist: String,
// the cover's copy, empty without one
cover: String,
}
pub struct Last {
song: Option<Song>,
// the players to start, as (desktop file name, the name it shows), and the setting they were read for
apps: Vec<(String, String)>,
apps_for: Option<String>,
// what a start is doing, or what went wrong
note: Option<String>,
}
impl Service for Last {
fn new() -> Self {
let saved = parse::settings(&fs::read_to_string(folder().join("last-played")).unwrap_or_default());
let get = |key: &str| saved.iter().find(|(name, _)| name == key).map_or(String::new(), |(_, value)| value.clone());
let song = Song { player: get("player"), identity: get("identity"), title: get("title"), artist: get("artist"), cover: get("cover") };
Self { song: Some(song).filter(|song| !song.title.is_empty()), apps: Vec::new(), apps_for: None, note: None }
}
// changes through watch() and the buttons
fn listen() {}
}
const PREFIX: &str = "org.mpris.MediaPlayer2.";
fn folder() -> PathBuf {
let state = env::var_os("XDG_STATE_HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(env::var_os("HOME").unwrap_or_default()).join(".local/state"));
state.join("amane")
}
// one line, for the key = value file
fn clean(words: &str) -> String {
words.replace(['\n', '\r'], " ").trim().to_string()
}
/*
* art.rs's check, with the cover it holds for the island's player: notes the song the
* island's player plays, and reads the players to start again when their setting changed
*/
pub fn watch(url: &str, path: &str) {
let wanted = idle::players();
if Last::read().apps_for.as_deref() != Some(wanted.as_str()) {
let apps = desktop_apps(&wanted);
let mut last = Last::write();
last.apps = apps;
last.apps_for = Some(wanted);
}
let selected = Island::read().selected.clone();
let now = {
let media = Media::read();
island::shown(&media, selected.as_deref()).filter(|player| player.playing() && !player.title().is_empty()).map(|player| {
let song = Song { player: player.name().to_string(), identity: clean(player.identity()), title: clean(player.title()), artist: clean(player.artist()), cover: String::new() };
(song, player.art_url() == url && !path.is_empty())
})
};
let Some((mut song, cover_ready)) = now else { return };
let known = Last::read().song.clone();
let same = known.as_ref().is_some_and(|known| known.player == song.player && known.title == song.title && known.artist == song.artist);
let has_cover = known.as_ref().is_some_and(|known| !known.cover.is_empty());
// the same song, and its cover kept already or not there yet
if same && (has_cover || !cover_ready) {
return;
}
song.cover = if cover_ready { keep_cover(path, &song) } else { String::new() };
if let Some(old) = known.filter(|old| !old.cover.is_empty() && old.cover != song.cover) {
let _ = fs::remove_file(&old.cover);
}
save(&song);
Last::write().song = Some(song);
}
// a copy of the cover under a name of its own (amane keeps a picture by its path, so a reused name would show the old one)
fn keep_cover(path: &str, song: &Song) -> String {
let mut hasher = DefaultHasher::new();
(&song.player, &song.title, &song.artist).hash(&mut hasher);
let file = folder().join(format!("cover-{:016x}", hasher.finish()));
let _ = fs::create_dir_all(folder());
if fs::copy(path, &file).is_ok() { file.to_string_lossy().into_owned() } else { String::new() }
}
fn save(song: &Song) {
let _ = fs::create_dir_all(folder());
let text = format!(
"# the song the island last heard, for its card while nothing plays\nplayer = {}\nidentity = {}\ntitle = {}\nartist = {}\ncover = {}\n",
song.player, song.identity, song.title, song.artist, song.cover
);
let _ = fs::write(folder().join("last-played"), text);
}
// the players of the setting that have a desktop file, with the name it gives them
fn desktop_apps(wanted: &str) -> Vec<(String, String)> {
let home = PathBuf::from(env::var_os("HOME").unwrap_or_default());
let folders = [home.join(".local/share/applications"), PathBuf::from("/usr/local/share/applications"), PathBuf::from("/usr/share/applications")];
wanted
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.filter_map(|id| {
let entry = folders.iter().find_map(|folder| fs::read_to_string(folder.join(format!("{id}.desktop"))).ok())?;
let name = parse::settings(&entry).into_iter().find(|(key, _)| key == "Name").map_or_else(|| id.to_string(), |(_, name)| name);
Some((id.to_string(), name))
})
.collect()
}
// a player's desktop file name from its bus name: org.mpris.MediaPlayer2.firefox.instance_1_2 is firefox
fn app_of(bus: &str) -> String {
bus.strip_prefix(PREFIX).unwrap_or(bus).split('.').next().unwrap_or_default().to_string()
}
fn say(note: Option<String>) {
Last::write().note = note;
}
/*
* starts a player (gtk-launch), and with `resume` (its bus name) asks it to play until it
* does or island.idle.wait_s runs out. the card closes: the pill shows the song once it plays
*/
fn start(id: String, resume: Option<String>) {
let name = Last::read().apps.iter().find(|(app, _)| *app == id).map_or_else(|| id.clone(), |(_, name)| name.clone());
island::collapse();
thread::spawn(move || {
say(Some(format!("Starting {name}…")));
let started = Command::new("gtk-launch").arg(&id).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|status| status.success());
if !started {
say(Some(format!("{name} couldn't be started (gtk-launch {id})")));
return;
}
let Some(bus) = resume else {
say(None);
return;
};
let until = Instant::now() + Duration::from_secs(idle::wait_s());
while Instant::now() < until {
// an instance number may follow the name it had before
let found = {
let media = Media::read();
media.players().iter().find(|player| player.name() == bus || player.name().starts_with(&format!("{bus}."))).map(|player| (player.name().to_string(), player.playing()))
};
match found {
Some((_, true)) => {
say(None);
return;
}
// Play is harmless when it already plays, so it's asked until it does
Some((player, false)) => {
Bus::session().call(&player, "/org/mpris/MediaPlayer2", "org.mpris.MediaPlayer2.Player", "Play", &[]);
}
None => {}
}
thread::sleep(Duration::from_millis(idle::check_ms()));
}
say(Some(format!("{name} opened but didn't start playing")));
});
}
pub fn height() -> f32 {
2.0 * card::pad() + disc::size() + card::gap() + controls::chip_height()
}
// a line of text in a box as wide as the side, cut with … when longer
fn line(words: impl Into<String>, size: f32, color: amane::Color, bold: bool, width: f32) -> Rectangle {
let words = Text::new(words).size(size).color(color).weight(if bold { Weight::Bold } else { Weight::Regular }).elide();
Rectangle::new().width(width).height(size + 8.0).align_child(Start, Center).child(words)
}
fn chip(words: &str, pressed: impl Fn() + 'static) -> Rectangle {
let width = words.chars().count() as f32 * text::small() * font::letter_width() + 18.0;
Rectangle::new()
.width(width)
.height(controls::chip_height())
.radius(Full)
.fill(overlays::chip())
.align_child(Center, Center)
.cursor(Pointer)
.on_click(move |_| pressed())
.child(Text::new(words).size(text::small()).color(theme::now().text))
}
pub fn card() -> Rectangle {
let last = Last::read();
let theme = theme::now();
let (width, pad, gap) = (card::width(), card::pad(), card::gap());
let inner_w = width - 2.0 * pad;
let side_w = inner_w - disc::size() - card::side_gap();
// the last song's cover on the cd, or the empty cd
let cover = last.song.as_ref().map_or("", |song| song.cover.as_str());
let cd = Rectangle::new().width(disc::size()).height(disc::size()).align_child(Center, Center).child(island::cover(cover, disc::cover(), 2.0, true));
let mut lines: Vec<Box<dyn Widget>> = Vec::new();
match &last.song {
Some(song) => {
let (app, bus) = (app_of(&song.player), song.player.clone());
let words = format!("Play in {}", song.identity);
let button_w = words.chars().count() as f32 * text::small() * font::letter_width() + 44.0;
lines.push(Box::new(line("Last played", text::small(), theme.dim, false, side_w)));
lines.push(Box::new(line(song.title.as_str(), text::title(), theme.text, true, side_w)));
lines.push(Box::new(line(song.artist.as_str(), text::body(), theme.dim, false, side_w)));
lines.push(Box::new(
Rectangle::new()
.width(button_w)
.height(controls::chip_height() + 10.0)
.radius(Full)
.fill(theme.accent)
.align_child(Center, Center)
.cursor(Pointer)
.on_click(move |_| start(app.clone(), Some(bus.clone())))
.child(Row::new(vec![Box::new(icon(Glyph::Play, 12.0, theme.on_accent)) as Box<dyn Widget>, Box::new(Text::new(words).size(text::small()).weight(Weight::Bold).color(theme.on_accent))]).gap(8.0).align(Center)),
));
}
None => {
lines.push(Box::new(line("Nothing playing", text::title(), theme.text, true, side_w)));
lines.push(Box::new(line("Start a player below", text::small(), theme.dim, false, side_w)));
}
}
if let Some(note) = &last.note {
lines.push(Box::new(line(note.as_str(), text::small(), theme.dim, false, side_w)));
}
let side = Rectangle::new().width(side_w).height(disc::size()).align_child(Start, Center).child(Column::new(lines).gap(gap / 2.0));
// the players to start
let mut chips: Vec<Box<dyn Widget>> = vec![Box::new(Text::new("Start").size(text::small()).color(theme.dim))];
for (id, name) in &last.apps {
let id = id.clone();
chips.push(Box::new(chip(name, move || start(id.clone(), None))));
}
if last.apps.is_empty() {
chips.push(Box::new(Text::new("no player of island.idle.players has a desktop file").size(text::small()).color(theme.dim)));
}
let bottom = Rectangle::new().width(inner_w).height(controls::chip_height()).align_child(Start, Center).child(Row::new(chips).gap(gap / 2.0).align(Center));
Rectangle::new().width(width).height(height()).padding(pad).child(Column::new(vec![Box::new(Row::new(vec![Box::new(cd) as Box<dyn Widget>, Box::new(side)]).gap(card::side_gap())) as Box<dyn Widget>, Box::new(bottom)]).gap(gap))
}
+1
View File
@@ -7,6 +7,7 @@ mod glyph;
mod home;
mod hypr;
mod island;
mod last;
mod map;
mod notices;
mod parse;
+2 -1
View File
@@ -334,7 +334,8 @@ fn side(monitor: &Monitor, left: bool) -> LayerWindow {
let window = if typing {
window.keyboard(Keyboard::OnDemand).on_key(|key| {
if key == Key::Escape {
// Esc closes the home card's event form first, then the card
if key == Key::Escape && !home::escape() {
close();
}
})
+90
View File
@@ -0,0 +1,90 @@
// the ring of candles around the cd, one candle per angle step, each a line from the
// cover's edge outward as far as its level reaches, with a glow under the loud ones and
// a flame at the tip of the loudest. island.rs fills the values:
// 0: centre, inner radius, candles, levels given
// 1: reach at full level, glow width, body width, flame width
// 2: glow colour, 3: body colour, 4: flame colour (plain, 0 to 1)
// 5-15: levels for half the ring (the other half mirrors it), already eased, 0 to 1
const TAU: f32 = 6.28318530718;
fn level(step: u32, half: u32) -> f32 {
let count = u32(values[0].w);
if count == 0u {
return 0.0;
}
let index = min(step * count / max(half, 1u), count - 1u);
return values[5u + index / 4u][index % 4u];
}
// distance from p to the line from `start` to `end` along `direction`, out from the centre
fn line(p: vec2<f32>, direction: vec2<f32>, start: f32, end: f32) -> f32 {
let along = dot(p, direction);
let across = dot(p, vec2<f32>(-direction.y, direction.x));
return length(vec2<f32>(across, along - clamp(along, start, end)));
}
// how much of a pixel a round-capped stroke `width` wide covers, at distance d from its line
fn stroke(d: f32, width: f32, pixel: f32) -> f32 {
return clamp((width / 2.0 - d) / pixel + 0.5, 0.0, 1.0);
}
fn over(top: vec4<f32>, under: vec4<f32>) -> vec4<f32> {
return top + under * (1.0 - top.a);
}
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let ring = values[0];
let widths = values[1];
let candles = u32(ring.z);
let half = candles / 2u;
let inner = ring.y;
let p = uv * size - vec2<f32>(ring.x);
let pixel = max(fwidth(p.x), 1e-4);
// the nearest candle by angle, 0 at the top going clockwise, and its neighbours
let angle = atan2(p.x, -p.y);
let nearest = i32(round(select(angle, angle + TAU, angle < 0.0) / TAU * f32(candles)));
var glow = 0.0;
var body = 0.0;
var flame = 0.0;
for (var offset = -1; offset <= 1; offset++) {
let index = u32((nearest + offset + i32(candles)) % i32(candles));
let step = select(candles - 1u - index, index, index < half);
let loud = level(step, half);
let a = f32(index) / f32(candles) * TAU;
let direction = vec2<f32>(sin(a), -cos(a));
let reach = inner + 3.0 + loud * widths.x;
let d = line(p, direction, inner, reach);
body = max(body, stroke(d, widths.z, pixel));
if loud > 0.25 {
glow = max(glow, stroke(d, widths.y, pixel));
}
if loud > 0.45 {
flame = max(flame, stroke(line(p, direction, reach - 0.5, reach), widths.w, pixel));
}
}
var color = vec4<f32>(values[2].rgb * values[2].a, values[2].a) * glow;
color = over(vec4<f32>(values[3].rgb * values[3].a, values[3].a) * body, color);
color = over(vec4<f32>(values[4].rgb * values[4].a, values[4].a) * flame, color);
if color.a <= 0.0 {
return vec4<f32>(0.0);
}
return vec4<f32>(color.rgb / color.a, color.a);
}
+51 -12
View File
@@ -37,17 +37,21 @@ pub struct Ui {
query: String,
message: Option<(String, bool)>,
hover: Option<String>,
// just opened, or a key went to the search: it takes the keys on the next frame
focus_search: bool,
}
impl Service for Ui {
fn new() -> Self {
Self { page: String::from("appearance"), query: String::new(), message: None, hover: None }
Self { page: String::from("appearance"), query: String::new(), message: None, hover: None, focus_search: false }
}
fn listen() {}
}
pub fn open() {
Ui::write().focus_search = true;
open_window(NAME, view);
hypr::load();
@@ -158,6 +162,16 @@ fn say(result: Result<(), String>, done: String) {
pub fn view() -> Window {
theme::begin();
/*
* only on this frame: a TextInput that's .focused() takes the keys every frame it's
* drawn, so always focused, the search would take them back from a value being typed
*/
let focus_search = Ui::read().focus_search;
if focus_search {
Ui::write().focus_search = false;
}
let colors = theme::now();
let ui = Ui::read();
let hypr = Hypr::read();
@@ -283,22 +297,43 @@ pub fn view() -> Window {
.title(TITLE)
.size(1100.0, 760.0)
.resizable(true)
// Escape closes it, also from a field (a typed value only applies on Enter)
.on_key(|key| {
if key == Key::Escape {
close_window(NAME);
}
/*
* Escape closes it, also from a field (a typed value only applies on Enter). keys no
* field takes, like right after opening, are typed into the search, which then takes
* the keys itself: amane drops a field's focus whenever one of the shell's windows
* loses the keyboard, as one does when this window opens
*/
.on_key(|key| match key {
Key::Escape => close_window(NAME),
Key::Character(letter) => search_key(Some(letter)),
Key::Space => search_key(Some(' ')),
Key::Backspace => search_key(None),
_ => {}
})
.child(
Rectangle::new()
.width(width)
.height(height)
.fill(colors.surface_low)
.child(Row::new(children![sidebar(&ui, &hypr, height, &colors), main])),
.child(Row::new(children![sidebar(&ui, &hypr, height, &colors, focus_search), main])),
)
}
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> Rectangle {
// a key typed with no field focused: into the search (none: Backspace), which takes the next keys
fn search_key(letter: Option<char>) {
let mut ui = Ui::write();
match letter {
Some(letter) => ui.query.push(letter),
None => {
ui.query.pop();
}
}
ui.focus_search = true;
}
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors, focus_search: bool) -> Rectangle {
let mut entries: Vec<Box<dyn Widget>> = vec![Box::new(caption("SHELL", colors))];
for module in config::modules() {
@@ -333,11 +368,15 @@ fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> Rectangle {
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Cursor::Text)
.child(TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| {
SYNCED.with_borrow_mut(|synced| synced.insert(SEARCH, text.clone()));
.child({
let input = TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| {
SYNCED.with_borrow_mut(|synced| synced.insert(SEARCH, text.clone()));
Ui::write().query = text;
}));
Ui::write().query = text;
});
if focus_search { input.focused() } else { input }
});
Rectangle::new()
.width(SIDEBAR_W)
+12 -4
View File
@@ -156,12 +156,19 @@ fn gpu_clock() -> (f32, String) {
(0.0, String::from("no clock to read"))
}
// every hwmon device's hottest sensor, named for what it is, hottest first
// what the temperatures are shown in, always: a sensor keeps its place whatever its reading
const ORDER: [&str; 8] = ["CPU", "iGPU", "eGPU", "NVMe", "Memory", "Board", "ThinkPad", "Wi-Fi"];
// every hwmon device's hottest sensor, named for what it is, in ORDER (then by name, then by device)
fn temperatures() -> Vec<(String, Glyph, f32)> {
let mut found: Vec<(String, Glyph, f32)> = Vec::new();
for entry in fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten() {
let device = entry.path();
// the devices in their numbers' order (hwmon2 before hwmon10), so two of a kind keep theirs too
let mut devices: Vec<PathBuf> = fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten().map(|entry| entry.path()).collect();
devices.sort_by_key(|path| path.file_name().and_then(|name| name.to_str()).and_then(|name| name.trim_start_matches("hwmon").parse::<u32>().ok()).unwrap_or(u32::MAX));
for device in devices {
let kind = read(device.join("name")).trim().to_string();
let hottest = fs::read_dir(&device)
@@ -191,7 +198,8 @@ fn temperatures() -> Vec<(String, Glyph, f32)> {
found.push((name.to_string(), glyph, celsius));
}
found.sort_by(|a, b| b.2.total_cmp(&a.2));
// a stable sort: same kinds keep the devices' order
found.sort_by_key(|(name, ..)| (ORDER.iter().position(|kind| kind == name).unwrap_or(ORDER.len()), name.clone()));
found.truncate(settings::temperatures());
// a kind shown more than once is numbered, in the order shown
+128 -28
View File
@@ -1,7 +1,10 @@
use amane::{Blend, Color, Gradient, Rectangle, Shadow, Stack, children};
use std::path::PathBuf;
use std::sync::LazyLock;
use amane::{Blend, Color, Rectangle, Stack, children};
use crate::config::appearance::{frosted, liquid, solid, surface};
use crate::island::{backdrop, faded};
use crate::island::faded;
use crate::theme;
/*
@@ -10,48 +13,74 @@ use crate::theme;
* `content` (already width x height, with its own padding and alignment) in the surface.
* `lift` (0 to 1) is how raised it is: hovered, or a popup over the rest.
* see-through designs need Hyprland's blur behind the shell (README: Glass)
*
* every design is a shadow, a gradient fill and maybe a second gradient just inside the
* rim, which surface.wgsl draws in one pass. drawn as amane's shadow and gradients they
* took two vello passes over the whole window on every frame (vello keeps no gradient
* between frames), so a playing pill, whose bars redraw it 60 times a second, cost the
* igpu about 3 ms a frame; the shader costs a few hundred thousand pixels of arithmetic.
* the rim stays a border, which amane draws as a quad
*/
pub fn shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
let theme = theme::now();
let base = Rectangle::new().width(width).height(height).radius(radius);
match surface::design().as_str() {
let look = match surface::design().as_str() {
// after Apple's materials: a thin tint, a hairline light rim (the accent when hovered),
// a soft highlight along the top as if lit from above, one soft low shadow instead of a glow
"frosted" => {
let sheen = inner(width, height, radius).fill(Gradient::linear(180.0, [(0.0, white(frosted::sheen())), (0.3, white(frosted::sheen() * 0.26)), (0.65, white(0.0))]));
let a = alpha(frosted::opacity());
let sheen = frosted::sheen();
base.fill(backdrop(colors, alpha(frosted::opacity())))
.border(1.0, Color::blend(white(frosted::rim()), faded(theme.accent, 150), lift))
.shadow(Shadow::drop(Color::BLACK).opacity(frosted::shadow() + 0.1 * lift).blur(24.0).offset(0.0, 6.0))
.child(Stack::new(children![sheen, content]).width(width).height(height))
Look {
shadow: (Color::BLACK, frosted::shadow() + 0.1 * lift, 24.0, (0.0, 6.0)),
fill: (150.0, vec![(0.0, faded(colors[0], a)), (0.55, faded(colors[1], a)), (1.0, faded(colors[2], a))]),
inner: (180.0, vec![(0.0, white(sheen)), (0.3, white(sheen * 0.26)), (0.65, white(0.0))]),
rim: (1.0, Color::blend(white(frosted::rim()), faded(theme.accent, 150), lift)),
}
}
// clear glass, denser toward the bottom right where the equalizer's labels sit; a bright
// edge where light enters at the top left and leaves at the bottom right; the accent's glow
"liquid" => {
let glass = liquid::opacity();
let fill = Gradient::linear(150.0, [(0.0, faded(colors[0], alpha(glass * 0.55))), (0.55, faded(colors[1], alpha(glass))), (1.0, faded(colors[2], alpha(glass * 1.6)))]);
let light = liquid::light();
let edge = inner(width, height, radius).fill(Gradient::linear(
135.0,
[(0.0, white(light)), (0.16, white(light * 0.23)), (0.4, white(0.0)), (0.82, white(0.0)), (1.0, white(light * 0.43))],
));
base.fill(fill)
.border(liquid::rim_width(), Color::blend(white(liquid::rim()), faded(theme.flame, 200), lift))
.shadow(Shadow::drop(theme.accent).opacity(liquid::glow() + 0.3 * lift).blur(24.0))
.child(Stack::new(children![edge, content]).width(width).height(height))
Look {
shadow: (theme.accent, liquid::glow() + 0.3 * lift, 24.0, (0.0, 0.0)),
fill: (150.0, vec![(0.0, faded(colors[0], alpha(glass * 0.55))), (0.55, faded(colors[1], alpha(glass))), (1.0, faded(colors[2], alpha(glass * 1.6)))]),
inner: (135.0, vec![(0.0, white(light)), (0.16, white(light * 0.23)), (0.4, white(0.0)), (0.82, white(0.0)), (1.0, white(light * 0.43))]),
rim: (liquid::rim_width(), Color::blend(white(liquid::rim()), faded(theme.flame, 200), lift)),
}
}
// the original: the wallpaper's gradient, a 1 px rim, a soft glow in the accent
_ => base
.fill(backdrop(colors, alpha(solid::opacity())))
.border(1.0, Color::blend(theme.outline, faded(theme.accent, 110), lift))
.shadow(Shadow::drop(theme.accent).opacity(solid::glow() + (solid::hover_glow() - solid::glow()) * lift).blur(22.0))
.child(content),
}
_ => {
let a = alpha(solid::opacity());
Look {
shadow: (theme.accent, solid::glow() + (solid::hover_glow() - solid::glow()) * lift, 22.0, (0.0, 0.0)),
fill: (150.0, vec![(0.0, faded(colors[0], a)), (0.55, faded(colors[1], a)), (1.0, faded(colors[2], a))]),
inner: (0.0, Vec::new()),
rim: (1.0, Color::blend(theme.outline, faded(theme.accent, 110), lift)),
}
}
};
// room for the shadow: vello draws a blurred rectangle 2.5 deviations (half the blur each) past its edge
let (_, _, blur, (dx, dy)) = look.shadow;
let margin = (1.25 * blur + dx.abs().max(dy.abs())).ceil();
let paint = Rectangle::new()
.width(width + 2.0 * margin)
.height(height + 2.0 * margin)
.translate(-margin, -margin)
.shader(SHADER.as_path())
.shader_values(look.values(width, height, radius, margin));
let (thickness, rim_color) = look.rim;
let rim = Rectangle::new().width(width).height(height).radius(radius).border(thickness, rim_color);
Rectangle::new().width(width).height(height).child(Stack::new(children![paint, rim, content]).width(width).height(height))
}
// whether the desktop shows through: then a veil can't hide a face, so faces fade with .opacity()
@@ -59,9 +88,80 @@ pub fn clear() -> bool {
surface::design() != "solid" || solid::opacity() < 1.0
}
// a layer just inside the rim, so the rim stays crisp over it
fn inner(width: f32, height: f32, radius: f32) -> Rectangle {
Rectangle::new().width(width - 2.0).height(height - 2.0).radius((radius - 1.0).max(0.0)).translate(1.0, 1.0)
static SHADER: LazyLock<PathBuf> = LazyLock::new(|| shader_file("surface.wgsl", include_str!("surface.wgsl")));
// a shader's source is part of the shell; amane wants a file, so it goes beside cava's config
pub fn shader_file(name: &str, source: &str) -> PathBuf {
let path = crate::art::folder().join(name);
let _ = std::fs::write(&path, source);
path
}
// numbers as a shader's rows of four, the last one padded with 0
pub fn rows(numbers: &[f32]) -> Vec<[f32; 4]> {
numbers.chunks(4).map(|chunk| std::array::from_fn(|index| chunk.get(index).copied().unwrap_or(0.0))).collect()
}
type Stops = Vec<(f32, Color)>;
struct Look {
// colour, strength, blur, offset
shadow: (Color, f32, f32, (f32, f32)),
// angle and stops: up to 3 for the fill, 5 for the gradient inside the rim (none: no stops)
fill: (f32, Stops),
inner: (f32, Stops),
// width and colour
rim: (f32, Color),
}
impl Look {
// the rows surface.wgsl reads, see its top
fn values(&self, width: f32, height: f32, radius: f32, margin: f32) -> Vec<[f32; 4]> {
let (color, strength, blur, (dx, dy)) = self.shadow;
let mut shadow = plain(color);
shadow[3] *= strength.clamp(0.0, 1.0);
let mut rows = vec![
[width, height, radius, margin],
shadow,
[blur / 2.0, dx, dy, 1.0],
[self.fill.0, self.inner.0, self.fill.1.len().min(3) as f32, self.inner.1.len().min(5) as f32],
];
let (colors, offsets) = stops::<3>(&self.fill.1);
rows.extend(colors);
rows.push([offsets[0], offsets[1], offsets[2], 0.0]);
let (colors, offsets) = stops::<5>(&self.inner.1);
rows.extend(colors);
rows.push([offsets[0], offsets[1], offsets[2], offsets[3]]);
rows.push([offsets[4], 0.0, 0.0, 0.0]);
rows
}
}
// N (at most 5) stops as colours and their offsets, missing ones clear
fn stops<const N: usize>(stops: &[(f32, Color)]) -> ([[f32; 4]; N], [f32; 5]) {
let mut colors = [[0.0; 4]; N];
let mut offsets = [0.0; 5];
for (index, (offset, color)) in stops.iter().take(N).enumerate() {
colors[index] = plain(*color);
offsets[index] = *offset;
}
(colors, offsets)
}
// a colour as a shader reads it: plain, not premultiplied, 0 to 1
pub fn plain(color: Color) -> [f32; 4] {
[color.red(), color.green(), color.blue(), color.alpha()].map(|part| f32::from(part) / 255.0)
}
// an opacity, 0 to 1, as a colour's alpha
+148
View File
@@ -0,0 +1,148 @@
// a surface in one pass: its drop shadow, its gradient fill and a second gradient just
// inside the rim (frosted's sheen, liquid's lit edge), shaped by the rounded outline
// worked out here rather than by a path, so amane lays it straight onto the window
// without a layer of its own. surface.rs fills the values:
// 0: shape width, height, corner radius, margin (room around the shape for the shadow)
// 1: shadow colour, its alpha already scaled by the shadow's strength
// 2: shadow deviation, its x and y offset, the inset of the inner gradient
// 3: fill angle, inner angle, fill stops (up to 3), inner stops (0 to 5)
// 4-6: fill colours, 7: their offsets
// 8-12: inner colours, 13: offsets of the first four, 14.x: the fifth's
// colours are plain (not premultiplied), 0 to 1. the shadow follows vello's own
// blurred rounded rectangle, the gradients amane's linear ones, so it looks the same
// vello's error function approximation (raphlinus.github.io, blurred rounded rects)
fn erf7(x: f32) -> f32 {
let y = clamp(x * 1.1283791671, -100.0, 100.0);
let yy = y * y;
let z = y + (0.24295 + (0.03395 + 0.0104 * yy) * yy) * (y * yy);
return z / sqrt(1.0 + z * z);
}
// vello's blurred rounded rectangle at p, from its centre
fn shadow(p: vec2<f32>, w: f32, h: f32, radius: f32, deviation: f32) -> f32 {
let s = max(deviation, 1e-5);
let inv = 1.0 / s;
let min_edge = min(w, h);
let radius_max = 0.5 * min_edge;
let r0 = min(length(vec2<f32>(radius, s * 1.15)), radius_max);
let r1 = min(length(vec2<f32>(radius, s * 2.0)), radius_max);
let exponent = 2.0 * r1 / r0;
let delta = 1.25 * s * (exp(-pow(0.5 * inv * w, 2.0)) - exp(-pow(0.5 * inv * h, 2.0)));
let width = w + min(delta, 0.0);
let height = h - max(delta, 0.0);
let scale = 0.5 * erf7(inv * 0.5 * (max(width, height) - 0.5 * radius));
let y0 = abs(p.y) - (height * 0.5 - r1);
let x0 = abs(p.x) - (width * 0.5 - r1);
let d_pos = pow(pow(max(x0, 0.0), exponent) + pow(max(y0, 0.0), exponent), 1.0 / exponent);
let d = d_pos + min(max(x0, y0), 0.0) - r1;
return scale * (erf7(inv * (min_edge + d)) - erf7(inv * d));
}
// signed distance to a rounded rectangle centred on 0, negative inside
fn rounded(p: vec2<f32>, half: vec2<f32>, radius: f32) -> f32 {
let r = min(radius, min(half.x, half.y));
let q = abs(p) - half + vec2<f32>(r);
return length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - r;
}
// how much of a pixel `pixel` wide the shape covers at distance d
fn cover(d: f32, pixel: f32) -> f32 {
return clamp(0.5 - d / pixel, 0.0, 1.0);
}
// where p falls along amane's linear gradient over a box at `origin`, 0 to 1:
// a line through the centre, 0 degrees pointing up, long enough to reach the corners
fn along(p: vec2<f32>, origin: vec2<f32>, extent: vec2<f32>, angle: f32) -> f32 {
let a = radians(angle);
let direction = vec2<f32>(sin(a), -cos(a));
let half_length = (extent.x * abs(direction.x) + extent.y * abs(direction.y)) / 2.0;
return clamp(0.5 + dot(p - origin - extent / 2.0, direction) / max(2.0 * half_length, 1e-5), 0.0, 1.0);
}
fn premultiplied(c: vec4<f32>) -> vec4<f32> {
return vec4<f32>(c.rgb * c.a, c.a);
}
// the fill's colour at t, premultiplied; stops past the last repeat it
fn fill_color(t: f32) -> vec4<f32> {
let count = u32(values[3].z);
var color = premultiplied(values[4]);
for (var i = 1u; i < count; i++) {
let lo = values[7][i - 1u];
let hi = values[7][i];
let k = clamp((t - lo) / max(hi - lo, 1e-5), 0.0, 1.0);
color = select(color, mix(premultiplied(values[3u + i]), premultiplied(values[4u + i]), k), t > lo);
}
return color;
}
fn inner_offset(i: u32) -> f32 {
return select(values[13][min(i, 3u)], values[14].x, i == 4u);
}
fn inner_color(t: f32) -> vec4<f32> {
let count = u32(values[3].w);
var color = premultiplied(values[8]);
for (var i = 1u; i < count; i++) {
let lo = inner_offset(i - 1u);
let hi = inner_offset(i);
let k = clamp((t - lo) / max(hi - lo, 1e-5), 0.0, 1.0);
color = select(color, mix(premultiplied(values[7u + i]), premultiplied(values[8u + i]), k), t > lo);
}
return color;
}
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let shape = values[0];
let margin = shape.w;
let extent = shape.xy;
// this pixel, from the shape's top left corner
let p = uv * size - vec2<f32>(margin);
let pixel = max(fwidth(p.x), 1e-4);
let centre = extent / 2.0;
// the shadow, under everything
let offset = values[2].yz;
let shade = shadow(p - centre - offset, extent.x, extent.y, shape.z, values[2].x);
var color = premultiplied(vec4<f32>(values[1].rgb, values[1].a * shade));
// the fill over it
let inside = cover(rounded(p - centre, centre, shape.z), pixel);
let fill = fill_color(along(p, vec2<f32>(0.0), extent, values[3].x)) * inside;
color = fill + color * (1.0 - fill.a);
// the inner gradient, inset from the rim by values[2].w
if values[3].w > 0.0 {
let inset = values[2].w;
let inner = extent - vec2<f32>(2.0 * inset);
let within = cover(rounded(p - centre, inner / 2.0, max(shape.z - inset, 0.0)), pixel);
let light = inner_color(along(p, vec2<f32>(inset), inner, values[3].y)) * within;
color = light + color * (1.0 - light.a);
}
// amane blends a plain colour over the window
if color.a <= 0.0 {
return vec4<f32>(0.0);
}
return vec4<f32>(color.rgb / color.a, color.a);
}
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# perf.sh PID [SECONDS]: a process's cpu %, the cpu % of the commands it ran and waited for
# (children), its gpu busy % per engine (drm fdinfo, xe and i915) and its resident memory
# over SECONDS (default 10). cpu 100 % is one core
pid=${1:?pid}; secs=${2:-10}
sample() {
# utime + stime in clock ticks, then engine cycles summed over every drm fd as "engine used total"
awk '{print "cpu", $14 + $15, $16 + $17}' /proc/$pid/stat
cat /proc/$pid/fdinfo/* 2>/dev/null | awk '
/^drm-cycles-/ {e=substr($1, 12); sub(":", "", e); used[e]+=$2}
/^drm-total-cycles-/ {e=substr($1, 18); sub(":", "", e); total[e]=$2}
/^drm-engine-[a-z]+:/ && $3 == "ns" {e=substr($1, 12); sub(":", "", e); ns[e]+=$2}
END {for (e in used) print "cyc", e, used[e], total[e]; for (e in ns) print "ns", e, ns[e]}'
}
a=$(sample); start=$(date +%s%N); sleep "$secs"; b=$(sample); end=$(date +%s%N)
printf '%s\n--\n%s\n' "$a" "$b" | awk -v tck="$(getconf CLK_TCK)" -v wall="$(( (end - start) / 1000 ))" '
$1 == "--" {second=1; next}
$1 == "cpu" {if (second) {cpu1=$2; kid1=$3} else {cpu0=$2; kid0=$3}}
$1 == "cyc" {if (second) {u1[$2]=$3; t1[$2]=$4} else {u0[$2]=$3; t0[$2]=$4}}
$1 == "ns" {if (second) n1[$2]=$3; else n0[$2]=$3}
END {
printf "cpu %.1f %% children %.1f %%", (cpu1 - cpu0) / tck * 1e6 / wall * 100, (kid1 - kid0) / tck * 1e6 / wall * 100
for (e in u1) if (t1[e] > t0[e]) printf " %s %.2f %%", e, (u1[e] - u0[e]) / (t1[e] - t0[e]) * 100
for (e in n1) printf " %s %.2f %%", e, (n1[e] - n0[e]) / (wall * 1000) * 100
}'
printf ' rss %s MiB\n' "$(awk '/^VmRSS/ {printf "%.0f", $2 / 1024}' /proc/$pid/status)"