Compare commits
6 Commits
master
...
side-pills
| Author | SHA1 | Date | |
|---|---|---|---|
| 9407ee3506 | |||
| 84b01f7c56 | |||
| 9b4334835a | |||
| 49a0bae039 | |||
| bccdb86ebb | |||
| ed0eb532db |
@@ -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.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
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.
|
- **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`.
|
- **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.
|
- **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.
|
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
|
### 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).
|
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/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/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/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/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/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/settings.rs the config window, `config` IPC
|
||||||
src/parse.rs pure text parsing with unit tests, no amane dependency
|
src/parse.rs pure text parsing with unit tests, no amane dependency
|
||||||
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme
|
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme
|
||||||
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq)
|
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
|
## 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/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.
|
- `~/.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>`.
|
- `~/.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`).
|
- 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
|
## Known limits
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ use crate::parse::{self, Chunk};
|
|||||||
use crate::{pods, theme};
|
use crate::{pods, theme};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* the AI card: a chat with a local model through ollama's http api (curl, streamed line
|
* the AI card: a chat with a model on your own ollama servers (pills.ai.servers) through
|
||||||
* by line), never a cloud service. ollama isn't kept running: the first question starts
|
* their http api (curl, streamed line by line), never a cloud service. a question goes to
|
||||||
* its openrc service when it doesn't answer (doas, the same fixed rc-service rule the
|
* the first server that has the chosen model. a local ollama isn't kept running: it's
|
||||||
* control centre uses)
|
* 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)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
pub enum Who {
|
pub enum Who {
|
||||||
@@ -36,8 +37,8 @@ pub struct Ai {
|
|||||||
typed: String,
|
typed: String,
|
||||||
// bumped by Stop and New chat, so an answer still streaming stops writing
|
// bumped by Stop and New chat, so an answer still streaming stops writing
|
||||||
round: u32,
|
round: u32,
|
||||||
// the models ollama has, while the model list is open (empty while it loads)
|
// (server, model) for every model the servers have, while the model list is open (empty while it loads)
|
||||||
models: Option<Vec<String>>,
|
models: Option<Vec<(String, String)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Service for Ai {
|
impl Service for Ai {
|
||||||
@@ -114,11 +115,15 @@ fn new_chat() {
|
|||||||
ai.back = 0.0;
|
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> {
|
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();
|
let body = request();
|
||||||
|
|
||||||
@@ -170,18 +175,97 @@ fn answer(round: u32) -> Result<(), String> {
|
|||||||
Ok(())
|
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> {
|
fn wake(server: &str, say: impl Fn(&str)) -> Result<(), String> {
|
||||||
if answers(server) {
|
if answers(server) {
|
||||||
return Ok(());
|
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…");
|
say("Starting ollama…");
|
||||||
|
|
||||||
let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output();
|
let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output();
|
||||||
@@ -213,24 +297,7 @@ fn pick() {
|
|||||||
drop(ai);
|
drop(ai);
|
||||||
|
|
||||||
thread::spawn(|| {
|
thread::spawn(|| {
|
||||||
let server = settings::server().trim_end_matches('/').to_string();
|
let names = models(None, |words| Ai::write().note = (!words.is_empty()).then(|| words.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 mut ai = Ai::write();
|
let mut ai = Ai::write();
|
||||||
|
|
||||||
@@ -321,7 +388,9 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
|||||||
let theme = theme::now();
|
let theme = theme::now();
|
||||||
|
|
||||||
// the model, as a chip that opens the list of the others
|
// 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 {
|
if ai.busy {
|
||||||
buttons.push(Box::new(pods::chip("Stop", None, stop)));
|
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 chat: Box<dyn Widget> = if let Some(models) = &ai.models {
|
||||||
let current = settings::model();
|
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 chosen = *name == current;
|
||||||
let picked = name.clone();
|
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(
|
rows.push(Box::new(
|
||||||
Rectangle::new()
|
Rectangle::new()
|
||||||
.width(width)
|
.width(width)
|
||||||
@@ -380,13 +454,20 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
|||||||
.align_child(Start, Center)
|
.align_child(Start, Center)
|
||||||
.cursor(amane::Pointer)
|
.cursor(amane::Pointer)
|
||||||
.on_click(move |_| choose(picked.clone()))
|
.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)))
|
Box::new(Rectangle::new().width(width).height(chat_h).clip().child(Column::new(rows).gap(6.0)))
|
||||||
} else if bubbles.is_empty() {
|
} 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 {
|
} else {
|
||||||
// the column's bottom on the area's bottom, moved down by the scroll back; what's above is cut
|
// 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));
|
let back = ai.back.min((total - chat_h).max(0.0));
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ impl Service for Art {
|
|||||||
*Self::write() = Self { url, path };
|
*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
|
// 4 checks a second: after next/previous the new cover follows the title quickly
|
||||||
thread::sleep(Duration::from_millis(timing::cover_check_ms()));
|
thread::sleep(Duration::from_millis(timing::cover_check_ms()));
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -329,6 +329,11 @@ settings! {
|
|||||||
row_letters: usize = 30, 4..=200; "Letters of a device name a menu row shows before cutting the middle"
|
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"
|
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 {
|
sound {
|
||||||
@@ -508,7 +513,7 @@ settings! {
|
|||||||
ai {
|
ai {
|
||||||
width: f32 = 440.0, 280..=1200; "Width of the AI card"
|
width: f32 = 440.0, 280..=1200; "Width of the AI card"
|
||||||
height: f32 = 520.0, 200..=1200; "Height 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)"
|
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"
|
||||||
@@ -518,7 +523,7 @@ settings! {
|
|||||||
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 = 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"
|
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"
|
||||||
cool_color: Color = Color::rgb(120, 200, 255); "Colour of a sensor below the warm temperature"
|
cool_color: Color = Color::rgb(120, 200, 255); "Colour of a sensor below the warm temperature"
|
||||||
@@ -532,9 +537,12 @@ settings! {
|
|||||||
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 = 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_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"
|
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: 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"
|
gap: f32 = 12.0, 0..=60; "Space between the home card's parts"
|
||||||
city: String = String::from("Munich"); "The weather's place, as shown"
|
city: String = String::from("Munich"); "The weather's place, as shown"
|
||||||
latitude: f32 = 48.137, -90.0..=90.0; "The weather's latitude"
|
latitude: f32 = 48.137, -90.0..=90.0; "The weather's latitude"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub enum Glyph {
|
|||||||
Muted,
|
Muted,
|
||||||
Bluetooth,
|
Bluetooth,
|
||||||
Chevron,
|
Chevron,
|
||||||
|
ChevronUp,
|
||||||
// pointing left, for a menu's way back
|
// pointing left, for a menu's way back
|
||||||
Back,
|
Back,
|
||||||
Note,
|
Note,
|
||||||
@@ -138,6 +139,7 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
|
|||||||
.cap(Cap::Round)
|
.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::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)],
|
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
|
// an eighth note: round head, stem, a curved flag
|
||||||
Glyph::Note => shapes![
|
Glyph::Note => shapes![
|
||||||
|
|||||||
+36
-11
@@ -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
|
// 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 {
|
pub fn typing() -> bool {
|
||||||
Agenda::read().form.is_some()
|
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
|
// 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> {
|
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 gap = settings::gap();
|
||||||
|
|
||||||
let seconds_today = clock.now.rem_euclid(86_400);
|
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 (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 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 weather_w = (width - clock_w - gap).max(0.0);
|
||||||
let height = middle_h();
|
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);
|
let weather = weather(forecast, weather_w, today, (clock_w + gap, y + (height - weather_h()) / 2.0), shapes);
|
||||||
|
|
||||||
Box::new(
|
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
|
// 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 {
|
fn weather(forecast: &Forecast, width: f32, today: i64, origin: (f32, f32), shapes: &mut Vec<Box<dyn Shape>>) -> Rectangle {
|
||||||
let theme = theme::now();
|
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 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() {
|
if form.editing.is_some() {
|
||||||
let words = match (form.confirm, series) {
|
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 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![
|
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(Rectangle::new().width(width).height(pods::header_height()).child(Row::new(top).width(width).justify(SpaceBetween).align(Center))),
|
||||||
Box::new(field(title, width)),
|
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)));
|
parts.push(Box::new(wrapped(problem.clone(), overlays::low_battery(), width)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let buttons: Vec<Box<dyn Widget>> = vec![
|
parts.push(Box::new(choice(if form.saving { "Saving…" } else { "Save" }, true, None, save)));
|
||||||
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)));
|
|
||||||
|
|
||||||
Box::new(Rectangle::new().width(width).height(calendar_h()).child(Column::new(parts).gap(gap)))
|
Box::new(Rectangle::new().width(width).height(calendar_h()).child(Column::new(parts).gap(gap)))
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-119
@@ -1,13 +1,13 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::f32::consts::{PI, TAU};
|
use std::path::PathBuf;
|
||||||
|
use std::sync::LazyLock;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use amane::{
|
use amane::{
|
||||||
Animation, Argument, Blend, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient, Image,
|
Animation, Argument, Blend, Bus, Center, Color, Column, Easing, End, Full, Gradient, Image, InputArea, Layer,
|
||||||
InputArea, Layer, LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path, Pointer,
|
LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Pointer, Rectangle, Row, Scroll, ScrollArea,
|
||||||
Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Start, Text, Vertical,
|
Service, Shadow, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children,
|
||||||
Weight, Widget, Workspaces, Zone, children, shapes,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::art::Art;
|
use crate::art::Art;
|
||||||
@@ -245,23 +245,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
let spectrum = live.then(Spectrum::read);
|
let spectrum = live.then(Spectrum::read);
|
||||||
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
|
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, which opens into the last song's card (last.rs)
|
||||||
/*
|
let player = shown(&media, island.selected.as_deref());
|
||||||
* 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())),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
let (grow, pill, reveal) = phases(open);
|
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 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);
|
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 wallpaper = theme::now().surfaces();
|
||||||
|
|
||||||
let colors = if cover_colors {
|
let colors = if cover_colors && player.is_some() {
|
||||||
let cover = backdrop_colors(&Palette::read());
|
let cover = backdrop_colors(&Palette::read());
|
||||||
|
|
||||||
std::array::from_fn(|stop| Color::blend(wallpaper[stop], cover[stop], reveal))
|
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
|
// at most one face at a time, each faded by a veil instead of a gpu layer
|
||||||
let face = if reveal > 0.0 {
|
let face = match player {
|
||||||
let scene = Scene {
|
Some(player) if reveal > 0.0 => {
|
||||||
player,
|
let scene = Scene {
|
||||||
players: media.players(),
|
player,
|
||||||
island: &island,
|
players: media.players(),
|
||||||
sound: &sound,
|
island: &island,
|
||||||
art: &art.path,
|
sound: &sound,
|
||||||
levels,
|
art: &art.path,
|
||||||
};
|
levels,
|
||||||
|
};
|
||||||
|
|
||||||
veiled(card(&scene), width, height, radius, reveal, &colors)
|
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)
|
Some(player) if pill > 0.0 => veiled(compact(player, playing(player, &island), &art.path, levels), width, height, radius, pill, &colors),
|
||||||
} else {
|
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
|
// 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
|
// 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)
|
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
|
// the pill while no player is open: a music note and "Nothing playing"; the words only
|
||||||
fn idle(colors: &[Color; 3]) -> Rectangle {
|
// where they fit with half the pill's height to spare at each end, else the note alone, so it never nears the rim
|
||||||
let content = Rectangle::new()
|
fn idle() -> Rectangle {
|
||||||
.width(c::pill::width())
|
let (width, h, note) = (c::pill::width(), c::pill::height(), 15.0);
|
||||||
.height(c::pill::height())
|
let words = Text::new("Nothing playing").size(text::body()).color(theme::now().dim);
|
||||||
.align_child(Center, Center)
|
let fits = matches!(words.width(), amane::Size::Fixed(words_w) if note + 8.0 + words_w + h <= width);
|
||||||
.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));
|
|
||||||
|
|
||||||
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
|
// 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
|
(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
|
* the candles, drawn by ring.wgsl: as stroked paths they cost a vello pass over the whole
|
||||||
* shape each: 120 shapes cost this iGPU a third of its frame rate, 3 cost nothing
|
* 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 {
|
fn ring(levels: &[f32]) -> Rectangle {
|
||||||
let center = c::disc::size() / 2.0;
|
let candles = c::disc::candles();
|
||||||
let inner = c::disc::cover() / 2.0 + 7.0;
|
let half = (candles / 2).max(1);
|
||||||
|
let given = half.min(44);
|
||||||
|
|
||||||
let mut glow = Path::new();
|
let theme = theme::now();
|
||||||
let mut body = Path::new();
|
let size = c::disc::size();
|
||||||
let mut flames = Path::new();
|
|
||||||
|
|
||||||
for index in 0..c::disc::candles() {
|
let eased: Vec<f32> = (0..given).map(|slot| mirrored(levels, slot * half / given).powf(0.75)).collect();
|
||||||
// a gentle curve lifts the quiet bands, so the ring isn't all bass
|
|
||||||
let level = mirrored(levels, index).powf(0.75);
|
|
||||||
|
|
||||||
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);
|
Rectangle::new().width(size).height(size).shader(RING.as_path()).shader_values(values)
|
||||||
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),
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// low frequencies at the top, rising down both sides, so the ring is symmetric
|
// 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
|
// 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())
|
let given = levels.len().min(36);
|
||||||
.map(|step| {
|
let sampled: Vec<f32> = (0..given).map(|slot| levels[if given > 1 { slot * (levels.len() - 1) / (given - 1) } else { 0 }]).collect();
|
||||||
let along = step as f32 / c::equalizer::curve_steps() as f32;
|
|
||||||
|
|
||||||
// cosine blend between neighbouring knobs keeps the curve round
|
let knobs: Vec<f32> = gains[..bands].iter().map(|&gain| knob_y(gain)).collect();
|
||||||
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 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)
|
Rectangle::new().width(inner_w()).height(c::equalizer::slider_height()).shader(CURVE.as_path()).shader_values(values)
|
||||||
})
|
|
||||||
.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)])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn presets(scene: &Scene) -> Column {
|
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
|
// 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()
|
let frame = Rectangle::new()
|
||||||
.width(size)
|
.width(size)
|
||||||
.height(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
|
* 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
|
* 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());
|
let (bars, width, height) = (c::pill::bars(), c::pill::bars_width(), c::pill::bars_height());
|
||||||
|
|
||||||
if !playing {
|
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
|
// 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 middle = height / 2.0;
|
||||||
|
|
||||||
let mut glow = 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 mut body = Path::new();
|
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 {
|
for index in 0..bars {
|
||||||
let level = if levels.is_empty() {
|
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 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;
|
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 {
|
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![
|
// every glow under every bar, as the two strokes were
|
||||||
glow.stroke(7.0, faded(theme::now().accent, 55)).cap(Cap::Round),
|
glow.extend(body);
|
||||||
body.stroke(3.2, theme::now().accent).cap(Cap::Round),
|
|
||||||
])
|
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
|
// the loudest level in one of `groups` equal slices of the spectrum, low to high
|
||||||
|
|||||||
+314
@@ -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))
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ mod glyph;
|
|||||||
mod home;
|
mod home;
|
||||||
mod hypr;
|
mod hypr;
|
||||||
mod island;
|
mod island;
|
||||||
|
mod last;
|
||||||
mod map;
|
mod map;
|
||||||
mod notices;
|
mod notices;
|
||||||
mod parse;
|
mod parse;
|
||||||
|
|||||||
+2
-1
@@ -334,7 +334,8 @@ fn side(monitor: &Monitor, left: bool) -> LayerWindow {
|
|||||||
|
|
||||||
let window = if typing {
|
let window = if typing {
|
||||||
window.keyboard(Keyboard::OnDemand).on_key(|key| {
|
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();
|
close();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -37,17 +37,21 @@ pub struct Ui {
|
|||||||
query: String,
|
query: String,
|
||||||
message: Option<(String, bool)>,
|
message: Option<(String, bool)>,
|
||||||
hover: Option<String>,
|
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 {
|
impl Service for Ui {
|
||||||
fn new() -> Self {
|
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() {}
|
fn listen() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open() {
|
pub fn open() {
|
||||||
|
Ui::write().focus_search = true;
|
||||||
|
|
||||||
open_window(NAME, view);
|
open_window(NAME, view);
|
||||||
|
|
||||||
hypr::load();
|
hypr::load();
|
||||||
@@ -158,6 +162,16 @@ fn say(result: Result<(), String>, done: String) {
|
|||||||
pub fn view() -> Window {
|
pub fn view() -> Window {
|
||||||
theme::begin();
|
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 colors = theme::now();
|
||||||
let ui = Ui::read();
|
let ui = Ui::read();
|
||||||
let hypr = Hypr::read();
|
let hypr = Hypr::read();
|
||||||
@@ -283,22 +297,43 @@ pub fn view() -> Window {
|
|||||||
.title(TITLE)
|
.title(TITLE)
|
||||||
.size(1100.0, 760.0)
|
.size(1100.0, 760.0)
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
// Escape closes it, also from a field (a typed value only applies on Enter)
|
/*
|
||||||
.on_key(|key| {
|
* Escape closes it, also from a field (a typed value only applies on Enter). keys no
|
||||||
if key == Key::Escape {
|
* field takes, like right after opening, are typed into the search, which then takes
|
||||||
close_window(NAME);
|
* 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(
|
.child(
|
||||||
Rectangle::new()
|
Rectangle::new()
|
||||||
.width(width)
|
.width(width)
|
||||||
.height(height)
|
.height(height)
|
||||||
.fill(colors.surface_low)
|
.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))];
|
let mut entries: Vec<Box<dyn Widget>> = vec![Box::new(caption("SHELL", colors))];
|
||||||
|
|
||||||
for module in config::modules() {
|
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 })
|
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
|
||||||
.align_child(Start, Center)
|
.align_child(Start, Center)
|
||||||
.cursor(Cursor::Text)
|
.cursor(Cursor::Text)
|
||||||
.child(TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| {
|
.child({
|
||||||
SYNCED.with_borrow_mut(|synced| synced.insert(SEARCH, text.clone()));
|
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()
|
Rectangle::new()
|
||||||
.width(SIDEBAR_W)
|
.width(SIDEBAR_W)
|
||||||
|
|||||||
+12
-4
@@ -156,12 +156,19 @@ fn gpu_clock() -> (f32, String) {
|
|||||||
(0.0, String::from("no clock to read"))
|
(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)> {
|
fn temperatures() -> Vec<(String, Glyph, f32)> {
|
||||||
let mut found: Vec<(String, Glyph, f32)> = Vec::new();
|
let mut found: Vec<(String, Glyph, f32)> = Vec::new();
|
||||||
|
|
||||||
for entry in fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten() {
|
// the devices in their numbers' order (hwmon2 before hwmon10), so two of a kind keep theirs too
|
||||||
let device = entry.path();
|
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 kind = read(device.join("name")).trim().to_string();
|
||||||
|
|
||||||
let hottest = fs::read_dir(&device)
|
let hottest = fs::read_dir(&device)
|
||||||
@@ -191,7 +198,8 @@ fn temperatures() -> Vec<(String, Glyph, f32)> {
|
|||||||
found.push((name.to_string(), glyph, celsius));
|
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());
|
found.truncate(settings::temperatures());
|
||||||
|
|
||||||
// a kind shown more than once is numbered, in the order shown
|
// a kind shown more than once is numbered, in the order shown
|
||||||
|
|||||||
+128
-28
@@ -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::config::appearance::{frosted, liquid, solid, surface};
|
||||||
use crate::island::{backdrop, faded};
|
use crate::island::faded;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -10,48 +13,74 @@ use crate::theme;
|
|||||||
* `content` (already width x height, with its own padding and alignment) in the surface.
|
* `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.
|
* `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)
|
* 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 {
|
pub fn shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
|
||||||
let theme = theme::now();
|
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),
|
// 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
|
// a soft highlight along the top as if lit from above, one soft low shadow instead of a glow
|
||||||
"frosted" => {
|
"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())))
|
Look {
|
||||||
.border(1.0, Color::blend(white(frosted::rim()), faded(theme.accent, 150), lift))
|
shadow: (Color::BLACK, frosted::shadow() + 0.1 * lift, 24.0, (0.0, 6.0)),
|
||||||
.shadow(Shadow::drop(Color::BLACK).opacity(frosted::shadow() + 0.1 * lift).blur(24.0).offset(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))]),
|
||||||
.child(Stack::new(children![sheen, content]).width(width).height(height))
|
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
|
// 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
|
// edge where light enters at the top left and leaves at the bottom right; the accent's glow
|
||||||
"liquid" => {
|
"liquid" => {
|
||||||
let glass = liquid::opacity();
|
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 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)
|
Look {
|
||||||
.border(liquid::rim_width(), Color::blend(white(liquid::rim()), faded(theme.flame, 200), lift))
|
shadow: (theme.accent, liquid::glow() + 0.3 * lift, 24.0, (0.0, 0.0)),
|
||||||
.shadow(Shadow::drop(theme.accent).opacity(liquid::glow() + 0.3 * lift).blur(24.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)))]),
|
||||||
.child(Stack::new(children![edge, content]).width(width).height(height))
|
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
|
// the original: the wallpaper's gradient, a 1 px rim, a soft glow in the accent
|
||||||
_ => base
|
_ => {
|
||||||
.fill(backdrop(colors, alpha(solid::opacity())))
|
let a = 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))
|
Look {
|
||||||
.child(content),
|
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()
|
// 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
|
surface::design() != "solid" || solid::opacity() < 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// a layer just inside the rim, so the rim stays crisp over it
|
static SHADER: LazyLock<PathBuf> = LazyLock::new(|| shader_file("surface.wgsl", include_str!("surface.wgsl")));
|
||||||
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)
|
// 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
|
// an opacity, 0 to 1, as a colour's alpha
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
Reference in New Issue
Block a user