Compare commits
22
Commits
e8741b1cb3
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
303764732b | ||
|
|
184da49c2f | ||
|
|
e963c944e8 | ||
|
|
c24671e981 | ||
|
|
90130add06 | ||
|
|
1b8fc66130 | ||
|
|
f79662bd5b | ||
|
|
e0d8a5479c | ||
|
|
344d318959 | ||
|
|
801eab9ab4 | ||
|
|
c0fff44bcf | ||
|
|
f55314f511 | ||
|
|
3de3709871 | ||
|
|
0457830aba | ||
|
|
689b53be0f | ||
|
|
f8c17025ce | ||
|
|
aadf765d87 | ||
|
|
756f38be4e | ||
|
|
7cb2368b23 | ||
|
|
8cbd211da0 | ||
|
|
e2ef1a9d52 | ||
|
|
d59a5bc81c |
@@ -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.
|
||||||
@@ -113,28 +113,46 @@ Whether the open card takes the song cover's colours instead (`appearance.colors
|
|||||||
|
|
||||||
## Side pills
|
## Side pills
|
||||||
|
|
||||||
Two small one-icon pills sit left of the island and two right of it, on every monitor; they move outward while the island opens. A click morphs a pill into its card. Only one card is open across the shell: opening the island closes a card, and opening a card closes the island. Esc, the cross or a click elsewhere closes it.
|
Two small one-icon pills sit left of the island and two right of it, on every monitor, each in its own colour (`pills.colors.*`; Home's follows the sky). A click grows a pill into a big card in the middle of the top of the screen; meanwhile the row becomes a carousel: the other pills and the island's pill line up two left and two right of the card in their usual order (around Home: AI and System left, the island and the bell right), and clicking one switches straight to its card (the island's pill closes the card, then opens the island). A closing card shrinks back into its own pill's place. Only one card is open across the shell. Esc, the cross or a click elsewhere closes it.
|
||||||
|
|
||||||
- **AI** (sparkle): a chat with a local Ollama model, `gemma4:e4b` at `http://localhost:11434` by default. 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, GPU clock, memory, network and disk read/write as graphs over the last minute, plus the hottest sensors. 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). The month grid dots the days with events. The iCloud calendar shows the coming events once you sign in from the card with your Apple ID and an [app-specific password](https://account.apple.com). The password goes into the keyring through `secret-tool` (libsecret, so a Secret Service like gnome-keyring or KeePassXC has to run), never into `config.toml`. The calendar is read over CalDAV from `pills.calendar.server`, so other CalDAV servers work too.
|
- **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 turns 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.
|
||||||
|
|
||||||
`amane ipc call pods open|toggle ai|monitor|home|notifications [monitor]`, `close`, `ask <question>`, `processes`, `notices list|dismiss <id>|clear`, `status`. Sizes, colours, poll intervals, the city and its coordinates, the model and the popup timeouts are under Side pills and Notifications in the [settings](#settings).
|
`amane ipc call pods open|toggle ai|monitor|home|notifications [monitor]`, `close`, `ask <question>`, `processes`, `notices list|dismiss <id>|clear`, `status`. Sizes, colours, poll intervals, the city and its coordinates, the model and the popup timeouts are under Side pills and Notifications in the [settings](#settings).
|
||||||
|
|
||||||
Left out on purpose: phone stats (iOS shares nothing Linux can read without extra daemons).
|
Left out on purpose: phone stats (iOS shares nothing Linux can read without extra daemons).
|
||||||
|
|
||||||
|
## Lock screen
|
||||||
|
|
||||||
|
`amane ipc call lock` locks the session (ext-session-lock; bind it to a key or an idle daemon yourself). Every monitor shows the wallpaper blurred and darkened, the date with Munich's weather now, a big clock, Hyprland's splash message, what plays with its cover, how far it is and previous/play/next (they work while locked), chips for the next calendar event, the new notifications and the battery, and at the bottom `~/.face`, your name and the password field. The monitors share one field. Enter checks the password with PAM as `login` does; a wrong one lights the field's rim and says so. Each part can be switched off and every size is a setting (`lock.look.*`, `lock.show.*`, the Lock screen page).
|
||||||
|
|
||||||
|
The picture is `lock.look.wallpaper`, or else the wallpaper the colours came from: `matugen/amane.colors` writes its path into the theme file (`wallpaper=`), so it's there after the next wallpaper change. Without either, the colours' gradient. Amane reads PNG and JPEG only.
|
||||||
|
|
||||||
|
It redraws for the music's progress, the clock and typing, so it's cheap to draw: the backdrop (the gradient, and the veil over the picture, darker toward the corners, `lock.look.vignette`) is one shader, `src/lock.wgsl`, and every icon goes into one canvas. Measured in a preview on one 1080p monitor: 0.8 ms per frame to submit and present (2.4 ms before), the GPU's render engine half as busy.
|
||||||
|
|
||||||
|
Killing the shell does not unlock: Hyprland keeps the session locked and shows its own error screen. Keep a second TTY logged in when trying changes to the lock screen.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
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), `liquid` (clear glass, denser toward the bottom right, a light-catching edge, the accent's glow) or `glass` (after Apple's Liquid Glass: a thin tint and an edge like the rim of a lens, brightest where the light falls in at the top left, a glint where it leaves at the bottom right, a thin specular line and a faint warm and cool fringe). Each design's opacity, rim and light are settings of their own (`appearance.solid|frosted|liquid|glass.*`); 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). `glass` has its own, `src/glass.wgsl`, drawn twice: the shadow and tint under the content, the lit edge over it, so a photo on a card lies behind the glass. Unlike Apple's, it doesn't bend the desktop at the rim: a shell's shader never sees what's behind its window, Hyprland's blur is all it has. On see-through glass a face fades with `.opacity()` instead of a veil, a GPU layer only while it fades.
|
||||||
|
|
||||||
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).
|
Screenshots of each design, from when they were made: `docs/design/` (`base-solid-card.png`, `base-glass-0.5-card.png` for solid below 1, `frosted-0.62-card.png`, `liquid-0.4-card.png`, and a board for each glass design: the card three times side by side, with the pills below).
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
Amane draws rectangles, borders, letters and pictures as cheap quads, and gradients, shadows and `Canvas` paths with vello, which renders over the whole window and then lays the result on top. vello keeps a result between frames only when its shapes didn't change and hold no gradient, so anything drawn with it on every frame costs a pass over the whole window. What redraws every frame is therefore kept off vello: the surfaces are a shader, the pill's bars are rounded rectangles, the candle ring and the equalizer's curve are shaders (`src/ring.wgsl`, `src/curve.wgsl`). A shader's file is written to `$XDG_RUNTIME_DIR/magic-island/` when it is first drawn. A shader must not contain the word `time`, or amane redraws its window on every frame.
|
||||||
|
|
||||||
|
`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; glass is its `opacity` all over, 0.32 by default).
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
hl.layer_rule({
|
hl.layer_rule({
|
||||||
@@ -177,14 +195,16 @@ 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/lock.rs the lock screen (amane's App::lock), `lock` IPC
|
||||||
|
src/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs as values for surface.wgsl, glass for glass.wgsl
|
||||||
|
src/*.wgsl shaders: surface (every surface), glass (the glass design), ring (the candles), curve (the equalizer's line), lock (the lock screen's backdrop)
|
||||||
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
|
||||||
@@ -195,6 +215,7 @@ On the desktop that runs the shell:
|
|||||||
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
|
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
|
||||||
- `cava`, `curl`
|
- `cava`, `curl`
|
||||||
- the Fira Code font
|
- the Fira Code font
|
||||||
|
- PAM (`/etc/pam.d/login`), for the lock screen's password
|
||||||
- for the control centre: `iwd`, openrc `openvpn.<name>` services, `pciutils` (`lspci`), `wl-clipboard` (`wl-copy`, for copying a saved password or an address), `iproute2` (`ip`, for the Ethernet details), optionally `bluez` (with `bluetoothctl`), and a doas rule without a password for `rc-service`, `sh` and `cat` (see [Control centre](#control-centre))
|
- for the control centre: `iwd`, openrc `openvpn.<name>` services, `pciutils` (`lspci`), `wl-clipboard` (`wl-copy`, for copying a saved password or an address), `iproute2` (`ip`, for the Ethernet details), optionally `bluez` (with `bluetoothctl`), and a doas rule without a password for `rc-service`, `sh` and `cat` (see [Control centre](#control-centre))
|
||||||
|
|
||||||
## Install and run
|
## Install and run
|
||||||
@@ -226,6 +247,7 @@ amane ipc call config set island.pill.width 260 # any setting by its con
|
|||||||
amane ipc call config set hyprland.decoration.blur.size 3
|
amane ipc call config set hyprland.decoration.blur.size 3
|
||||||
amane ipc call config reset island.pill.width
|
amane ipc call config reset island.pill.width
|
||||||
amane ipc call config status # open=<bool> page=<id> errors=<n> search=<words>
|
amane ipc call config status # open=<bool> page=<id> errors=<n> search=<words>
|
||||||
|
amane ipc call lock # lock the session
|
||||||
```
|
```
|
||||||
|
|
||||||
Example Hyprland keybind:
|
Example Hyprland keybind:
|
||||||
@@ -238,8 +260,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
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 328 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 880 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 381 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -10,3 +10,5 @@ outline={{colors.outline_variant.default.hex}}
|
|||||||
surface={{colors.surface_container.default.hex}}
|
surface={{colors.surface_container.default.hex}}
|
||||||
surface_high={{colors.surface_container_high.default.hex}}
|
surface_high={{colors.surface_container_high.default.hex}}
|
||||||
surface_low={{colors.surface_container_lowest.default.hex}}
|
surface_low={{colors.surface_container_lowest.default.hex}}
|
||||||
|
# the picture the colours came from, for the lock screen
|
||||||
|
wallpaper={{image}}
|
||||||
|
|||||||
@@ -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,11 +37,13 @@ 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,
|
||||||
|
// (server, model) for every model the servers have, while the model list is open (empty while it loads)
|
||||||
|
models: Option<Vec<(String, String)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Service for Ai {
|
impl Service for Ai {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self { said: Vec::new(), busy: false, note: None, back: 0.0, typed: String::new(), round: 0 }
|
Self { said: Vec::new(), busy: false, note: None, back: 0.0, typed: String::new(), round: 0, models: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
// changes only through input and the answering thread
|
// changes only through input and the answering thread
|
||||||
@@ -112,32 +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();
|
||||||
|
|
||||||
if !answers(&server) {
|
let server = models(Some(&model), |words| note(round, words))?
|
||||||
let local = server.contains("://localhost") || server.contains("://127.0.0.1");
|
.into_iter()
|
||||||
|
.find(|(_, name)| *name == model)
|
||||||
if !(settings::start_service() && local) {
|
.map(|(server, _)| server)
|
||||||
return Err(format!("Ollama doesn't answer at {server}"));
|
.ok_or_else(|| format!("None of pills.ai.servers has {model}"))?;
|
||||||
}
|
|
||||||
|
|
||||||
note(round, "Starting ollama…");
|
|
||||||
|
|
||||||
let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output();
|
|
||||||
let until = Instant::now() + Duration::from_secs(30);
|
|
||||||
|
|
||||||
while !answers(&server) {
|
|
||||||
if Instant::now() > until {
|
|
||||||
return Err(String::from("Ollama didn't start (doas rc-service ollama start)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(Duration::from_millis(500));
|
|
||||||
}
|
|
||||||
|
|
||||||
note(round, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = request();
|
let body = request();
|
||||||
|
|
||||||
@@ -189,6 +175,154 @@ fn answer(round: u32) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pills.ai.servers, in order, without trailing slashes
|
||||||
|
fn servers() -> Vec<String> {
|
||||||
|
settings::servers().split(',').map(|server| server.trim().trim_end_matches('/').to_string()).filter(|server| !server.is_empty()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local(server: &str) -> bool {
|
||||||
|
server.contains("://localhost") || server.contains("://127.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// the models a server has; none when it doesn't answer
|
||||||
|
fn models_on(server: &str) -> Option<Vec<String>> {
|
||||||
|
let done = Command::new("curl").args(["-s", "--connect-timeout", "1", "-m", "5", &format!("{server}/api/tags")]).output().ok()?;
|
||||||
|
|
||||||
|
if !done.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = parse::json(&String::from_utf8_lossy(&done.stdout));
|
||||||
|
|
||||||
|
let names = tags.first().and_then(|tags| tags.get("models")).map(|models| match models {
|
||||||
|
parse::Value::List(models) => models.iter().filter_map(|model| model.get("name").and_then(parse::Value::text).map(str::to_string)).collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(names.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* (server, model) for the servers' models, in the servers' order, each model once with the
|
||||||
|
* first server that has it; with `wanted`, only until a server has that model. a local
|
||||||
|
* server that doesn't answer is started (pills.ai.start_service) when the others don't have
|
||||||
|
* `wanted`, or for the whole list; `say` tells what's going on
|
||||||
|
*/
|
||||||
|
fn models(wanted: Option<&str>, say: impl Fn(&str)) -> Result<Vec<(String, String)>, String> {
|
||||||
|
let servers = servers();
|
||||||
|
|
||||||
|
if servers.is_empty() {
|
||||||
|
return Err(String::from("pills.ai.servers names no server"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut started = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut found: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut silent_local = None;
|
||||||
|
let mut answered = false;
|
||||||
|
|
||||||
|
for server in &servers {
|
||||||
|
let Some(names) = models_on(server) else {
|
||||||
|
if local(server) && silent_local.is_none() {
|
||||||
|
silent_local = Some(server.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
answered = true;
|
||||||
|
|
||||||
|
for name in names {
|
||||||
|
if !found.iter().any(|(_, known)| *known == name) {
|
||||||
|
found.push((server.clone(), name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if wanted.is_some_and(|wanted| found.iter().any(|(_, name)| name == wanted)) {
|
||||||
|
return Ok(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(server) = silent_local.filter(|_| !started && settings::start_service()) {
|
||||||
|
wake(&server, &say)?;
|
||||||
|
|
||||||
|
started = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (answered, found.is_empty()) {
|
||||||
|
(false, _) => Err(format!("No Ollama server answers ({})", servers.join(", "))),
|
||||||
|
(true, true) => Err(String::from("The servers have no models (ollama pull <model>)")),
|
||||||
|
(true, false) => Ok(found),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// starts the local ollama service, telling `say` what's going on, and waits until it answers
|
||||||
|
fn wake(server: &str, say: impl Fn(&str)) -> Result<(), String> {
|
||||||
|
if answers(server) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
say("Starting ollama…");
|
||||||
|
|
||||||
|
let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output();
|
||||||
|
let until = Instant::now() + Duration::from_secs(30);
|
||||||
|
|
||||||
|
while !answers(server) {
|
||||||
|
if Instant::now() > until {
|
||||||
|
return Err(String::from("Ollama didn't start (doas rc-service ollama start)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
say("");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// the model list opens (asking ollama which models it has) or closes
|
||||||
|
fn pick() {
|
||||||
|
let mut ai = Ai::write();
|
||||||
|
|
||||||
|
if ai.models.take().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ai.models = Some(Vec::new());
|
||||||
|
|
||||||
|
drop(ai);
|
||||||
|
|
||||||
|
thread::spawn(|| {
|
||||||
|
let names = models(None, |words| Ai::write().note = (!words.is_empty()).then(|| words.to_string()));
|
||||||
|
|
||||||
|
let mut ai = Ai::write();
|
||||||
|
|
||||||
|
match names {
|
||||||
|
// only if the list wasn't closed meanwhile
|
||||||
|
Ok(names) => {
|
||||||
|
if ai.models.is_some() {
|
||||||
|
ai.models = Some(names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(why) => {
|
||||||
|
ai.models = None;
|
||||||
|
ai.note = Some(why);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// another model from the list; kept in config.toml, used from the next question on
|
||||||
|
fn choose(name: String) {
|
||||||
|
Ai::write().models = None;
|
||||||
|
|
||||||
|
let _ = crate::config::set("pills.ai.model", parse::Value::Text(name));
|
||||||
|
}
|
||||||
|
|
||||||
// whether ollama answers at all, within a second
|
// whether ollama answers at all, within a second
|
||||||
fn answers(server: &str) -> bool {
|
fn answers(server: &str) -> bool {
|
||||||
Command::new("curl")
|
Command::new("curl")
|
||||||
@@ -253,7 +387,10 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
|||||||
let ai = Ai::read();
|
let ai = Ai::read();
|
||||||
let theme = theme::now();
|
let theme = theme::now();
|
||||||
|
|
||||||
let mut buttons: Vec<Box<dyn Widget>> = Vec::new();
|
// the model, as a chip that opens the list of the others
|
||||||
|
// 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)));
|
||||||
@@ -261,7 +398,7 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
|||||||
buttons.push(Box::new(pods::chip("New chat", None, new_chat)));
|
buttons.push(Box::new(pods::chip("New chat", None, new_chat)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let header = pods::header(&format!("AI · {}", settings::model()), buttons, width);
|
let header = pods::header("AI", buttons, width);
|
||||||
|
|
||||||
let field_h = 40.0;
|
let field_h = 40.0;
|
||||||
let note_h = text::small() + 8.0;
|
let note_h = text::small() + 8.0;
|
||||||
@@ -294,8 +431,43 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
|||||||
bubbles.push(Box::new(row.child(bubble)));
|
bubbles.push(Box::new(row.child(bubble)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let chat: Box<dyn Widget> = if bubbles.is_empty() {
|
let chat: Box<dyn Widget> = if let Some(models) = &ai.models {
|
||||||
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)))
|
let current = settings::model();
|
||||||
|
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 (server, name) in models {
|
||||||
|
let chosen = *name == current;
|
||||||
|
let picked = name.clone();
|
||||||
|
|
||||||
|
// with more than one server, where the model is, without the scheme
|
||||||
|
let host = if several { server.split("://").last().unwrap_or(server) } else { "" };
|
||||||
|
let host_w = host.chars().count() as f32 * text::small() * crate::config::appearance::font::letter_width();
|
||||||
|
|
||||||
|
rows.push(Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(width)
|
||||||
|
.height(34.0)
|
||||||
|
.radius(12.0)
|
||||||
|
.fill(if chosen { faded(theme.accent, 70) } else { overlays::chip() })
|
||||||
|
.padding(amane::Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 12.0 })
|
||||||
|
.align_child(Start, Center)
|
||||||
|
.cursor(amane::Pointer)
|
||||||
|
.on_click(move |_| choose(picked.clone()))
|
||||||
|
.child(
|
||||||
|
Row::new(vec![
|
||||||
|
Box::new(Rectangle::new().width(width - 24.0 - host_w - 12.0).height(34.0).align_child(Start, Center).child(Text::new(name.as_str()).size(text::body()).color(theme.text).elide())) as Box<dyn Widget>,
|
||||||
|
Box::new(Text::new(host).size(text::small()).color(theme.dim)),
|
||||||
|
])
|
||||||
|
.gap(12.0)
|
||||||
|
.align(Center),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Box::new(Rectangle::new().width(width).height(chat_h).clip().child(Column::new(rows).gap(6.0)))
|
||||||
|
} else if bubbles.is_empty() {
|
||||||
|
Box::new(Rectangle::new().width(width).height(chat_h).align_child(Center, Center).child(Text::new(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()));
|
||||||
}
|
}
|
||||||
|
|||||||
+193
-27
@@ -1,7 +1,10 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{LazyLock, Mutex, PoisonError, RwLock};
|
use std::sync::{LazyLock, Mutex, PoisonError, RwLock};
|
||||||
|
use std::thread::LocalKey;
|
||||||
|
|
||||||
use amane::Color;
|
use amane::Color;
|
||||||
|
|
||||||
@@ -181,7 +184,11 @@ macro_rules! settings {
|
|||||||
$(
|
$(
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn $name() -> $ty {
|
pub fn $name() -> $ty {
|
||||||
super::super::get(concat!(stringify!($module), ".", stringify!($group), ".", stringify!($name)), || $default)
|
std::thread_local! {
|
||||||
|
static SLOT: std::cell::RefCell<(u64, Option<$ty>)> = const { std::cell::RefCell::new((0, None)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
super::super::cached(&SLOT, concat!(stringify!($module), ".", stringify!($group), ".", stringify!($name)), || $default)
|
||||||
}
|
}
|
||||||
)*
|
)*
|
||||||
} )*
|
} )*
|
||||||
@@ -225,7 +232,7 @@ settings! {
|
|||||||
hover_ms: u64 = 220, 0..=2000; "How long hover highlights take to fade in and out"
|
hover_ms: u64 = 220, 0..=2000; "How long hover highlights take to fade in and out"
|
||||||
}
|
}
|
||||||
surface {
|
surface {
|
||||||
design: String ["solid", "frosted", "liquid"] = String::from("solid"); "What the island, the pills, the menus, the control centre and its popups are made of: solid (the wallpaper's gradient with an accent glow), frosted (a thin tint, a hairline rim and a sheen) or liquid (clear glass with a light-catching edge). See-through ones need Hyprland's blur behind the shell: README, Glass"
|
design: String ["solid", "frosted", "liquid", "glass"] = String::from("solid"); "What the island, the pills, the menus, the control centre and its popups are made of: solid (the wallpaper's gradient with an accent glow), frosted (a thin tint, a hairline rim and a sheen), liquid (clear glass with a light-catching edge) or glass (after Apple's Liquid Glass: a thin tint with a lens-like rim). See-through ones need Hyprland's blur behind the shell: README, Glass"
|
||||||
}
|
}
|
||||||
solid {
|
solid {
|
||||||
opacity: f32 = 1.0, 0.05..=1.0; "How opaque the solid design is; below 1 the desktop shows through"
|
opacity: f32 = 1.0, 0.05..=1.0; "How opaque the solid design is; below 1 the desktop shows through"
|
||||||
@@ -245,6 +252,14 @@ settings! {
|
|||||||
light: f32 = 0.235, 0.0..=1.0; "Opacity of the light caught at the top left edge"
|
light: f32 = 0.235, 0.0..=1.0; "Opacity of the light caught at the top left edge"
|
||||||
glow: f32 = 0.22, 0.0..=1.0; "Strength of the accent glow around each surface"
|
glow: f32 = 0.22, 0.0..=1.0; "Strength of the accent glow around each surface"
|
||||||
}
|
}
|
||||||
|
glass {
|
||||||
|
opacity: f32 = 0.32, 0.05..=1.0; "How strongly the glass is tinted in its middle. Keep it above the ignore_alpha of Hyprland's layer rule, or the desktop behind isn't blurred (README, Glass)"
|
||||||
|
edge: f32 = 16.0, 1..=60; "Width of the band along the rim that catches the light like the edge of a lens, in pixels"
|
||||||
|
light: f32 = 0.55, 0.0..=1.0; "Strength of the light caught where it falls in (top left) and where it leaves (bottom right)"
|
||||||
|
rim: f32 = 0.5, 0.0..=1.0; "Strength of the thin bright line on the rim"
|
||||||
|
dispersion: f32 = 0.3, 0.0..=1.0; "Strength of the warm and cool fringe along the rim, as glass splits light"
|
||||||
|
shadow: f32 = 0.18, 0.0..=1.0; "Strength of the soft shadow under each surface"
|
||||||
|
}
|
||||||
fallback {
|
fallback {
|
||||||
accent: Color = Color::rgb(247, 168, 200); "Accent, used until matugen writes ~/.config/amane/theme from the wallpaper"
|
accent: Color = Color::rgb(247, 168, 200); "Accent, used until matugen writes ~/.config/amane/theme from the wallpaper"
|
||||||
on_accent: Color = Color::rgb(40, 18, 32); "Text and icons on the accent colour, without a theme file"
|
on_accent: Color = Color::rgb(40, 18, 32); "Text and icons on the accent colour, without a theme file"
|
||||||
@@ -329,6 +344,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 {
|
||||||
@@ -420,9 +440,6 @@ settings! {
|
|||||||
slider_height: f32 = 28.0, 8..=100; "Height of a slider"
|
slider_height: f32 = 28.0, 8..=100; "Height of a slider"
|
||||||
keys_height: f32 = 26.0, 8..=100; "Height of the keyboard light's buttons"
|
keys_height: f32 = 26.0, 8..=100; "Height of the keyboard light's buttons"
|
||||||
profile_height: f32 = 48.0, 16..=200; "Height of the performance profile buttons"
|
profile_height: f32 = 48.0, 16..=200; "Height of the performance profile buttons"
|
||||||
vram_height: f32 = 14.0, 4..=60; "Height of the external GPU's memory line"
|
|
||||||
row_height: f32 = 22.0, 8..=100; "Height of a program row under the external GPU"
|
|
||||||
max_rows: usize = 5, 1..=50; "Programs listed under the external GPU before \"and N more\""
|
|
||||||
}
|
}
|
||||||
menus {
|
menus {
|
||||||
pad: f32 = 12.0, 0..=100; "Padding inside a menu"
|
pad: f32 = 12.0, 0..=100; "Padding inside a menu"
|
||||||
@@ -502,45 +519,93 @@ settings! {
|
|||||||
layout {
|
layout {
|
||||||
gap: f32 = 10.0, 0..=200; "Space between the island's pill and the small pills beside it"
|
gap: f32 = 10.0, 0..=200; "Space between the island's pill and the small pills beside it"
|
||||||
spacing: f32 = 8.0, 0..=100; "Space between the small pills on one side"
|
spacing: f32 = 8.0, 0..=100; "Space between the small pills on one side"
|
||||||
icon: f32 = 16.0, 6..=40; "Size of the small pills' icons"
|
icon: f32 = 18.0, 6..=40; "Size of the small pills' icons"
|
||||||
open_ms: u64 = 420, 0..=5000; "How long a small pill takes to open into its card, and to close"
|
open_ms: u64 = 420, 0..=5000; "How long a small pill takes to open into its card, and to close"
|
||||||
radius: f32 = 22.0, 0..=60; "Corner radius of the opened cards"
|
radius: f32 = 28.0, 0..=60; "Corner radius of the opened cards"
|
||||||
pad: f32 = 18.0, 0..=60; "Padding inside the opened cards"
|
pad: f32 = 24.0, 0..=60; "Padding inside the opened cards"
|
||||||
|
}
|
||||||
|
colors {
|
||||||
|
ai: Color = Color::rgb(160, 128, 255); "The AI pill's own colour, mixed into its pill and card"
|
||||||
|
monitor: Color = Color::rgb(64, 208, 190); "The system monitor pill's own colour"
|
||||||
|
notifications: Color = Color::rgb(255, 128, 104); "The notification pill's own colour"
|
||||||
|
home_clear_day: Color = Color::rgb(255, 190, 92); "The home pill's colour under a clear or partly cloudy day sky"
|
||||||
|
home_clear_night: Color = Color::rgb(104, 118, 230); "The home pill's colour on a clear night"
|
||||||
|
home_cloudy: Color = Color::rgb(150, 170, 200); "The home pill's colour under clouds (and before the weather is known)"
|
||||||
|
home_fog: Color = Color::rgb(176, 176, 190); "The home pill's colour in fog"
|
||||||
|
home_rain: Color = Color::rgb(84, 156, 244); "The home pill's colour in rain"
|
||||||
|
home_snow: Color = Color::rgb(206, 232, 255); "The home pill's colour in snow"
|
||||||
|
home_storm: Color = Color::rgb(160, 104, 230); "The home pill's colour in a storm"
|
||||||
|
pill_mix: f32 = 0.55, 0..=1; "How much of its own colour a small pill takes (0 = the wallpaper's only)"
|
||||||
|
card_mix: f32 = 0.28, 0..=1; "How much of its pill's colour an open card takes"
|
||||||
}
|
}
|
||||||
ai {
|
ai {
|
||||||
width: f32 = 440.0, 280..=1200; "Width of the AI card"
|
width: f32 = 760.0, 280..=1400; "Width of the AI card"
|
||||||
height: f32 = 520.0, 200..=1200; "Height of the AI card"
|
height: f32 = 680.0, 200..=1200; "Height of the AI card"
|
||||||
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"
|
||||||
}
|
}
|
||||||
monitor {
|
monitor {
|
||||||
width: f32 = 420.0, 280..=1200; "Width of the system monitor card"
|
width: f32 = 620.0, 280..=1400; "Width of the system monitor card"
|
||||||
interval_ms: u64 = 1000, 200..=10000; "How often the system monitor reads the system"
|
interval_ms: u64 = 1000, 200..=10000; "How often the system monitor reads the system"
|
||||||
history: usize = 60, 10..=600; "How many readings the graphs show"
|
history: usize = 60, 10..=600; "How many readings the graphs show"
|
||||||
graph_height: f32 = 34.0, 10..=200; "Height of each graph"
|
graph_height: f32 = 46.0, 10..=200; "Height of each graph"
|
||||||
temperatures: usize = 6, 0..=20; "How many temperature sensors to show, hottest first"
|
temperatures: usize = 6, 0..=20; "How many temperature sensors to show, always in the same order: CPU, iGPU, eGPU, NVMe, memory, board, then the rest"
|
||||||
|
warm: f32 = 65.0, 20..=120; "From this temperature (°C) a sensor shows in the warm colour"
|
||||||
|
hot: f32 = 85.0, 20..=130; "From this temperature (°C) a sensor shows in the hot colour"
|
||||||
|
cool_color: Color = Color::rgb(120, 200, 255); "Colour of a sensor below the warm temperature"
|
||||||
|
warm_color: Color = Color::rgb(255, 196, 92); "Colour of a warm sensor"
|
||||||
|
hot_color: Color = Color::rgb(255, 105, 97); "Colour of a hot sensor"
|
||||||
|
programs: usize = 5, 1..=50; "Programs on the external GPU listed before the rest are counted"
|
||||||
terminal: String = String::from("ghostty"); "The terminal that runs the process list"
|
terminal: String = String::from("ghostty"); "The terminal that runs the process list"
|
||||||
processes: String = String::from("btm --default_widget_type proc --expanded"); "The process list command, opened on a special workspace"
|
processes: String = String::from("btm --default_widget_type proc --expanded"); "The process list command, opened on a special workspace"
|
||||||
workspace: String = String::from("processes"); "The special workspace the process list opens on"
|
workspace: String = String::from("processes"); "The special workspace the process list opens on"
|
||||||
}
|
}
|
||||||
home {
|
home {
|
||||||
width: f32 = 460.0, 300..=1200; "Width of the home card"
|
width: f32 = 880.0, 300..=1600; "Width of the home card. It opens in the middle with the AI, system and island pills on its left, which on a 1920 px wide monitor leaves room for about 900 beside the workspace pill"
|
||||||
clock_size: f32 = 64.0, 20..=200; "Size of the big clock"
|
clock_size: f32 = 64.0, 20..=200; "Size of the big clock"
|
||||||
|
clock_font: String = String::new(); "The big clock's font family, any installed one (fc-list : family lists them); empty takes the shell's font"
|
||||||
|
clock_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 = 84.0, 24..=200; "Size of the avatar; the greeting row is as tall"
|
||||||
|
gap: f32 = 12.0, 0..=60; "Space between the home card's parts"
|
||||||
city: String = String::from("Munich"); "The weather's place, as shown"
|
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"
|
||||||
longitude: f32 = 11.575, -180.0..=180.0; "The weather's longitude"
|
longitude: f32 = 11.575, -180.0..=180.0; "The weather's longitude"
|
||||||
weather_minutes: u64 = 20, 5..=360; "How often the weather is fetched, in minutes"
|
weather_minutes: u64 = 20, 5..=360; "How often the weather is fetched, in minutes"
|
||||||
|
weather_height: f32 = 60.0, 30..=200; "Height of the weather now"
|
||||||
|
weather_icon: f32 = 44.0, 16..=120; "Size of the icon of the weather now"
|
||||||
|
temperature_size: f32 = 28.0, 10..=100; "Size of the temperature now"
|
||||||
|
forecast_height: f32 = 64.0, 30..=200; "Height of the coming days' weather"
|
||||||
|
forecast_icon: f32 = 22.0, 10..=80; "Size of the coming days' weather icons"
|
||||||
|
photo: bool = true; "Show a photo of the weather now behind the home card"
|
||||||
|
photos: String = String::from("~/.config/amane/weather"); "Folder of the weather photos: <kind>-<day|night>.jpg for clear, partly-cloudy, cloudy, fog, rain, snow and storm. A missing one is downloaded once from Wikimedia Commons (credits in the repo's weather/CREDITS.md); a file that's there is never replaced, so put your own in its place (the shell shows a swapped file after a restart)"
|
||||||
|
photo_opacity: f32 = 0.45, 0..=1; "How much the weather photo shows through behind the home card: the card's colour veils the rest, 0 hides it"
|
||||||
}
|
}
|
||||||
calendar {
|
calendar {
|
||||||
server: String = String::from("https://caldav.icloud.com/"); "The CalDAV server; iCloud's by default"
|
server: String = String::from("https://caldav.icloud.com/"); "The CalDAV server; iCloud's by default"
|
||||||
account: String = String::new(); "The Apple ID (or CalDAV user). Sign in from the home card; the app-specific password goes to the keyring, never here"
|
contacts_server: String = String::from("https://contacts.icloud.com/"); "The CardDAV server the birthdays come from, with the same account; iCloud's by default"
|
||||||
days: u64 = 14, 1..=90; "How many days ahead the calendar shows events"
|
account: String = String::new(); "Your Apple ID (or CalDAV user). Store its app-specific password (account.apple.com > Sign-In and Security > App-Specific Passwords) in the keyring with: secret-tool store --label='Magic Island calendar' service amane-calendar account <Apple ID>"
|
||||||
sync_minutes: u64 = 15, 1..=360; "How often the calendar is synced, in minutes"
|
sync_minutes: u64 = 15, 1..=360; "How often the calendar and the birthdays are synced, in minutes"
|
||||||
events: usize = 6, 1..=30; "How many upcoming events the home card lists"
|
holidays: String = String::from("BY"); "The German state whose public holidays the calendar marks: BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH or TH; empty marks none"
|
||||||
|
birthdays: bool = true; "Show the birthdays of your contacts"
|
||||||
|
cell: f32 = 52.0, 32..=96; "Size of a day in the month grid; the card grows with it"
|
||||||
|
disc: f32 = 0.72, 0.3..=1.0; "Size of today's disc and the selected day's ring, as a share of a day"
|
||||||
|
ring: f32 = 2.0, 0.5..=6.0; "Thickness of the selected day's ring"
|
||||||
|
dot: f32 = 5.0, 2..=16; "Size of the dots under a day with events, one per calendar"
|
||||||
|
dots: usize = 3, 1..=6; "Most dots under one day"
|
||||||
|
mark: f32 = 13.0, 6..=30; "Size of the cake on a day with a birthday"
|
||||||
|
list_gap: f32 = 24.0, 0..=100; "Space between the month grid and the selected day's list"
|
||||||
|
item_gap: f32 = 8.0, 0..=40; "Space between the selected day's items"
|
||||||
|
field_height: f32 = 32.0, 20..=60; "Height of the event form's text fields"
|
||||||
|
length_minutes: u64 = 60, 5..=1440; "How long a new event lasts until you change its end"
|
||||||
|
holiday_color: Color = Color::rgb(255, 122, 104); "Colour of public holidays: the day's number, its tint and flag"
|
||||||
|
holiday_tint: f32 = 0.2, 0..=1; "How strongly a public holiday is tinted in its colour"
|
||||||
|
birthday_color: Color = Color::rgb(255, 138, 196); "Colour of birthdays: the cake on the day and in its list"
|
||||||
|
selected_color: Color = Color::rgba(255, 255, 255, 220); "Colour of the selected day's ring"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,16 +619,57 @@ settings! {
|
|||||||
count: usize = 4, 1..=10; "How many popups show at once"
|
count: usize = 4, 1..=10; "How many popups show at once"
|
||||||
icon: f32 = 40.0, 16..=128; "Size of a notification's icon or picture"
|
icon: f32 = 40.0, 16..=128; "Size of a notification's icon or picture"
|
||||||
}
|
}
|
||||||
|
apps {
|
||||||
|
muted: String = String::new(); "Apps whose notifications don't pop up but stay in the history, comma-separated (Mute app on a notification adds it)"
|
||||||
|
blocked: String = String::new(); "Apps whose notifications are dropped at once, comma-separated (Block app on a notification adds it)"
|
||||||
|
}
|
||||||
history {
|
history {
|
||||||
width: f32 = 420.0, 280..=1200; "Width of the notification card"
|
width: f32 = 620.0, 280..=1400; "Width of the notification card"
|
||||||
height: f32 = 560.0, 200..=1400; "Height of the notification card"
|
height: f32 = 700.0, 200..=1400; "Height of the notification card"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock {
|
||||||
|
look {
|
||||||
|
wallpaper: String = String::new(); "The picture behind the lock screen, PNG or JPEG; empty takes the wallpaper the shell's colours come from (the wallpaper line matugen writes into the theme file), else the colours' gradient"
|
||||||
|
blur: u64 = 6, 0..=40; "How blurred the picture is, in pixels of its small copy; 0 shows it sharp"
|
||||||
|
copy_width: u64 = 320, 64..=1920; "Width of the small copy of the picture that's blurred and stretched over the screen; smaller is softer"
|
||||||
|
dim: f32 = 0.3, 0..=1; "How much the picture is darkened, so the words over it stay readable"
|
||||||
|
vignette: f32 = 0.35, 0..=1; "How much darker the screen's corners are than its middle"
|
||||||
|
top: f32 = 90.0, 0..=600; "Space above the date"
|
||||||
|
bottom: f32 = 70.0, 0..=600; "Space below the password field"
|
||||||
|
gap: f32 = 16.0, 0..=80; "Space between the lock screen's parts"
|
||||||
|
date_size: f32 = 22.0, 10..=80; "Size of the date and the weather beside it"
|
||||||
|
clock_size: f32 = 150.0, 40..=400; "Size of the clock"
|
||||||
|
clock_font: String = String::new(); "The clock's font family, any installed one (fc-list : family lists them); empty takes the shell's font"
|
||||||
|
clock_weight: u64 = 600, 100..=900; "The clock's weight, 100 (thin) to 900 (black)"
|
||||||
|
card_width: f32 = 440.0, 240..=1200; "Width of the music card"
|
||||||
|
card_height: f32 = 100.0, 60..=300; "Height of the music card"
|
||||||
|
cover: f32 = 68.0, 24..=200; "Size of the cover on the music card"
|
||||||
|
chip_height: f32 = 34.0, 20..=80; "Height of the chips under the music: the next event, the new notifications, the battery"
|
||||||
|
avatar_size: f32 = 96.0, 24..=300; "Size of the avatar; pills.home.avatar is the picture"
|
||||||
|
field_width: f32 = 300.0, 160..=800; "Width of the password field"
|
||||||
|
field_height: f32 = 46.0, 28..=100; "Height of the password field"
|
||||||
|
wrong_color: Color = Color::rgb(255, 105, 97); "Colour of the note after a wrong password"
|
||||||
|
}
|
||||||
|
show {
|
||||||
|
weather: bool = true; "The weather now beside the date, for the home pill's place"
|
||||||
|
splash: bool = true; "Hyprland's splash message under the clock"
|
||||||
|
music: bool = true; "What plays, with its cover"
|
||||||
|
controls: bool = true; "Previous, play/pause and next on the music card; they work while locked"
|
||||||
|
event: bool = true; "The next event of the home pill's calendar"
|
||||||
|
notifications: bool = true; "How many notifications came in that you haven't seen yet"
|
||||||
|
battery: bool = true; "The battery's charge"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// what config.toml set, by path; a missing or bad setting takes its default
|
// what config.toml set, by path; a missing or bad setting takes its default. written only through change()
|
||||||
static VALUES: LazyLock<RwLock<HashMap<String, Value>>> = LazyLock::new(Default::default);
|
static VALUES: LazyLock<RwLock<HashMap<String, Value>>> = LazyLock::new(Default::default);
|
||||||
|
|
||||||
|
// how many times VALUES changed; an accessor's cached answer from an older count is read again
|
||||||
|
static GENERATION: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
// what was wrong in the file when it was last read, for the config window
|
// what was wrong in the file when it was last read, for the config window
|
||||||
static ERRORS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
static ERRORS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
@@ -578,11 +684,43 @@ fn written() -> std::sync::MutexGuard<'static, String> {
|
|||||||
WRITTEN.lock().unwrap_or_else(PoisonError::into_inner)
|
WRITTEN.lock().unwrap_or_else(PoisonError::into_inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: a lock and a hash lookup per call, a few hundred a frame; a snapshot struct if a profile ever shows it
|
|
||||||
fn get<T: Setting>(path: &str, default: impl FnOnce() -> T) -> T {
|
fn get<T: Setting>(path: &str, default: impl FnOnce() -> T) -> T {
|
||||||
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).and_then(T::read).unwrap_or_else(default)
|
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).and_then(T::read).unwrap_or_else(default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// an accessor's last answer on this thread, with the GENERATION it was read at
|
||||||
|
type Slot<T> = LocalKey<RefCell<(u64, Option<T>)>>;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* what every accessor (settings!) returns: its last answer while VALUES hasn't changed
|
||||||
|
* since, else a fresh get(). a few hundred reads a frame with a card open, each a lock,
|
||||||
|
* a hash of the path and a conversion (a colour parsed from its text) at 160 to 570 ns
|
||||||
|
* unoptimised; a cached one is an atomic load and a copy, 13 ns. per thread, so it takes
|
||||||
|
* no lock. a read racing a change may return the old value once, as get() could
|
||||||
|
*/
|
||||||
|
fn cached<T: Setting + Clone>(slot: &'static Slot<T>, path: &str, default: impl FnOnce() -> T) -> T {
|
||||||
|
let now = GENERATION.load(Ordering::Acquire);
|
||||||
|
|
||||||
|
if let Some(value) = slot.with_borrow(|(seen, value)| value.clone().filter(|_| *seen == now)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = get(path, default);
|
||||||
|
|
||||||
|
slot.set((now, Some(value.clone())));
|
||||||
|
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
// every change to VALUES: made, then counted (the write lock is gone by then), so the accessors read again
|
||||||
|
fn change<R>(edit: impl FnOnce(&mut HashMap<String, Value>) -> R) -> R {
|
||||||
|
let done = edit(&mut VALUES.write().unwrap_or_else(PoisonError::into_inner));
|
||||||
|
|
||||||
|
GENERATION.fetch_add(1, Ordering::Release);
|
||||||
|
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
pub fn file() -> PathBuf {
|
pub fn file() -> PathBuf {
|
||||||
theme::config_file("config.toml")
|
theme::config_file("config.toml")
|
||||||
}
|
}
|
||||||
@@ -629,8 +767,8 @@ pub fn reread() -> bool {
|
|||||||
// shell.conf's four settings, moved to their places in config.toml
|
// shell.conf's four settings, moved to their places in config.toml
|
||||||
fn migrate() {
|
fn migrate() {
|
||||||
let old = fs::read_to_string(theme::config_file("shell.conf")).unwrap_or_default();
|
let old = fs::read_to_string(theme::config_file("shell.conf")).unwrap_or_default();
|
||||||
let mut values = VALUES.write().unwrap_or_else(PoisonError::into_inner);
|
|
||||||
|
|
||||||
|
change(|values| {
|
||||||
for (key, text) in parse::settings(&old) {
|
for (key, text) in parse::settings(&old) {
|
||||||
let path = match key.as_str() {
|
let path = match key.as_str() {
|
||||||
"cover_colors" => "appearance.colors.cover_colors",
|
"cover_colors" => "appearance.colors.cover_colors",
|
||||||
@@ -646,6 +784,7 @@ fn migrate() {
|
|||||||
values.insert(path.to_string(), value);
|
values.insert(path.to_string(), value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// the file's text, read anew: what it sets replaces everything set before
|
// the file's text, read anew: what it sets replaces everything set before
|
||||||
@@ -662,7 +801,7 @@ pub fn apply_text(text: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*VALUES.write().unwrap_or_else(PoisonError::into_inner) = values;
|
change(|old| *old = values);
|
||||||
*ERRORS.lock().unwrap_or_else(PoisonError::into_inner) = errors;
|
*ERRORS.lock().unwrap_or_else(PoisonError::into_inner) = errors;
|
||||||
|
|
||||||
hypr::sync_soon();
|
hypr::sync_soon();
|
||||||
@@ -762,7 +901,7 @@ pub fn set(path: &str, value: Value) -> Result<(), String> {
|
|||||||
|
|
||||||
let mut written = written();
|
let mut written = written();
|
||||||
|
|
||||||
VALUES.write().unwrap_or_else(PoisonError::into_inner).insert(path.to_string(), value);
|
change(|values| values.insert(path.to_string(), value));
|
||||||
|
|
||||||
save(&mut written)
|
save(&mut written)
|
||||||
}
|
}
|
||||||
@@ -771,7 +910,7 @@ pub fn set(path: &str, value: Value) -> Result<(), String> {
|
|||||||
pub fn reset(path: &str) -> Result<(), String> {
|
pub fn reset(path: &str) -> Result<(), String> {
|
||||||
let mut written = written();
|
let mut written = written();
|
||||||
|
|
||||||
VALUES.write().unwrap_or_else(PoisonError::into_inner).remove(path);
|
change(|values| values.remove(path));
|
||||||
|
|
||||||
save(&mut written)
|
save(&mut written)
|
||||||
}
|
}
|
||||||
@@ -927,6 +1066,7 @@ pub fn title(id: &str) -> String {
|
|||||||
match id {
|
match id {
|
||||||
"control" => String::from("Control centre"),
|
"control" => String::from("Control centre"),
|
||||||
"pills" => String::from("Side pills"),
|
"pills" => String::from("Side pills"),
|
||||||
|
"lock" => String::from("Lock screen"),
|
||||||
"bar" => String::from("Bar hiding"),
|
"bar" => String::from("Bar hiding"),
|
||||||
"input-capture" => String::from("Input capture"),
|
"input-capture" => String::from("Input capture"),
|
||||||
"opengl" => String::from("OpenGL"),
|
"opengl" => String::from("OpenGL"),
|
||||||
@@ -950,3 +1090,29 @@ pub fn label(name: &str) -> String {
|
|||||||
|
|
||||||
title(name)
|
title(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// the cached accessors follow every change, on this thread and on others
|
||||||
|
#[test]
|
||||||
|
fn accessors_follow_changes() {
|
||||||
|
let (width, family, accent) = (island::pill::width(), appearance::font::family(), appearance::fallback::accent());
|
||||||
|
|
||||||
|
change(|values| {
|
||||||
|
values.insert("island.pill.width".into(), Value::Float(321.0));
|
||||||
|
values.insert("appearance.font.family".into(), Value::Text("Iosevka".into()));
|
||||||
|
values.insert("appearance.fallback.accent".into(), Value::Text("#102030".into()));
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(island::pill::width(), 321.0);
|
||||||
|
assert_eq!(appearance::font::family(), "Iosevka");
|
||||||
|
assert_eq!(appearance::fallback::accent(), Color::rgb(16, 32, 48));
|
||||||
|
assert_eq!(std::thread::spawn(island::pill::width).join().unwrap(), 321.0);
|
||||||
|
|
||||||
|
change(|values| values.clear());
|
||||||
|
|
||||||
|
assert_eq!((island::pill::width(), appearance::font::family(), appearance::fallback::accent()), (width, family, accent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+17
-99
@@ -2,7 +2,7 @@
|
|||||||
* the control centre: a status pill in the top right corner of every monitor (wi-fi,
|
* the control centre: a status pill in the top right corner of every monitor (wi-fi,
|
||||||
* vpn, bluetooth, battery) that opens, on the monitor it was clicked on, into a panel
|
* vpn, bluetooth, battery) that opens, on the monitor it was clicked on, into a panel
|
||||||
* like macOS's: connection tiles, sound, the screen and keyboard light, the performance
|
* like macOS's: connection tiles, sound, the screen and keyboard light, the performance
|
||||||
* mode with the battery, and the external gpu with the programs using it. it opens on
|
* mode with the battery (the external gpu moved to the system monitor pill). it opens on
|
||||||
* the island's timeline (island::phases): pill out, the empty shape grows, panel in
|
* the island's timeline (island::phases): pill out, the empty shape grows, panel in
|
||||||
*/
|
*/
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -26,7 +26,7 @@ use crate::parse;
|
|||||||
use crate::prompt;
|
use crate::prompt;
|
||||||
use crate::settings;
|
use crate::settings;
|
||||||
use crate::sound::Sound;
|
use crate::sound::Sound;
|
||||||
use crate::system::{self, Charge, Gpu, Links, Power, VpnState};
|
use crate::system::{self, Charge, Links, Power, VpnState};
|
||||||
use crate::{surface, theme};
|
use crate::{surface, theme};
|
||||||
|
|
||||||
// every size and timing is a setting (config.rs: control.*); these follow from them
|
// every size and timing is a setting (config.rs: control.*); these follow from them
|
||||||
@@ -266,12 +266,11 @@ struct Layout {
|
|||||||
keyboard: bool,
|
keyboard: bool,
|
||||||
profiles: bool,
|
profiles: bool,
|
||||||
battery: bool,
|
battery: bool,
|
||||||
gpu_rows: Option<usize>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Layout {
|
impl Layout {
|
||||||
fn largest() -> Layout {
|
fn largest() -> Layout {
|
||||||
Layout { ethernet: true, extra_output: true, screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(c::modules::max_rows()) }
|
Layout { ethernet: true, extra_output: true, screen: true, keyboard: true, profiles: true, battery: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sound(&self) -> f32 {
|
fn sound(&self) -> f32 {
|
||||||
@@ -292,12 +291,8 @@ impl Layout {
|
|||||||
module_height(&parts)
|
module_height(&parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gpu(&self) -> Option<f32> {
|
|
||||||
self.gpu_rows.map(|rows| 2.0 * c::modules::pad() + c::modules::title_height() + c::modules::vram_height() + rows.max(1) as f32 * c::modules::row_height() + 2.0 * c::modules::line_gap())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn height(&self) -> f32 {
|
fn height(&self) -> f32 {
|
||||||
let modules: f32 = [Some(self.sound()), self.display(), self.performance(), self.gpu()].into_iter().flatten().map(|height| c::panel::gap() + height).sum();
|
let modules: f32 = [Some(self.sound()), self.display(), self.performance()].into_iter().flatten().map(|height| c::panel::gap() + height).sum();
|
||||||
let tile_rows = if self.ethernet { 3.0 } else { 2.0 };
|
let tile_rows = if self.ethernet { 3.0 } else { 2.0 };
|
||||||
|
|
||||||
// the Settings button closes the panel
|
// the Settings button closes the panel
|
||||||
@@ -321,7 +316,6 @@ struct Scene<'a> {
|
|||||||
bluetooth: &'a Bluetooth,
|
bluetooth: &'a Bluetooth,
|
||||||
audio: &'a Audio,
|
audio: &'a Audio,
|
||||||
screen: &'a Brightness,
|
screen: &'a Brightness,
|
||||||
gpu: &'a Gpu,
|
|
||||||
hotspot: &'a str,
|
hotspot: &'a str,
|
||||||
extra_output: Option<&'a parse::Sink>,
|
extra_output: Option<&'a parse::Sink>,
|
||||||
layout: Layout,
|
layout: Layout,
|
||||||
@@ -375,7 +369,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
|
|
||||||
let audio = open.then(Audio::read);
|
let audio = open.then(Audio::read);
|
||||||
let screen = open.then(Brightness::read);
|
let screen = open.then(Brightness::read);
|
||||||
let gpu = open.then(Gpu::read);
|
|
||||||
let settings = open.then(|| (c::services::hotspot(), c::services::extra_output()));
|
let settings = open.then(|| (c::services::hotspot(), c::services::extra_output()));
|
||||||
let sound = open.then(Sound::read);
|
let sound = open.then(Sound::read);
|
||||||
|
|
||||||
@@ -391,7 +384,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
keyboard: power.keyboard.is_some(),
|
keyboard: power.keyboard.is_some(),
|
||||||
profiles: !power.profiles.is_empty(),
|
profiles: !power.profiles.is_empty(),
|
||||||
battery: power.battery.is_some(),
|
battery: power.battery.is_some(),
|
||||||
gpu_rows: gpu.as_deref().filter(|gpu| !gpu.name.is_empty()).map(|gpu| gpu.programs.len().min(c::modules::max_rows())),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -427,8 +419,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
let colors = theme::now().surfaces();
|
let colors = theme::now().surfaces();
|
||||||
|
|
||||||
// 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 = match (&audio, &screen, &gpu, &settings) {
|
let face = match (&audio, &screen, &settings) {
|
||||||
(Some(audio), Some(screen), Some(gpu), Some((hotspot, _))) if reveal > 0.0 && fade > 0.0 => {
|
(Some(audio), Some(screen), Some((hotspot, _))) if reveal > 0.0 && fade > 0.0 => {
|
||||||
let scene = Scene {
|
let scene = Scene {
|
||||||
control: &control,
|
control: &control,
|
||||||
links: &links,
|
links: &links,
|
||||||
@@ -436,7 +428,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
bluetooth: &bluetooth,
|
bluetooth: &bluetooth,
|
||||||
audio,
|
audio,
|
||||||
screen,
|
screen,
|
||||||
gpu,
|
|
||||||
hotspot,
|
hotspot,
|
||||||
extra_output,
|
extra_output,
|
||||||
layout,
|
layout,
|
||||||
@@ -829,14 +820,19 @@ fn compact(items: Vec<Box<dyn Widget>>, width: f32) -> Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// as wide as battery() draws: the bolt, the body and its nub, the percentage
|
// as wide as battery() draws: the bolt, the body and its nub, the percentage
|
||||||
fn battery_width(charge: &Charge) -> f32 {
|
pub fn battery_width(charge: &Charge) -> f32 {
|
||||||
let bolt = if charge.status == "Charging" { 11.0 + 5.0 } else { 0.0 };
|
let bolt = if charge.status == "Charging" { 11.0 + 5.0 } else { 0.0 };
|
||||||
|
|
||||||
bolt + 24.0 + 1.0 + 1.8 + 5.0 + label_width(&format!("{}%", charge.percent), 11.0, 0.0)
|
bolt + 24.0 + 1.0 + 1.8 + 5.0 + label_width(&format!("{}%", charge.percent), 11.0, 0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// a battery drawn from rectangles, filled to its charge, with a bolt while it charges
|
// a battery drawn from rectangles, filled to its charge, with a bolt while it charges
|
||||||
fn battery(charge: &Charge) -> Row {
|
pub fn battery(charge: &Charge) -> Row {
|
||||||
|
battery_with(charge, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `bolt` false leaves the bolt's place (11 px, first) empty, for a caller drawing its icons into one canvas
|
||||||
|
pub fn battery_with(charge: &Charge, bolt: bool) -> Row {
|
||||||
let theme = theme::now();
|
let theme = theme::now();
|
||||||
|
|
||||||
let low = charge.percent <= 15 && charge.status == "Discharging";
|
let low = charge.percent <= 15 && charge.status == "Discharging";
|
||||||
@@ -858,7 +854,11 @@ fn battery(charge: &Charge) -> Row {
|
|||||||
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
|
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
if charge.status == "Charging" {
|
if charge.status == "Charging" {
|
||||||
|
if bolt {
|
||||||
parts.push(Box::new(icon(Glyph::Bolt, 11.0, theme.accent)));
|
parts.push(Box::new(icon(Glyph::Bolt, 11.0, theme.accent)));
|
||||||
|
} else {
|
||||||
|
parts.push(Box::new(Rectangle::new().width(11.0).height(11.0)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(Box::new(Row::new(children![body, nub]).gap(1.0).align(Center)));
|
parts.push(Box::new(Row::new(children![body, nub]).gap(1.0).align(Center)));
|
||||||
@@ -880,10 +880,6 @@ fn panel(scene: &Scene) -> Rectangle {
|
|||||||
blocks.push(Box::new(performance(scene, height)));
|
blocks.push(Box::new(performance(scene, height)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(height) = scene.layout.gpu() {
|
|
||||||
blocks.push(Box::new(gpu(scene, height)));
|
|
||||||
}
|
|
||||||
|
|
||||||
blocks.push(Box::new(settings_button(scene.control)));
|
blocks.push(Box::new(settings_button(scene.control)));
|
||||||
|
|
||||||
let height = scene.layout.height();
|
let height = scene.layout.height();
|
||||||
@@ -1480,84 +1476,6 @@ fn battery_words(charge: &Charge) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// the external gpu: its load, memory and temperature, and who uses it
|
|
||||||
|
|
||||||
fn gpu(scene: &Scene, height: f32) -> Rectangle {
|
|
||||||
let theme = theme::now();
|
|
||||||
let gpu = scene.gpu;
|
|
||||||
|
|
||||||
let mut facts = vec![format!("{}%", gpu.busy)];
|
|
||||||
|
|
||||||
if let Some(celsius) = gpu.celsius {
|
|
||||||
facts.push(format!("{celsius}°C"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(watts) = gpu.watts {
|
|
||||||
facts.push(format!("{watts:.0} W"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let title = Row::new(children![icon(Glyph::Chip, 15.0, theme.accent), Text::new(gpu.name.as_str()).size(text::body()).weight(Weight::Bold).color(theme.text).elide()]).gap(8.0).align(Center);
|
|
||||||
|
|
||||||
let top = Row::new(children![title, note(facts.join(" · "))]).justify(SpaceBetween).align(Center).width(module_w()).height(c::modules::title_height());
|
|
||||||
|
|
||||||
let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 };
|
|
||||||
|
|
||||||
let memory_label = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024));
|
|
||||||
let memory_w = label_width(&memory_label, 10.5, 4.0);
|
|
||||||
let bar_w = module_w() - memory_w - 10.0;
|
|
||||||
|
|
||||||
let memory = Row::new(children![
|
|
||||||
Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)),
|
|
||||||
Text::new(memory_label).size(text::caption()).color(theme.dim),
|
|
||||||
])
|
|
||||||
.gap(10.0)
|
|
||||||
.align(Center)
|
|
||||||
.width(module_w())
|
|
||||||
.height(c::modules::vram_height());
|
|
||||||
|
|
||||||
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
|
|
||||||
|
|
||||||
let shown = if gpu.programs.len() > c::modules::max_rows() { c::modules::max_rows() - 1 } else { c::modules::max_rows() };
|
|
||||||
|
|
||||||
for program in gpu.programs.iter().take(shown) {
|
|
||||||
let mut amount = size(program.vram_kib);
|
|
||||||
|
|
||||||
if let Some(busy) = program.busy {
|
|
||||||
amount = format!("{amount} · {busy}%");
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push(Box::new(
|
|
||||||
Row::new(children![
|
|
||||||
Text::new(program.name.as_str()).size(text::body()).color(theme.text).elide(),
|
|
||||||
note(amount),
|
|
||||||
])
|
|
||||||
.justify(SpaceBetween)
|
|
||||||
.align(Center)
|
|
||||||
.width(module_w())
|
|
||||||
.height(c::modules::row_height()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if gpu.programs.len() > c::modules::max_rows() {
|
|
||||||
rows.push(Box::new(Rectangle::new().width(module_w()).height(c::modules::row_height()).align_child(Start, Center).child(note(format!("and {} more", gpu.programs.len() - shown)))));
|
|
||||||
}
|
|
||||||
|
|
||||||
if gpu.programs.is_empty() {
|
|
||||||
rows.push(Box::new(Rectangle::new().width(module_w()).height(c::modules::row_height()).align_child(Start, Center).child(note("No programs on it"))));
|
|
||||||
}
|
|
||||||
|
|
||||||
module(height, Column::new(children![top, memory, Column::new(rows)]).gap(c::modules::line_gap()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// video memory in KiB, the way a person reads it
|
|
||||||
fn size(kib: u64) -> String {
|
|
||||||
match kib {
|
|
||||||
0..1024 => String::from("<1 MB"),
|
|
||||||
1024..1_048_576 => format!("{} MB", kib / 1024),
|
|
||||||
_ => format!("{:.1} GB", kib as f64 / 1_048_576.0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* the menus: a window of their own per monitor, over the panel, so the panel never
|
* the menus: a window of their own per monitor, over the panel, so the panel never
|
||||||
* re-lays out. a menu grows out of the tile or block that opened it (the island's
|
* re-lays out. a menu grows out of the tile or block that opened it (the island's
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+1
-1
@@ -162,7 +162,7 @@ fn socket(name: &str) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// a question to hyprland, as `hyprctl <request>` would ask it; empty if it can't be asked
|
// a question to hyprland, as `hyprctl <request>` would ask it; empty if it can't be asked
|
||||||
fn hyprctl(request: &str) -> String {
|
pub fn hyprctl(request: &str) -> String {
|
||||||
let Some(mut stream) = socket(".socket.sock").and_then(|path| UnixStream::connect(path).ok()) else {
|
let Some(mut stream) = socket(".socket.sock").and_then(|path| UnixStream::connect(path).ok()) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
|
|||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
// glass after Apple's Liquid Glass, as far as a surface that can't see what's behind it
|
||||||
|
// gets: a thin, lightly tinted middle over Hyprland's blur, and an edge drawn like the rim
|
||||||
|
// of a lens: a band that brightens toward the rim where the light falls in (top left) and
|
||||||
|
// where it leaves again (bottom right), a thin specular line on the rim itself, and a faint
|
||||||
|
// warm and cool fringe. 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, lift (0 to 1, hovered or raised)
|
||||||
|
// 3: width of the edge band in pixels, light, rim and fringe strengths (0 to 1)
|
||||||
|
// 4-6: the tint, top left to bottom right, plain colours with the middle's opacity as alpha
|
||||||
|
// 7.x: the layer: 1 the shadow and the tint, under the content; 2 the lit edge and the rim,
|
||||||
|
// over it (a photo on the card then lies behind the glass); 0 both
|
||||||
|
|
||||||
|
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, as in surface.wgsl
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the way out of the shape at p: across a side, or out from a corner's centre
|
||||||
|
fn outward(p: vec2<f32>, half: vec2<f32>, radius: f32) -> vec2<f32> {
|
||||||
|
let r = min(radius, min(half.x, half.y));
|
||||||
|
let q = abs(p) - half + vec2<f32>(r);
|
||||||
|
let side = select(vec2<f32>(-1.0), vec2<f32>(1.0), p >= vec2<f32>(0.0));
|
||||||
|
|
||||||
|
if q.x > 0.0 && q.y > 0.0 {
|
||||||
|
return normalize(q) * side;
|
||||||
|
}
|
||||||
|
|
||||||
|
return select(vec2<f32>(0.0, side.y), vec2<f32>(side.x, 0.0), q.x > q.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// premultiplied colours, one over the other
|
||||||
|
fn over(top: vec4<f32>, under: vec4<f32>) -> vec4<f32> {
|
||||||
|
return top + under * (1.0 - top.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// amane blends a plain colour over the window
|
||||||
|
fn plain(color: vec4<f32>) -> vec4<f32> {
|
||||||
|
if color.a <= 0.0 {
|
||||||
|
return vec4<f32>(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return vec4<f32>(color.rgb / color.a, color.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||||
|
let shape = values[0];
|
||||||
|
let extent = shape.xy;
|
||||||
|
let centre = extent / 2.0;
|
||||||
|
let lift = values[2].w;
|
||||||
|
let edge = max(values[3].x, 1.0);
|
||||||
|
let light = values[3].y * (1.0 + 0.35 * lift);
|
||||||
|
let rim = values[3].z * (1.0 + 0.35 * lift);
|
||||||
|
let fringe = values[3].w;
|
||||||
|
|
||||||
|
let layer = u32(values[7].x);
|
||||||
|
|
||||||
|
let p = uv * size - vec2<f32>(shape.w);
|
||||||
|
let pixel = max(fwidth(p.x), 1e-4);
|
||||||
|
let from_centre = p - centre;
|
||||||
|
|
||||||
|
let shade = select(shadow(from_centre - values[2].yz, extent.x, extent.y, shape.z, values[2].x) * values[1].a, 0.0, layer == 2u);
|
||||||
|
let under = vec4<f32>(values[1].rgb * shade, shade);
|
||||||
|
|
||||||
|
let d = rounded(from_centre, centre, shape.z);
|
||||||
|
let inside = clamp(0.5 - d / pixel, 0.0, 1.0);
|
||||||
|
|
||||||
|
if inside <= 0.0 {
|
||||||
|
return plain(under);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the tint, a gradient over the shape from its top left to its bottom right
|
||||||
|
let t = clamp(dot(p / max(extent, vec2<f32>(1.0)), vec2<f32>(0.5)), 0.0, 1.0);
|
||||||
|
let tint = select(mix(values[5], values[6], (t - 0.5) * 2.0), mix(values[4], values[5], t * 2.0), t < 0.5);
|
||||||
|
|
||||||
|
var glass = select(vec4<f32>(tint.rgb * tint.a, tint.a), vec4<f32>(0.0), layer == 2u);
|
||||||
|
|
||||||
|
if layer == 1u {
|
||||||
|
return plain(over(glass * inside, under));
|
||||||
|
}
|
||||||
|
|
||||||
|
// the lens: 1 on the rim, 0 deeper in than `edge`, steepest at the rim like a bevel
|
||||||
|
let band = pow(1.0 - smoothstep(0.0, edge, max(-d, 0.0)), 2.0);
|
||||||
|
|
||||||
|
// which way this part of the rim faces: toward the light (top left) is 1, away is -1
|
||||||
|
let facing = dot(outward(from_centre, centre, shape.z), vec2<f32>(-0.7071, -0.7071));
|
||||||
|
|
||||||
|
// a faint warm fringe toward the light and a cool one away from it
|
||||||
|
let hue = mix(vec3<f32>(0.75, 0.85, 1.0), vec3<f32>(1.0, 0.9, 0.78), facing * 0.5 + 0.5);
|
||||||
|
let tone = mix(vec3<f32>(1.0), hue, fringe);
|
||||||
|
|
||||||
|
// brightest where the light comes in, a weaker glint where it goes out, a little all round
|
||||||
|
let caught = clamp(light * band * (0.9 * max(facing, 0.0) + 0.45 * max(-facing, 0.0) + 0.12), 0.0, 1.0);
|
||||||
|
|
||||||
|
glass = over(vec4<f32>(tone * caught, caught), glass);
|
||||||
|
|
||||||
|
// the specular line on the rim itself, about 1.5 px wide
|
||||||
|
let line = clamp(clamp((d + 1.5) / pixel, 0.0, 1.0) * rim * (0.35 + 0.65 * abs(facing)), 0.0, 1.0);
|
||||||
|
|
||||||
|
glass = over(vec4<f32>(tone * line, line), glass);
|
||||||
|
|
||||||
|
return plain(over(glass * inside, under));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -52,6 +53,15 @@ pub enum Glyph {
|
|||||||
Snow,
|
Snow,
|
||||||
Storm,
|
Storm,
|
||||||
Fog,
|
Fog,
|
||||||
|
Thermometer,
|
||||||
|
Drive,
|
||||||
|
Memory,
|
||||||
|
Gpu,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash,
|
||||||
|
Cake,
|
||||||
|
Flag,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
|
pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
|
||||||
@@ -129,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![
|
||||||
@@ -307,6 +318,34 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
|
|||||||
line(0.5, 0.08, 0.5, 0.16),
|
line(0.5, 0.08, 0.5, 0.16),
|
||||||
],
|
],
|
||||||
Glyph::Cross => shapes![line(0.24, 0.24, 0.76, 0.76), line(0.76, 0.24, 0.24, 0.76)],
|
Glyph::Cross => shapes![line(0.24, 0.24, 0.76, 0.76), line(0.76, 0.24, 0.24, 0.76)],
|
||||||
|
// a tube with its bulb
|
||||||
|
Glyph::Thermometer => shapes![
|
||||||
|
Path::new().move_to(x(0.4), y(0.62)).line_to(x(0.4), y(0.16)).quad_to(x(0.4), y(0.06), x(0.5), y(0.06)).quad_to(x(0.6), y(0.06), x(0.6), y(0.16)).line_to(x(0.6), y(0.62)).stroke(thin, color),
|
||||||
|
Circle::new().center(x(0.5), y(0.76)).radius(0.17 * s).fill(color),
|
||||||
|
line(0.5, 0.3, 0.5, 0.7),
|
||||||
|
],
|
||||||
|
// a flat drive with its connector
|
||||||
|
Glyph::Drive => shapes![frame(0.08, 0.3, 0.92, 0.7).stroke(thin, color), line(0.2, 0.5, 0.2, 0.5), line(0.66, 0.5, 0.8, 0.5)],
|
||||||
|
// a memory stick: a board with chips and pins
|
||||||
|
Glyph::Memory => shapes![frame(0.06, 0.28, 0.94, 0.62).stroke(thin, color), frame(0.18, 0.38, 0.34, 0.52).fill(color), frame(0.42, 0.38, 0.58, 0.52).fill(color), frame(0.66, 0.38, 0.82, 0.52).fill(color), line(0.2, 0.62, 0.2, 0.76), line(0.4, 0.62, 0.4, 0.76), line(0.6, 0.62, 0.6, 0.76), line(0.8, 0.62, 0.8, 0.76)],
|
||||||
|
// a graphics card: a board with a fan
|
||||||
|
Glyph::Gpu => shapes![frame(0.06, 0.24, 0.94, 0.72).stroke(thin, color), Circle::new().center(x(0.4), y(0.48)).radius(0.14 * s).stroke(thin, color), line(0.66, 0.4, 0.8, 0.4), line(0.66, 0.56, 0.8, 0.56), line(0.16, 0.72, 0.16, 0.86)],
|
||||||
|
Glyph::Plus => shapes![line(0.5, 0.2, 0.5, 0.8), line(0.2, 0.5, 0.8, 0.5)],
|
||||||
|
Glyph::Pencil => shapes![
|
||||||
|
Path::new().move_to(x(0.2), y(0.8)).line_to(x(0.24), y(0.62)).line_to(x(0.66), y(0.2)).line_to(x(0.8), y(0.34)).line_to(x(0.38), y(0.76)).close().stroke(thin, color),
|
||||||
|
line(0.58, 0.28, 0.72, 0.42),
|
||||||
|
],
|
||||||
|
Glyph::Trash => shapes![
|
||||||
|
Path::new().move_to(x(0.24), y(0.3)).line_to(x(0.3), y(0.88)).line_to(x(0.7), y(0.88)).line_to(x(0.76), y(0.3)).stroke(thin, color),
|
||||||
|
line(0.14, 0.3, 0.86, 0.3),
|
||||||
|
line(0.4, 0.14, 0.6, 0.14),
|
||||||
|
line(0.44, 0.44, 0.44, 0.74),
|
||||||
|
line(0.56, 0.44, 0.56, 0.74),
|
||||||
|
],
|
||||||
|
// a cake with a candle, for birthdays
|
||||||
|
Glyph::Cake => shapes![frame(0.14, 0.5, 0.86, 0.86).fill(color), line(0.5, 0.26, 0.5, 0.44), Circle::new().center(x(0.5), y(0.16)).radius(0.07 * s).fill(color)],
|
||||||
|
// a flag on a pole, for holidays
|
||||||
|
Glyph::Flag => shapes![line(0.22, 0.1, 0.22, 0.9), Path::new().move_to(x(0.22), y(0.12)).line_to(x(0.82), y(0.12)).line_to(x(0.68), y(0.3)).line_to(x(0.82), y(0.48)).line_to(x(0.22), y(0.48)).close().fill(color)],
|
||||||
Glyph::Send => shapes![
|
Glyph::Send => shapes![
|
||||||
Path::new().move_to(x(0.1), y(0.14)).line_to(x(0.92), y(0.5)).line_to(x(0.1), y(0.86)).line_to(x(0.24), y(0.5)).close().fill(color)
|
Path::new().move_to(x(0.1), y(0.14)).line_to(x(0.92), y(0.5)).line_to(x(0.1), y(0.86)).line_to(x(0.24), y(0.5)).close().fill(color)
|
||||||
],
|
],
|
||||||
|
|||||||
+1550
-293
File diff suppressed because it is too large
Load Diff
+178
-116
@@ -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;
|
||||||
@@ -225,8 +225,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
.space(Zone::Ignore)
|
.space(Zone::Ignore)
|
||||||
.namespace("magic-island");
|
.namespace("magic-island");
|
||||||
|
|
||||||
// out of sight: empty and click-through (never hidden, see below), and nothing else read, so a playing player doesn't redraw it
|
/*
|
||||||
if slide <= -edge::hide_h() {
|
* out of sight: empty and click-through (never hidden, see below), and nothing else
|
||||||
|
* read, so a playing player doesn't redraw it. also while a side pill's card is open in
|
||||||
|
* the middle: the pods' window draws this pill in the row beside it meanwhile
|
||||||
|
*/
|
||||||
|
if slide <= -edge::hide_h() || crate::pods::holds_island(here) {
|
||||||
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,23 +249,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 +269,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 +279,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,7 +288,8 @@ 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 {
|
||||||
|
Some(player) if reveal > 0.0 => {
|
||||||
let scene = Scene {
|
let scene = Scene {
|
||||||
player,
|
player,
|
||||||
players: media.players(),
|
players: media.players(),
|
||||||
@@ -310,11 +300,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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, c::pill::width()), 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_face(c::pill::width()), width, height, radius, pill, &colors),
|
||||||
// mid-grow: the empty shape alone, the cheapest frames of the animation
|
// mid-grow: the empty shape alone, the cheapest frames of the animation
|
||||||
Rectangle::new().width(0.0).height(0.0)
|
_ => Rectangle::new().width(0.0).height(0.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
||||||
@@ -369,8 +360,74 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the island's pill as the side pills' carousel draws it while one of their cards is
|
||||||
|
* open in the middle (the island's own window leaves it to them meanwhile, see
|
||||||
|
* pods::holds_island). `small` shrinks it from the pill (0) to its least (1): the song's
|
||||||
|
* cover, or its live bars when there's no cover, or a note with nothing playing. the
|
||||||
|
* pill's face fades out over the first half, the small one in over the second
|
||||||
|
*/
|
||||||
|
pub fn pill_face(monitor: &str, small: f32) -> Rectangle {
|
||||||
|
let media = Media::read();
|
||||||
|
let island = Island::read();
|
||||||
|
let colors = theme::now().surfaces();
|
||||||
|
let art = Art::read();
|
||||||
|
let h = c::pill::height();
|
||||||
|
|
||||||
|
let player = shown(&media, island.selected.as_deref());
|
||||||
|
let small_w = small_width_for(player.is_some(), &art.path);
|
||||||
|
let width = mix(c::pill::width(), small_w, small);
|
||||||
|
|
||||||
|
let live = player.is_some() && focused_monitor().as_deref().is_none_or(|name| name == monitor);
|
||||||
|
let spectrum = live.then(Spectrum::read);
|
||||||
|
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
|
||||||
|
|
||||||
|
if !levels.is_empty() {
|
||||||
|
amane::request_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the pill's face fades out, then the small one in: they swap halfway, or with nothing
|
||||||
|
* playing as soon as the shape gets too narrow for the words, so these never vanish while seen
|
||||||
|
*/
|
||||||
|
let turn = if player.is_some() { 0.5 } else { ((c::pill::width() - idle_room()) / (c::pill::width() - small_w).max(1.0)).clamp(0.05, 0.5) };
|
||||||
|
|
||||||
|
let face = if small < turn {
|
||||||
|
let big = match player {
|
||||||
|
Some(player) => compact(player, playing(player, &island), &art.path, levels, width),
|
||||||
|
None => idle_face(width),
|
||||||
|
};
|
||||||
|
|
||||||
|
veiled(big, width, h, h / 2.0, 1.0 - small / turn, &colors)
|
||||||
|
} else {
|
||||||
|
let little: Box<dyn Widget> = match player {
|
||||||
|
Some(_) if !art.path.is_empty() => Box::new(cover(&art.path, h - 10.0, 1.5, false)),
|
||||||
|
Some(player) => Box::new(pill_levels(playing(player, &island), levels)),
|
||||||
|
None => Box::new(icon(Glyph::Note, IDLE_NOTE, theme::now().accent)),
|
||||||
|
};
|
||||||
|
|
||||||
|
veiled(Rectangle::new().width(width).height(h).align_child(Center, Center).child(Stack::new(vec![little])), width, h, h / 2.0, (small - turn) / (1.0 - turn), &colors)
|
||||||
|
};
|
||||||
|
|
||||||
|
surface::shell(width, h, h / 2.0, &colors, 0.0, face)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the island's pill at its least in the carousel: as wide as tall with a cover or nothing playing, else room for its bars
|
||||||
|
pub fn small_width() -> f32 {
|
||||||
|
let media = Media::read();
|
||||||
|
let island = Island::read();
|
||||||
|
|
||||||
|
small_width_for(shown(&media, island.selected.as_deref()).is_some(), &Art::read().path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn small_width_for(playing_something: bool, art: &str) -> f32 {
|
||||||
|
let h = c::pill::height();
|
||||||
|
|
||||||
|
if !playing_something || !art.is_empty() { h } else { (c::pill::bars_width() + 24.0).max(h) }
|
||||||
|
}
|
||||||
|
|
||||||
// a click on the island of `monitor`: closes an open dropdown, else opens the card there
|
// a click on the island of `monitor`: closes an open dropdown, else opens the card there
|
||||||
fn tap(monitor: &str) {
|
pub fn tap(monitor: &str) {
|
||||||
let mut island = Island::write();
|
let mut island = Island::write();
|
||||||
|
|
||||||
if island.menu.take().is_none() {
|
if island.menu.take().is_none() {
|
||||||
@@ -600,21 +657,36 @@ pub fn faded(color: Color, alpha: u8) -> Color {
|
|||||||
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
||||||
}
|
}
|
||||||
|
|
||||||
// the island while no player is open: a music note and "Nothing playing", on the pill's surface
|
/*
|
||||||
fn idle(colors: &[Color; 3]) -> Rectangle {
|
* a note and "Nothing playing", centred. the words only where they fit with half the
|
||||||
let content = Rectangle::new()
|
* pill's height to spare at each end, else the note alone: an elided text takes the whole
|
||||||
.width(c::pill::width())
|
* width, which put the note against the rim, and a pill narrower than the words did too
|
||||||
.height(c::pill::height())
|
*/
|
||||||
.align_child(Center, Center)
|
fn idle_face(width: f32) -> Rectangle {
|
||||||
.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));
|
let note = icon(Glyph::Note, IDLE_NOTE, theme::now().accent);
|
||||||
|
let row = if idle_room() <= width { Row::new(children![note, idle_words()]) } else { Row::new(children![note]) };
|
||||||
|
|
||||||
surface::shell(c::pill::width(), c::pill::height(), c::pill::height() / 2.0, colors, 0.0, content)
|
Rectangle::new().width(width).height(c::pill::height()).align_child(Center, Center).child(row.gap(8.0).align(Center))
|
||||||
}
|
}
|
||||||
|
|
||||||
// the pill: cover, title, the player's live levels
|
const IDLE_NOTE: f32 = 15.0;
|
||||||
fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle {
|
|
||||||
|
fn idle_words() -> Text {
|
||||||
|
Text::new("Nothing playing").size(text::body()).color(theme::now().dim)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the width the note and the words need, with half the pill's height to spare at each end
|
||||||
|
fn idle_room() -> f32 {
|
||||||
|
match idle_words().width() {
|
||||||
|
amane::Size::Fixed(words) => IDLE_NOTE + 8.0 + words + c::pill::height(),
|
||||||
|
amane::Size::Parent => f32::MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the pill, `width` wide: cover, title, the player's live levels
|
||||||
|
fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32], width: f32) -> Rectangle {
|
||||||
Rectangle::new()
|
Rectangle::new()
|
||||||
.width(c::pill::width())
|
.width(width)
|
||||||
.height(c::pill::height())
|
.height(c::pill::height())
|
||||||
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 })
|
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 })
|
||||||
.child(
|
.child(
|
||||||
@@ -683,46 +755,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 +1231,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 +1392,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 +1419,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 +1456,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))
|
||||||
|
}
|
||||||
+488
@@ -0,0 +1,488 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use amane::{Canvas, Center, Color, Column, Full, Image, LayerWindow, Lock, Media, MediaPlayer, Monitor, Pointer, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget, children, window_size};
|
||||||
|
|
||||||
|
use crate::art::Art;
|
||||||
|
use crate::config::appearance::{font, overlays, text};
|
||||||
|
use crate::config::lock::{look, show};
|
||||||
|
use crate::config::pills::layout;
|
||||||
|
use crate::glyph::{Glyph, glyph_shapes};
|
||||||
|
use crate::home::{self, Clock};
|
||||||
|
use crate::island::{self, CLEAR, Island, faded};
|
||||||
|
use crate::system::Power;
|
||||||
|
use crate::{control, edge, notices, parse, surface, theme};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the lock screen, amane's App::lock (ext-session-lock; pam checks the password the way the
|
||||||
|
* `login` service does): the wallpaper blurred under a veil; the date with the weather now,
|
||||||
|
* a big clock and hyprland's splash; the music with its controls, then chips for the next
|
||||||
|
* event, the new notifications and the battery; at the bottom the avatar, the name and the
|
||||||
|
* password. every monitor shows it, all sharing the one password field.
|
||||||
|
* `amane ipc call lock` locks. killing the shell doesn't unlock: hyprland keeps the session
|
||||||
|
* locked (README, Lock screen)
|
||||||
|
*
|
||||||
|
* it redraws for the music's progress, the clock and the password, so it's drawn cheaply:
|
||||||
|
* the backdrop is one shader (lock.wgsl) instead of a gradient vello paints over the whole
|
||||||
|
* screen on every frame, the glass is surface.rs's shader, and every icon goes into one
|
||||||
|
* canvas over it all, as each canvas is a vello pass of its own. so that where each icon
|
||||||
|
* lands is known, every part has a fixed size
|
||||||
|
*/
|
||||||
|
|
||||||
|
// what the lock screen looks up once per lock, before it shows
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Shown {
|
||||||
|
splash: String,
|
||||||
|
picture: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Shown {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// set by command() only
|
||||||
|
fn listen() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the password field's name; every monitor's lock screen shows the same one
|
||||||
|
const FIELD: &str = "lock-password";
|
||||||
|
|
||||||
|
static SHADER: LazyLock<PathBuf> = LazyLock::new(|| surface::shader_file("lock.wgsl", include_str!("lock.wgsl")));
|
||||||
|
|
||||||
|
// the icons of one frame, drawn together into one canvas over the screen
|
||||||
|
type Icons = Vec<Box<dyn Shape>>;
|
||||||
|
|
||||||
|
// a chip's parts, how wide they are, and the icon the canvas draws at their start
|
||||||
|
type Chip = (Vec<Box<dyn Widget>>, f32, Option<(Glyph, f32, Color)>);
|
||||||
|
|
||||||
|
// `amane ipc call lock`: the splash and the picture are looked up first, so the screen shows them from its first frame
|
||||||
|
pub fn command(words: &[String]) -> String {
|
||||||
|
if !words.is_empty() {
|
||||||
|
return String::from("usage: lock");
|
||||||
|
}
|
||||||
|
|
||||||
|
let splash = edge::hyprctl("splash").trim().to_string();
|
||||||
|
|
||||||
|
*Shown::write() = Shown { splash, picture: picture() };
|
||||||
|
|
||||||
|
Lock::start();
|
||||||
|
|
||||||
|
String::from("locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// the picture: the setting's, else the wallpaper matugen named in the theme file; only a file that's there
|
||||||
|
fn picture() -> Option<String> {
|
||||||
|
let set = look::wallpaper();
|
||||||
|
|
||||||
|
let wanted = if set.trim().is_empty() {
|
||||||
|
let theme = fs::read_to_string(theme::config_file("theme")).unwrap_or_default();
|
||||||
|
|
||||||
|
parse::settings(&theme).into_iter().find(|(key, _)| key == "wallpaper").map(|(_, value)| value)?
|
||||||
|
} else {
|
||||||
|
set
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = home::expanded(wanted.trim());
|
||||||
|
|
||||||
|
path.is_file().then(|| path.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(_: &Monitor) -> LayerWindow {
|
||||||
|
theme::begin();
|
||||||
|
|
||||||
|
// a lock screen always covers its whole monitor, whatever size it asks for
|
||||||
|
let window = LayerWindow::new().width(1.0).height(1.0);
|
||||||
|
let (width, height) = window_size();
|
||||||
|
|
||||||
|
// the compositor hasn't said the size yet
|
||||||
|
if width <= 0.0 || height <= 0.0 {
|
||||||
|
return window.child(Rectangle::new().width(0.0).height(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.child(screen(width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
// about how wide words are at `size`, as the chips size themselves
|
||||||
|
fn words_width(words: &str, size: f32) -> f32 {
|
||||||
|
words.chars().count() as f32 * size * font::letter_width()
|
||||||
|
}
|
||||||
|
|
||||||
|
// words in a box `width` wide, from its left, elided past it
|
||||||
|
fn words(words: &str, size: f32, color: Color, bold: bool, width: f32) -> Rectangle {
|
||||||
|
let text = Text::new(words).size(size).color(color).elide();
|
||||||
|
let text = if bold { text.weight(Weight::Bold) } else { text };
|
||||||
|
|
||||||
|
Rectangle::new().width(width).height(size * 1.4).align_child(Start, Center).child(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty room `width` wide, where an icon of the canvas goes or just space
|
||||||
|
fn room(width: f32) -> Rectangle {
|
||||||
|
Rectangle::new().width(width).height(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `glyph` centred on (x, y)
|
||||||
|
fn place(icons: &mut Icons, glyph: Glyph, size: f32, color: Color, (x, y): (f32, f32)) {
|
||||||
|
icons.extend(glyph_shapes(glyph, size, color, 0.0, (x - size / 2.0, y - size / 2.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen(width: f32, height: f32) -> Stack {
|
||||||
|
let clock = Clock::read();
|
||||||
|
let shown = Shown::read();
|
||||||
|
let gap = look::gap();
|
||||||
|
|
||||||
|
let mut icons: Icons = Vec::new();
|
||||||
|
|
||||||
|
// the top part downward from look::top, the bottom part upward from look::bottom
|
||||||
|
let upper_y = look::top() + gap;
|
||||||
|
let (upper, upper_h) = upper(&clock, &shown, width, upper_y, &mut icons);
|
||||||
|
let middle = middle(clock.now(), width, upper_y + upper_h + gap, &mut icons);
|
||||||
|
|
||||||
|
let lower_y = height - look::bottom() - lower_height();
|
||||||
|
let lower = lower(&clock, width, lower_y, &mut icons);
|
||||||
|
|
||||||
|
let top = Column::new(children![Rectangle::new().width(1.0).height(look::top()), upper, middle]).gap(gap).align(Center);
|
||||||
|
let bottom = Column::new(children![lower, Rectangle::new().width(1.0).height(look::bottom())]).align(Center);
|
||||||
|
|
||||||
|
let content = Column::new(children![top, bottom]).width(width).height(height).justify(SpaceBetween).align(Center);
|
||||||
|
|
||||||
|
let mut layers = backdrop(width, height, shown.picture.as_deref());
|
||||||
|
|
||||||
|
layers.push(Box::new(content));
|
||||||
|
|
||||||
|
if !icons.is_empty() {
|
||||||
|
layers.push(Box::new(Canvas::new().width(width).height(height).shapes(icons)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stack::new(layers).width(width).height(height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the wallpaper colours' gradient, the picture blurred over it, and a veil over that darker at the edges: lock.wgsl
|
||||||
|
fn backdrop(width: f32, height: f32, picture: Option<&str>) -> Vec<Box<dyn Widget>> {
|
||||||
|
let [high, middle, low] = theme::now().surfaces();
|
||||||
|
|
||||||
|
let shade = |layer: f32| {
|
||||||
|
let values = vec![[width, height, 160.0, layer], surface::plain(high), surface::plain(middle), surface::plain(low), [look::dim(), look::vignette(), 0.0, 0.0]];
|
||||||
|
|
||||||
|
Box::new(Rectangle::new().width(width).height(height).shader(SHADER.as_path()).shader_values(values)) as Box<dyn Widget>
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(path) = picture else {
|
||||||
|
return vec![shade(0.0)];
|
||||||
|
};
|
||||||
|
|
||||||
|
let blur = look::blur() as u32;
|
||||||
|
|
||||||
|
// blurred: a small copy, blurred once when decoded and stretched over the screen, costs nothing per frame
|
||||||
|
let image = if blur == 0 {
|
||||||
|
Image::cover(path).thumbnail(width.ceil() as u32, height.ceil() as u32)
|
||||||
|
} else {
|
||||||
|
let copy = look::copy_width() as u32;
|
||||||
|
|
||||||
|
Image::cover(path).thumbnail(copy, (copy as f32 * height / width).ceil() as u32).blurred(blur)
|
||||||
|
};
|
||||||
|
|
||||||
|
// the gradient shows while the picture decodes
|
||||||
|
vec![shade(1.0), Box::new(Rectangle::new().width(width).height(height).fill(image)), shade(2.0)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// the date with the weather now, the clock, hyprland's splash, from `y` down; and how tall
|
||||||
|
fn upper(clock: &Clock, shown: &Shown, width: f32, y: f32, icons: &mut Icons) -> (Rectangle, f32) {
|
||||||
|
let theme = theme::now();
|
||||||
|
let size = look::date_size();
|
||||||
|
let now = clock.now();
|
||||||
|
let inner = look::gap() * 0.4;
|
||||||
|
|
||||||
|
// the date, and the weather's icon and words after it
|
||||||
|
let date = if now > 0 { home::date_words(now) } else { String::new() };
|
||||||
|
let date_w = words_width(&date, size);
|
||||||
|
let date_h = size * 1.4;
|
||||||
|
|
||||||
|
let mut row: Vec<Box<dyn Widget>> = vec![Box::new(words(&date, size, theme.text, true, date_w))];
|
||||||
|
let mut row_w = date_w;
|
||||||
|
|
||||||
|
if show::weather()
|
||||||
|
&& let Some((glyph, said)) = home::weather_now()
|
||||||
|
{
|
||||||
|
let (space, icon, after) = (size * 0.6, size * 1.15, size * 0.3);
|
||||||
|
let said_w = words_width(&said, size);
|
||||||
|
let total = date_w + space + icon + after + said_w;
|
||||||
|
|
||||||
|
place(icons, glyph, icon, theme.text, ((width - total) / 2.0 + date_w + space + icon / 2.0, y + date_h / 2.0));
|
||||||
|
|
||||||
|
row.extend(children![room(space), room(icon), room(after), words(&said, size, theme.text, false, said_w)]);
|
||||||
|
row_w = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seconds = now.rem_euclid(86_400);
|
||||||
|
let time = if now > 0 { format!("{:02}:{:02}", seconds / 3_600, seconds % 3_600 / 60) } else { String::new() };
|
||||||
|
|
||||||
|
let digits = Text::new(time).size(look::clock_size()).weight(look::clock_weight().min(1000) as u16).color(theme.text).tight();
|
||||||
|
let family = look::clock_font();
|
||||||
|
let digits = if family.trim().is_empty() { digits } else { digits.font(family.trim()) };
|
||||||
|
let clock_h = digits.height_in(width);
|
||||||
|
|
||||||
|
let mut parts: Vec<Box<dyn Widget>> = vec![
|
||||||
|
Box::new(Rectangle::new().width(row_w).height(date_h).child(Row::new(row).align(Center))),
|
||||||
|
Box::new(Rectangle::new().width(width).height(clock_h).align_child(Center, Center).child(digits)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut height = date_h + inner + clock_h;
|
||||||
|
|
||||||
|
if show::splash() && !shown.splash.is_empty() {
|
||||||
|
let size = size * 0.7;
|
||||||
|
let said = format!("“{}”", shown.splash);
|
||||||
|
let said_w = (words_width(&said, size) + size).min(width * 0.7);
|
||||||
|
|
||||||
|
parts.push(Box::new(words(&said, size, theme.dim, false, said_w)));
|
||||||
|
height += inner + size * 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
(Rectangle::new().width(width).height(height).align_child(Center, Start).child(Column::new(parts).gap(inner).align(Center)), height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the music, then the chips, from `y` down
|
||||||
|
fn middle(now: i64, width: f32, y: f32, icons: &mut Icons) -> Column {
|
||||||
|
let gap = look::gap() * 0.75;
|
||||||
|
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
let mut y = y;
|
||||||
|
|
||||||
|
if show::music()
|
||||||
|
&& let Some(card) = music(width, y, icons)
|
||||||
|
{
|
||||||
|
parts.push(Box::new(card));
|
||||||
|
y += look::card_height() + gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(chips) = chips(now, width, y, icons) {
|
||||||
|
parts.push(Box::new(chips));
|
||||||
|
}
|
||||||
|
|
||||||
|
Column::new(parts).gap(gap).align(Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the shown player's song at `y`: its cover, title, artist and how far it is, and its buttons
|
||||||
|
fn music(width: f32, y: f32, icons: &mut Icons) -> Option<Rectangle> {
|
||||||
|
let media = Media::read();
|
||||||
|
let island = Island::read();
|
||||||
|
let art = Art::read();
|
||||||
|
let theme = theme::now();
|
||||||
|
|
||||||
|
let player = island::shown(&media, island.selected.as_deref())?;
|
||||||
|
|
||||||
|
let (card_w, card_h) = (look::card_width(), look::card_height());
|
||||||
|
let gap = look::gap() * 0.75;
|
||||||
|
let cover = look::cover().min(card_h - 8.0);
|
||||||
|
let pad = (card_h - cover) / 2.0;
|
||||||
|
|
||||||
|
// where the card's row starts, and the height every part of it is centred on
|
||||||
|
let left = (width - card_w) / 2.0 + pad;
|
||||||
|
let middle = y + card_h / 2.0;
|
||||||
|
|
||||||
|
let picture = Rectangle::new().width(cover).height(cover).radius(cover * 0.18);
|
||||||
|
|
||||||
|
let picture = if art.path.is_empty() {
|
||||||
|
place(icons, Glyph::Note, cover * 0.45, theme.accent, (left + cover / 2.0, middle));
|
||||||
|
|
||||||
|
picture.fill(theme.accent_soft)
|
||||||
|
} else {
|
||||||
|
let pixels = (cover * 2.0).ceil() as u32;
|
||||||
|
|
||||||
|
picture.fill(Image::cover(art.path.clone()).thumbnail(pixels, pixels))
|
||||||
|
};
|
||||||
|
|
||||||
|
let (small, big) = (text::title() * 1.9, text::title() * 1.9 * 1.25);
|
||||||
|
let buttons_w = if show::controls() { 2.0 * small + big + 8.0 + gap } else { 0.0 };
|
||||||
|
let words_w = (card_w - 2.0 * pad - cover - gap - buttons_w).max(0.0);
|
||||||
|
|
||||||
|
let title = if player.title().is_empty() { player.identity() } else { player.title() };
|
||||||
|
|
||||||
|
let length = player.length().as_secs_f32();
|
||||||
|
let done = if length > 0.0 { (player.position().as_secs_f32() / length).clamp(0.0, 1.0) } else { 0.0 };
|
||||||
|
|
||||||
|
let track = Rectangle::new().width(words_w).height(4.0).radius(Full).fill(overlays::track()).align_child(Start, Center).child(Rectangle::new().width((words_w * done).max(4.0)).height(4.0).radius(Full).fill(theme.text));
|
||||||
|
|
||||||
|
let said = Column::new(children![words(title, text::title(), theme.text, true, words_w), words(player.artist(), text::body(), theme.dim, false, words_w), track]).gap(6.0);
|
||||||
|
|
||||||
|
let mut items: Vec<Box<dyn Widget>> = vec![Box::new(picture), Box::new(said)];
|
||||||
|
|
||||||
|
if show::controls() {
|
||||||
|
items.push(Box::new(controls(player, (left + cover + gap + words_w + gap, middle), small, big, icons)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = Rectangle::new().width(card_w).height(card_h).padding(pad).align_child(Start, Center).child(Row::new(items).gap(gap).align(Center));
|
||||||
|
|
||||||
|
Some(surface::shell(card_w, card_h, layout::radius().min(card_h / 2.0), &theme.surfaces(), 0.0, content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// previous, play or pause, next, from (x, the middle's y), their icons into the canvas
|
||||||
|
fn controls(player: &MediaPlayer, (x, y): (f32, f32), small: f32, big: f32, icons: &mut Icons) -> Row {
|
||||||
|
let theme = theme::now();
|
||||||
|
|
||||||
|
let middle = if player.playing() { Glyph::Pause } else { Glyph::Play };
|
||||||
|
|
||||||
|
for (glyph, size, centre) in [(Glyph::Previous, small, x + small / 2.0), (middle, big, x + small + 4.0 + big / 2.0), (Glyph::Next, small, x + small + 4.0 + big + 4.0 + small / 2.0)] {
|
||||||
|
place(icons, glyph, size * 0.42, theme.text, (centre, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
let button = |size: f32, fill: Color, pressed: Box<dyn Fn()>| Rectangle::new().width(size).height(size).radius(Full).fill(fill).cursor(Pointer).on_click(move |_| pressed());
|
||||||
|
|
||||||
|
let (previous, toggle, next) = (player.clone(), player.clone(), player.clone());
|
||||||
|
|
||||||
|
Row::new(children![
|
||||||
|
button(small, CLEAR, Box::new(move || previous.previous())),
|
||||||
|
button(big, overlays::chip(), Box::new(move || toggle.play_pause())),
|
||||||
|
button(small, CLEAR, Box::new(move || next.next())),
|
||||||
|
])
|
||||||
|
.gap(4.0)
|
||||||
|
.align(Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a glass chip around `parts`, which are `inner` wide
|
||||||
|
fn chip(parts: Vec<Box<dyn Widget>>, inner: f32) -> Rectangle {
|
||||||
|
let height = look::chip_height();
|
||||||
|
let width = inner + height;
|
||||||
|
|
||||||
|
let content = Rectangle::new().width(width).height(height).align_child(Center, Center).child(Row::new(parts).gap(8.0).align(Center));
|
||||||
|
|
||||||
|
surface::shell(width, height, height / 2.0, &theme::now().surfaces(), 0.0, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the next event, the new notifications, the battery at `y`: each its own chip, when there is one
|
||||||
|
fn chips(now: i64, width: f32, y: f32, icons: &mut Icons) -> Option<Row> {
|
||||||
|
let theme = theme::now();
|
||||||
|
let (size, height) = (text::body(), look::chip_height());
|
||||||
|
let icon = size * 1.3;
|
||||||
|
let spacing = look::gap() * 0.6;
|
||||||
|
|
||||||
|
// each chip's parts and inner width, and an icon at the start of its parts
|
||||||
|
let mut wanted: Vec<Chip> = Vec::new();
|
||||||
|
|
||||||
|
if show::event()
|
||||||
|
&& let Some((when, what, color)) = home::next_event(now)
|
||||||
|
{
|
||||||
|
let said = format!("{when} · {what}");
|
||||||
|
let said_w = words_width(&said, size).min(look::card_width() * 0.8);
|
||||||
|
let dot = Rectangle::new().width(9.0).height(9.0).radius(Full).fill(color.unwrap_or(theme.accent));
|
||||||
|
|
||||||
|
wanted.push((children![dot, words(&said, size, theme.text, false, said_w)], 9.0 + 8.0 + said_w, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
let unread = notices::unread();
|
||||||
|
|
||||||
|
if show::notifications() && unread > 0 {
|
||||||
|
let said = if unread == 1 { String::from("1 new notification") } else { format!("{unread} new notifications") };
|
||||||
|
let said_w = words_width(&said, size).min(look::card_width());
|
||||||
|
|
||||||
|
wanted.push((children![room(icon), words(&said, size, theme.text, false, said_w)], icon + 8.0 + said_w, Some((Glyph::Bell, icon, theme.text))));
|
||||||
|
}
|
||||||
|
|
||||||
|
if show::battery()
|
||||||
|
&& let Some(charge) = &Power::read().battery
|
||||||
|
{
|
||||||
|
let bolt = (charge.status == "Charging").then_some((Glyph::Bolt, 11.0, theme.accent));
|
||||||
|
|
||||||
|
wanted.push((children![control::battery_with(charge, false)], control::battery_width(charge), bolt));
|
||||||
|
}
|
||||||
|
|
||||||
|
if wanted.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: f32 = wanted.iter().map(|(_, inner, _)| inner + height).sum::<f32>() + spacing * (wanted.len() - 1) as f32;
|
||||||
|
let mut x = (width - total) / 2.0;
|
||||||
|
|
||||||
|
let mut row: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
for (parts, inner, icon) in wanted {
|
||||||
|
// a chip's parts start half its height in
|
||||||
|
if let Some((glyph, size, color)) = icon {
|
||||||
|
place(icons, glyph, size, color, (x + height / 2.0 + size / 2.0, y + height / 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
row.push(Box::new(chip(parts, inner)));
|
||||||
|
x += inner + height + spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Row::new(row).gap(spacing).align(Center))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name_height() -> f32 {
|
||||||
|
text::title() * 1.4
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_height() -> f32 {
|
||||||
|
text::body() * 1.6
|
||||||
|
}
|
||||||
|
|
||||||
|
// how tall the avatar, the name, the password and the note are together
|
||||||
|
fn lower_height() -> f32 {
|
||||||
|
let gap = look::gap() * 0.6;
|
||||||
|
|
||||||
|
look::avatar_size() + gap + name_height() + gap + look::field_height() + gap + note_height()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the avatar, the name, the password and what became of the last one, from `y` down
|
||||||
|
fn lower(clock: &Clock, width: f32, y: f32, icons: &mut Icons) -> Column {
|
||||||
|
let theme = theme::now();
|
||||||
|
let lock = Lock::read();
|
||||||
|
let gap = look::gap() * 0.6;
|
||||||
|
let size = look::avatar_size();
|
||||||
|
|
||||||
|
let avatar = Rectangle::new().width(size).height(size).radius(Full).border(2.0, faded(theme.text, 90));
|
||||||
|
|
||||||
|
let avatar = match clock.avatar() {
|
||||||
|
Some(path) => {
|
||||||
|
let pixels = (size * 2.0).ceil() as u32;
|
||||||
|
|
||||||
|
avatar.fill(Image::cover(path).thumbnail(pixels, pixels))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
place(icons, Glyph::Person, size * 0.54, theme.accent, (width / 2.0, y + size / 2.0));
|
||||||
|
|
||||||
|
avatar.fill(faded(theme.accent, 60))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = Rectangle::new().width(width).height(name_height()).align_child(Center, Center).child(Text::new(home::name()).size(text::title()).weight(Weight::Bold).color(theme.text));
|
||||||
|
|
||||||
|
let (field_w, field_h) = (look::field_width(), look::field_height());
|
||||||
|
let letters = text::popup();
|
||||||
|
let icon = letters * 1.1;
|
||||||
|
let start = field_h * 0.45;
|
||||||
|
|
||||||
|
let field_y = y + size + gap + name_height() + gap;
|
||||||
|
|
||||||
|
place(icons, Glyph::Lock, icon, theme.dim, ((width - field_w) / 2.0 + start + icon / 2.0, field_y + field_h / 2.0));
|
||||||
|
|
||||||
|
let input = TextInput::new(FIELD).password().focused().placeholder("Enter password").size(letters).color(theme.text).on_submit(submit);
|
||||||
|
|
||||||
|
// the lock icon's room, then the input 10 px after it
|
||||||
|
let inside = Row::new(children![room(start), room(icon), room(10.0), Rectangle::new().width(field_w - 2.0 * start - icon - 10.0).height(field_h).align_child(Start, Center).child(input)]).align(Center);
|
||||||
|
|
||||||
|
// a wrong password lights the field's rim
|
||||||
|
let lift = if lock.failed() { 1.0 } else { 0.0 };
|
||||||
|
let field = surface::shell(field_w, field_h, field_h / 2.0, &theme.surfaces(), lift, Rectangle::new().width(field_w).height(field_h).align_child(Start, Center).child(inside));
|
||||||
|
|
||||||
|
let (note, color) = if lock.checking() {
|
||||||
|
("Checking…", theme.dim)
|
||||||
|
} else if lock.failed() {
|
||||||
|
("Wrong password, try again", look::wrong_color())
|
||||||
|
} else {
|
||||||
|
("", theme.dim)
|
||||||
|
};
|
||||||
|
|
||||||
|
let note = Rectangle::new().width(field_w).height(note_height()).align_child(Center, Center).child(Text::new(note).size(text::body()).color(color));
|
||||||
|
|
||||||
|
Column::new(children![avatar, name, field, note]).gap(gap).align(Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter: the field empties at once, pam gets the password on its own thread
|
||||||
|
fn submit(password: String) {
|
||||||
|
TextInput::set_text(FIELD, "");
|
||||||
|
|
||||||
|
if !password.is_empty() {
|
||||||
|
Lock::unlock(&password);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// the lock screen's backdrop in one pass: the wallpaper colours' gradient, and over the picture
|
||||||
|
// a veil that darkens it, more toward the edges. drawn as amane's gradient it was a vello pass
|
||||||
|
// over the whole screen on every frame (vello keeps no gradient between frames). lock.rs fills
|
||||||
|
// the values:
|
||||||
|
// 0: width, height, the gradient's angle in degrees (0 runs bottom to top, 90 left to right), layer
|
||||||
|
// 1-3: the gradient's colours at 0, 0.55 and 1, plain
|
||||||
|
// 4: the veil's darkness and the vignette's strength, 0 to 1
|
||||||
|
// layer 1 is the gradient (under the picture), 2 the veil (over it), 0 both (no picture)
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||||
|
let extent = values[0].xy;
|
||||||
|
let angle = radians(values[0].z);
|
||||||
|
let layer = u32(values[0].w);
|
||||||
|
|
||||||
|
// where this pixel lies along the gradient, 0 at its start corner and 1 at its end
|
||||||
|
let direction = vec2<f32>(sin(angle), -cos(angle));
|
||||||
|
let reach = abs(direction.x) * extent.x + abs(direction.y) * extent.y;
|
||||||
|
let t = clamp(dot((uv - 0.5) * extent, direction) / max(reach, 1.0) + 0.5, 0.0, 1.0);
|
||||||
|
|
||||||
|
let gradient = select(mix(values[2], values[3], (t - 0.55) / 0.45), mix(values[1], values[2], t / 0.55), t < 0.55);
|
||||||
|
|
||||||
|
// the veil, darker toward the corners: 0 in the middle, 1 in a corner
|
||||||
|
let corner = length((uv - 0.5) * 2.0) / 1.4142;
|
||||||
|
let dark = clamp(values[4].x + (1.0 - values[4].x) * values[4].y * smoothstep(0.3, 1.0, corner), 0.0, 1.0);
|
||||||
|
|
||||||
|
if layer == 1u {
|
||||||
|
return vec4<f32>(gradient.rgb, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if layer == 2u {
|
||||||
|
return vec4<f32>(0.0, 0.0, 0.0, dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
return vec4<f32>(gradient.rgb * (1.0 - dark), 1.0);
|
||||||
|
}
|
||||||
+7
-3
@@ -7,6 +7,8 @@ mod glyph;
|
|||||||
mod home;
|
mod home;
|
||||||
mod hypr;
|
mod hypr;
|
||||||
mod island;
|
mod island;
|
||||||
|
mod last;
|
||||||
|
mod lock;
|
||||||
mod map;
|
mod map;
|
||||||
mod notices;
|
mod notices;
|
||||||
mod parse;
|
mod parse;
|
||||||
@@ -43,12 +45,14 @@ fn main() {
|
|||||||
.ipc("tray", tray::command)
|
.ipc("tray", tray::command)
|
||||||
.ipc("config", settings::command)
|
.ipc("config", settings::command)
|
||||||
.ipc("pods", pods::command)
|
.ipc("pods", pods::command)
|
||||||
|
.ipc("lock", lock::command)
|
||||||
|
.lock(lock::view)
|
||||||
.window_per_monitor(edge::spacer)
|
.window_per_monitor(edge::spacer)
|
||||||
.window_per_monitor(edge::view)
|
.window_per_monitor(edge::view)
|
||||||
.window_per_monitor(island::view)
|
.window_per_monitor(island::view)
|
||||||
// the small pills beside the island, and their cards
|
// the small pills beside the island, and their cards in the middle
|
||||||
.window_per_monitor(pods::left)
|
.window_per_monitor(pods::row_view)
|
||||||
.window_per_monitor(pods::right)
|
.window_per_monitor(pods::card_view)
|
||||||
.window_per_monitor(workspaces::view)
|
.window_per_monitor(workspaces::view)
|
||||||
.window_per_monitor(control::view)
|
.window_per_monitor(control::view)
|
||||||
.window_per_monitor(control::menu_view)
|
.window_per_monitor(control::menu_view)
|
||||||
|
|||||||
+105
-11
@@ -11,7 +11,7 @@ use amane::{
|
|||||||
use crate::config::appearance::{overlays, text};
|
use crate::config::appearance::{overlays, text};
|
||||||
use crate::config::control::pill as control_pill;
|
use crate::config::control::pill as control_pill;
|
||||||
use crate::config::island::pill as island_pill;
|
use crate::config::island::pill as island_pill;
|
||||||
use crate::config::notifications::popups as settings;
|
use crate::config::notifications::{apps, popups as settings};
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
use crate::island::{self, faded};
|
use crate::island::{self, faded};
|
||||||
use crate::parse::{self, Value};
|
use crate::parse::{self, Value};
|
||||||
@@ -72,8 +72,11 @@ fn timeout(urgency: Urgency) -> Option<Duration> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn watch() {
|
fn watch() {
|
||||||
let current: Vec<(u32, SystemTime, Urgency, String, String)> =
|
let current: Vec<(u32, SystemTime, Urgency, String, String, String)> = Notifications::read()
|
||||||
Notifications::read().list().iter().map(|notice| (notice.id(), notice.received(), notice.urgency(), notice.icon().to_string(), notice.image().to_string())).collect();
|
.list()
|
||||||
|
.iter()
|
||||||
|
.map(|notice| (notice.id(), notice.received(), notice.urgency(), notice.icon().to_string(), notice.image().to_string(), notice.app_name().to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let (arrived, gone, expired) = {
|
let (arrived, gone, expired) = {
|
||||||
let notices = Notices::read();
|
let notices = Notices::read();
|
||||||
@@ -99,25 +102,34 @@ fn watch() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a blocked app's notifications are dropped at once; a muted one's stay in the history without popping up
|
||||||
|
let (blocked, muted) = (apps::blocked(), apps::muted());
|
||||||
|
let (dropped, arrived): (Vec<_>, Vec<_>) = arrived.into_iter().partition(|(.., app)| listed(&blocked, app));
|
||||||
|
|
||||||
|
for (id, ..) in &dropped {
|
||||||
|
Notifications::dismiss(*id);
|
||||||
|
}
|
||||||
|
|
||||||
// icon lookups walk the themes: here, not under the lock and never in a view
|
// icon lookups walk the themes: here, not under the lock and never in a view
|
||||||
let pictures: Vec<(u32, Option<String>)> = arrived.iter().map(|(id, _, _, icon, image)| (*id, picture(icon, image))).collect();
|
let pictures: Vec<(u32, Option<String>)> = arrived.iter().map(|(id, _, _, icon, image, _)| (*id, picture(icon, image))).collect();
|
||||||
|
|
||||||
let looking = pods::open_card() == Some(pods::Pod::Notifications);
|
let looking = pods::open_card() == Some(pods::Pod::Notifications);
|
||||||
let popping = settings::enabled();
|
let popping = settings::enabled();
|
||||||
|
|
||||||
let mut notices = Notices::write();
|
let mut notices = Notices::write();
|
||||||
|
|
||||||
for (id, received, ..) in &arrived {
|
for (id, received, .., app) in &arrived {
|
||||||
let fresh = !notices.known.contains_key(id);
|
let fresh = !notices.known.contains_key(id);
|
||||||
|
let quiet = listed(&muted, app);
|
||||||
|
|
||||||
notices.known.insert(*id, *received);
|
notices.known.insert(*id, *received);
|
||||||
notices.popups.retain(|(shown, _)| shown != id);
|
notices.popups.retain(|(shown, _)| shown != id);
|
||||||
|
|
||||||
if popping {
|
if popping && !quiet {
|
||||||
notices.popups.insert(0, (*id, Instant::now()));
|
notices.popups.insert(0, (*id, Instant::now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if fresh && !looking {
|
if fresh && !looking && !quiet {
|
||||||
notices.unread += 1;
|
notices.unread += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +151,46 @@ fn watch() {
|
|||||||
notices.unread = notices.unread.min(notices.known.len());
|
notices.unread = notices.unread.min(notices.known.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// whether `app` is in a comma-separated list of app names, in any case
|
||||||
|
fn listed(list: &str, app: &str) -> bool {
|
||||||
|
!app.is_empty() && list.split(',').any(|name| name.trim().eq_ignore_ascii_case(app))
|
||||||
|
}
|
||||||
|
|
||||||
|
// puts an app into the list at `path` in config.toml, or takes it out
|
||||||
|
fn relist(path: &str, list: String, app: &str, on: bool) {
|
||||||
|
let mut names: Vec<&str> = list.split(',').map(str::trim).filter(|name| !name.is_empty() && !name.eq_ignore_ascii_case(app)).collect();
|
||||||
|
|
||||||
|
if on {
|
||||||
|
names.push(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = config::set(path, Value::Text(names.join(", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mute app: its notifications stop popping up (its popups now go too) but stay in the history
|
||||||
|
fn mute(app: &str, on: bool) {
|
||||||
|
relist("notifications.apps.muted", apps::muted(), app, on);
|
||||||
|
|
||||||
|
if on {
|
||||||
|
hide_app(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block app: its notifications are dropped from now on
|
||||||
|
fn block(app: &str, on: bool) {
|
||||||
|
relist("notifications.apps.blocked", apps::blocked(), app, on);
|
||||||
|
|
||||||
|
if on {
|
||||||
|
hide_app(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_app(app: &str) {
|
||||||
|
let ids: Vec<u32> = Notifications::read().list().iter().filter(|notice| notice.app_name().eq_ignore_ascii_case(app)).map(Notification::id).collect();
|
||||||
|
|
||||||
|
Notices::write().popups.retain(|(shown, _)| !ids.contains(shown));
|
||||||
|
}
|
||||||
|
|
||||||
// a notification's picture as a file: its image (a path or file:// url), else its icon (a path or a theme name)
|
// a notification's picture as a file: its image (a path or file:// url), else its icon (a path or a theme name)
|
||||||
fn picture(icon: &str, image: &str) -> Option<String> {
|
fn picture(icon: &str, image: &str) -> Option<String> {
|
||||||
for wanted in [image, icon] {
|
for wanted in [image, icon] {
|
||||||
@@ -394,14 +446,29 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
|||||||
let list = Notifications::read();
|
let list = Notifications::read();
|
||||||
let theme = theme::now();
|
let theme = theme::now();
|
||||||
|
|
||||||
let mut buttons: Vec<Box<dyn Widget>> = vec![Box::new(pods::chip(if settings::enabled() { "Mute" } else { "Unmute" }, None, toggle_quiet))];
|
let mut buttons: Vec<Box<dyn Widget>> = vec![Box::new(pods::chip(if settings::enabled() { "Do not disturb" } else { "Popups on" }, None, toggle_quiet))];
|
||||||
|
|
||||||
if !list.list().is_empty() {
|
if !list.list().is_empty() {
|
||||||
buttons.push(Box::new(pods::chip("Clear all", None, Notifications::clear)));
|
buttons.push(Box::new(pods::chip("Clear all", None, Notifications::clear)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let header = pods::header("Notifications", buttons, width);
|
let header = pods::header("Notifications", buttons, width);
|
||||||
let list_h = height - pods::header_height() - 10.0;
|
|
||||||
|
// the muted and blocked apps, each a chip that takes it off its list again
|
||||||
|
let (muted, blocked) = (apps::muted(), apps::blocked());
|
||||||
|
let names = |list: &str| list.split(',').map(str::trim).filter(|name| !name.is_empty()).map(str::to_string).collect::<Vec<_>>();
|
||||||
|
let mut undo: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
for app in names(&muted) {
|
||||||
|
undo.push(Box::new(pods::chip(&format!("Unmute {app}"), Some(Glyph::Muted), move || mute(&app, false))));
|
||||||
|
}
|
||||||
|
|
||||||
|
for app in names(&blocked) {
|
||||||
|
undo.push(Box::new(pods::chip(&format!("Unblock {app}"), Some(Glyph::Cross), move || block(&app, false))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let undo_h = if undo.is_empty() { 0.0 } else { 26.0 + 10.0 };
|
||||||
|
let list_h = height - pods::header_height() - 10.0 - undo_h;
|
||||||
|
|
||||||
let body: Box<dyn Widget> = if !list.running() {
|
let body: Box<dyn Widget> = if !list.running() {
|
||||||
Box::new(
|
Box::new(
|
||||||
@@ -430,7 +497,25 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
|||||||
let (content, inner_h) = face(notice, notices.pictures.get(&id), item_w - 24.0, 4, Box::new(move || Notifications::dismiss(id)));
|
let (content, inner_h) = face(notice, notices.pictures.get(&id), item_w - 24.0, 4, Box::new(move || Notifications::dismiss(id)));
|
||||||
let has_default = notice.has_default_action();
|
let has_default = notice.has_default_action();
|
||||||
|
|
||||||
let item = Rectangle::new().width(item_w).height(inner_h + 24.0).radius(16.0).fill(overlays::chip()).padding(12.0).child(content);
|
// Mute app keeps its notifications quiet in the history, Block app drops them
|
||||||
|
let app = notice.app_name().to_string();
|
||||||
|
let content: Box<dyn Widget> = if app.is_empty() {
|
||||||
|
Box::new(content)
|
||||||
|
} else {
|
||||||
|
let quiet = listed(&muted, &app);
|
||||||
|
let (for_mute, for_block) = (app.clone(), app.clone());
|
||||||
|
|
||||||
|
let choices = Row::new(vec![
|
||||||
|
Box::new(pods::chip(if quiet { "Unmute app" } else { "Mute app" }, Some(Glyph::Muted), move || mute(&for_mute, !quiet))) as Box<dyn Widget>,
|
||||||
|
Box::new(pods::chip("Block app", Some(Glyph::Cross), move || block(&for_block, true))),
|
||||||
|
])
|
||||||
|
.gap(6.0);
|
||||||
|
|
||||||
|
Box::new(Column::new(vec![Box::new(content) as Box<dyn Widget>, Box::new(Rectangle::new().width(item_w - 24.0).height(26.0).align_child(amane::End, Center).child(choices))]).gap(8.0))
|
||||||
|
};
|
||||||
|
let inner_h = if app.is_empty() { inner_h } else { inner_h + 26.0 + 8.0 };
|
||||||
|
|
||||||
|
let item = Rectangle::new().width(item_w).height(inner_h + 24.0).radius(16.0).fill(overlays::chip()).padding(12.0).child(Column::new(vec![content]));
|
||||||
let item = if has_default { item.cursor(Pointer).on_click(move |_| Notifications::click(id)) } else { item };
|
let item = if has_default { item.cursor(Pointer).on_click(move |_| Notifications::click(id)) } else { item };
|
||||||
|
|
||||||
items.push(Box::new(item));
|
items.push(Box::new(item));
|
||||||
@@ -439,5 +524,14 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
|||||||
Box::new(ScrollArea::new("notices-history", Column::new(items).gap(8.0)).width(width).height(list_h))
|
Box::new(ScrollArea::new("notices-history", Column::new(items).gap(8.0)).width(width).height(list_h))
|
||||||
};
|
};
|
||||||
|
|
||||||
Rectangle::new().width(width).height(height).child(Column::new(vec![Box::new(header) as Box<dyn Widget>, body]).gap(10.0))
|
let mut parts: Vec<Box<dyn Widget>> = vec![Box::new(header)];
|
||||||
|
|
||||||
|
if !undo.is_empty() {
|
||||||
|
// ponytail: one row, cut at the card's edge; the config window has the whole lists
|
||||||
|
parts.push(Box::new(Rectangle::new().width(width).height(26.0).clip().child(Row::new(undo).gap(6.0))));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(body);
|
||||||
|
|
||||||
|
Rectangle::new().width(width).height(height).child(Column::new(parts).gap(10.0))
|
||||||
}
|
}
|
||||||
|
|||||||
+846
-15
@@ -1632,41 +1632,49 @@ struct Draft {
|
|||||||
summary: String,
|
summary: String,
|
||||||
location: String,
|
location: String,
|
||||||
cancelled: bool,
|
cancelled: bool,
|
||||||
|
uid: String,
|
||||||
|
recurring: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Draft {
|
impl Draft {
|
||||||
// the finished event, none without a start or when cancelled; with no end it lasts its duration, else a day or an hour
|
// the finished event, none without a start or when cancelled; with no end it lasts its duration, else a day or an hour
|
||||||
fn event(self) -> Option<Event> {
|
fn occurrence(self) -> Option<Occurrence> {
|
||||||
let (start, all_day) = self.start.filter(|_| !self.cancelled)?;
|
let (start, all_day) = self.start.filter(|_| !self.cancelled)?;
|
||||||
let end = self.end.or_else(|| self.duration.map(|duration| start.saturating_add(duration))).unwrap_or(start + if all_day { 86_400 } else { 3_600 });
|
let end = self.end.or_else(|| self.duration.map(|duration| start.saturating_add(duration))).unwrap_or(start + if all_day { 86_400 } else { 3_600 });
|
||||||
|
|
||||||
Some(Event { start, end, all_day, summary: self.summary, location: self.location })
|
Some(Occurrence { event: Event { start, end, all_day, summary: self.summary, location: self.location }, uid: self.uid, recurring: self.recurring })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* the events in an icalendar text (a caldav report's calendar-data): utc times as they are;
|
* the events in an icalendar text (a caldav report's calendar-data): utc times as they are;
|
||||||
* dates, TZID and floating times as local time `offset_s` seconds east of utc. cancelled events
|
* dates, TZID and floating times as local time `offset_s` seconds east of utc. cancelled events
|
||||||
* are skipped, alarms can't overwrite their event, and recurrences are left to the server
|
* are skipped, alarms can't overwrite their event, and recurrences are left to the server.
|
||||||
|
* the shell reads ics_occurrences; this is the events alone, as the tests read them
|
||||||
*/
|
*/
|
||||||
|
#[cfg(test)]
|
||||||
pub fn ics_events(ics: &str, offset_s: i64) -> Vec<Event> {
|
pub fn ics_events(ics: &str, offset_s: i64) -> Vec<Event> {
|
||||||
// a line starting with a space or tab continues the one before
|
ics_occurrences(ics, offset_s).into_iter().map(|occurrence| occurrence.event).collect()
|
||||||
let mut lines: Vec<String> = Vec::new();
|
}
|
||||||
|
|
||||||
for line in ics.lines().map(|line| line.trim_end_matches('\r')) {
|
// one event of an icalendar text with what changing it takes: its UID, and whether it's part of a series
|
||||||
match (line.strip_prefix([' ', '\t']), lines.last_mut()) {
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
(Some(more), Some(last)) => last.push_str(more),
|
pub struct Occurrence {
|
||||||
_ => lines.push(line.to_string()),
|
pub event: Event,
|
||||||
}
|
pub uid: String,
|
||||||
}
|
// an RRULE or RDATE, or one instance the server expanded from a series (it has a RECURRENCE-ID)
|
||||||
|
pub recurring: bool,
|
||||||
|
}
|
||||||
|
|
||||||
let mut events = Vec::new();
|
// the events of an icalendar text like ics_events, each with its UID and whether it repeats
|
||||||
|
pub fn ics_occurrences(ics: &str, offset_s: i64) -> Vec<Occurrence> {
|
||||||
|
let mut occurrences = Vec::new();
|
||||||
let mut draft: Option<Draft> = None;
|
let mut draft: Option<Draft> = None;
|
||||||
|
|
||||||
// how deep inside a VEVENT's own blocks, like VALARM, a line is
|
// how deep inside a VEVENT's own blocks, like VALARM, a line is
|
||||||
let mut nested = 0;
|
let mut nested = 0;
|
||||||
|
|
||||||
for line in &lines {
|
for line in &unfolded(ics) {
|
||||||
let Some((head, value)) = content_line(line) else {
|
let Some((head, value)) = content_line(line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -1677,7 +1685,7 @@ pub fn ics_events(ics: &str, offset_s: i64) -> Vec<Event> {
|
|||||||
("BEGIN", None) if value.eq_ignore_ascii_case("VEVENT") => draft = Some(Draft::default()),
|
("BEGIN", None) if value.eq_ignore_ascii_case("VEVENT") => draft = Some(Draft::default()),
|
||||||
("BEGIN", Some(_)) => nested += 1,
|
("BEGIN", Some(_)) => nested += 1,
|
||||||
("END", Some(_)) if nested > 0 => nested -= 1,
|
("END", Some(_)) if nested > 0 => nested -= 1,
|
||||||
("END", Some(_)) if value.eq_ignore_ascii_case("VEVENT") => events.extend(draft.take().and_then(Draft::event)),
|
("END", Some(_)) if value.eq_ignore_ascii_case("VEVENT") => occurrences.extend(draft.take().and_then(Draft::occurrence)),
|
||||||
(_, Some(_)) if nested > 0 => {}
|
(_, Some(_)) if nested > 0 => {}
|
||||||
("SUMMARY", Some(event)) => event.summary = ics_text(value),
|
("SUMMARY", Some(event)) => event.summary = ics_text(value),
|
||||||
("LOCATION", Some(event)) => event.location = ics_text(value),
|
("LOCATION", Some(event)) => event.location = ics_text(value),
|
||||||
@@ -1685,11 +1693,27 @@ pub fn ics_events(ics: &str, offset_s: i64) -> Vec<Event> {
|
|||||||
("DTSTART", Some(event)) => event.start = ics_time(value, offset_s),
|
("DTSTART", Some(event)) => event.start = ics_time(value, offset_s),
|
||||||
("DTEND", Some(event)) => event.end = ics_time(value, offset_s).map(|(time, _)| time),
|
("DTEND", Some(event)) => event.end = ics_time(value, offset_s).map(|(time, _)| time),
|
||||||
("DURATION", Some(event)) => event.duration = ics_duration(value),
|
("DURATION", Some(event)) => event.duration = ics_duration(value),
|
||||||
|
("UID", Some(event)) => event.uid = ics_text(value.trim()),
|
||||||
|
("RRULE" | "RDATE" | "RECURRENCE-ID", Some(event)) => event.recurring = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
events
|
occurrences
|
||||||
|
}
|
||||||
|
|
||||||
|
// an icalendar or vcard text as whole content lines: a line starting with a space or tab continues the one before
|
||||||
|
fn unfolded(text: &str) -> Vec<String> {
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for line in text.lines().map(|line| line.trim_end_matches('\r')) {
|
||||||
|
match (line.strip_prefix([' ', '\t']), lines.last_mut()) {
|
||||||
|
(Some(more), Some(last)) => last.push_str(more),
|
||||||
|
_ => lines.push(line.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
// an ics line as (name with its parameters, value), split at the first ':' outside quotes
|
// an ics line as (name with its parameters, value), split at the first ':' outside quotes
|
||||||
@@ -1825,6 +1849,563 @@ pub fn dav_time(unix: i64) -> String {
|
|||||||
format!("{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z", seconds / 3_600, seconds / 60 % 60, seconds % 60)
|
format!("{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z", seconds / 3_600, seconds / 60 % 60, seconds % 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* webdav answers beyond calendars' own: the href inside a property (current-user-principal,
|
||||||
|
* calendar-home-set, addressbook-home-set), a report's resources, address books, and the
|
||||||
|
* calendars the user may only read
|
||||||
|
*/
|
||||||
|
pub fn dav_href(xml: &str, property: &str) -> Option<String> {
|
||||||
|
xml_blocks(xml, property).iter().find_map(|block| xml_texts(block, "href").into_iter().find(|href| !href.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// one resource in a calendar-query or addressbook-query answer: its href, etag (quotes kept) and data
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Resource {
|
||||||
|
pub href: String,
|
||||||
|
pub etag: String,
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// the resources in a report answer, `data` naming the element that holds each one's text (calendar-data, address-data)
|
||||||
|
pub fn dav_resources(xml: &str, data: &str) -> Vec<Resource> {
|
||||||
|
xml_blocks(xml, "response")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|response| {
|
||||||
|
let found: String = xml_blocks(response, "propstat").into_iter().filter(|propstat| xml_texts(propstat, "status").iter().all(|status| status.contains(" 200"))).collect();
|
||||||
|
let href = xml_texts(response, "href").into_iter().next()?;
|
||||||
|
let text = xml_texts(&found, data).into_iter().next().filter(|text| !text.is_empty())?;
|
||||||
|
let etag = xml_texts(&found, "getetag").into_iter().next().unwrap_or_default();
|
||||||
|
|
||||||
|
Some(Resource { href, etag, data: text })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the address books in a depth 1 propfind answer, by href
|
||||||
|
pub fn dav_addressbooks(xml: &str) -> Vec<String> {
|
||||||
|
xml_blocks(xml, "response")
|
||||||
|
.iter()
|
||||||
|
.filter(|response| xml_blocks(response, "resourcetype").iter().any(|kinds| !xml_blocks(kinds, "addressbook").is_empty()))
|
||||||
|
.filter_map(|response| xml_texts(response, "href").into_iter().next())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the calendars in a propfind answer whose current-user-privilege-set grants no writing; one without the property counts as writable
|
||||||
|
pub fn dav_read_only(xml: &str) -> Vec<String> {
|
||||||
|
xml_blocks(xml, "response")
|
||||||
|
.iter()
|
||||||
|
.filter(|response| {
|
||||||
|
let found: String = xml_blocks(response, "propstat").into_iter().filter(|propstat| xml_texts(propstat, "status").iter().all(|status| status.contains(" 200"))).collect();
|
||||||
|
let sets = xml_blocks(&found, "current-user-privilege-set");
|
||||||
|
|
||||||
|
!sets.is_empty() && sets.iter().all(|set| ["write", "write-content", "all"].iter().all(|right| xml_blocks(set, right).is_empty()))
|
||||||
|
})
|
||||||
|
.filter_map(|response| xml_texts(response, "href").into_iter().next())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* curl's output with `-w "\n%{http_code}\n%header{etag}"` after the body, as
|
||||||
|
* (body, status code, etag); the etag is empty when the server sent none, or when
|
||||||
|
* a curl older than 7.84 printed the variable as it was
|
||||||
|
*/
|
||||||
|
pub fn curl_answer(printed: &str) -> (&str, &str, &str) {
|
||||||
|
let mut parts = printed.rsplitn(3, '\n');
|
||||||
|
let etag = parts.next().unwrap_or("").trim();
|
||||||
|
let status = parts.next().unwrap_or("");
|
||||||
|
let body = parts.next().unwrap_or("");
|
||||||
|
|
||||||
|
(body, status.trim(), if etag.starts_with("%header") { "" } else { etag })
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* text as a quoted value in a curl config file (read with -K - from stdin, which is how the
|
||||||
|
* password and the bodies reach curl): backslashes, quotes and line breaks escaped
|
||||||
|
*/
|
||||||
|
pub fn curl_quoted(text: &str) -> String {
|
||||||
|
let mut quoted = String::with_capacity(text.len() + 2);
|
||||||
|
|
||||||
|
quoted.push('"');
|
||||||
|
|
||||||
|
for letter in text.chars() {
|
||||||
|
match letter {
|
||||||
|
'\\' => quoted.push_str("\\\\"),
|
||||||
|
'"' => quoted.push_str("\\\""),
|
||||||
|
'\n' => quoted.push_str("\\n"),
|
||||||
|
'\r' => quoted.push_str("\\r"),
|
||||||
|
'\t' => quoted.push_str("\\t"),
|
||||||
|
other => quoted.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
quoted.push('"');
|
||||||
|
|
||||||
|
quoted
|
||||||
|
}
|
||||||
|
|
||||||
|
// a utc offset as `date +%z` prints it (+0200, -0930) in seconds east of utc
|
||||||
|
pub fn zone_offset(printed: &str) -> Option<i64> {
|
||||||
|
let printed = printed.trim();
|
||||||
|
let (sign, digits) = match printed.strip_prefix('-') {
|
||||||
|
Some(digits) => (-1, digits),
|
||||||
|
None => (1, printed.strip_prefix('+').unwrap_or(printed)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if digits.len() != 4 || !digits.chars().all(|digit| digit.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hours: i64 = digits.get(..2)?.parse().ok()?;
|
||||||
|
let minutes: i64 = digits.get(2..)?.parse().ok()?;
|
||||||
|
|
||||||
|
Some(sign * (hours * 3_600 + minutes * 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
// a time of day as typed (9, 930, 0930, 9:30, 09.30, 9h30) in minutes after midnight
|
||||||
|
pub fn clock_minutes(typed: &str) -> Option<u32> {
|
||||||
|
let typed = typed.trim();
|
||||||
|
let (hours, minutes) = match typed.find([':', '.', 'h', 'H']) {
|
||||||
|
Some(at) => (typed.get(..at)?, typed.get(at + 1..)?),
|
||||||
|
None if typed.len() <= 2 => (typed, "0"),
|
||||||
|
None if typed.len() <= 4 => typed.split_at(typed.len() - 2),
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if hours.is_empty() || minutes.is_empty() || !hours.chars().chain(minutes.chars()).all(|digit| digit.is_ascii_digit()) || minutes.len() > 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (hours, minutes): (u32, u32) = (hours.parse().ok()?, minutes.parse().ok()?);
|
||||||
|
|
||||||
|
(hours < 24 && minutes < 60).then_some(hours * 60 + minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// days since 1970 as an icalendar DATE, like "20261016"
|
||||||
|
pub fn ics_date(days: i64) -> String {
|
||||||
|
let (year, month, day) = civil_from_days(days);
|
||||||
|
|
||||||
|
format!("{year:04}{month:02}{day:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// text for an icalendar TEXT value (RFC 5545 3.3.11): backslashes, commas, semicolons and line breaks escaped
|
||||||
|
pub fn ics_escape(text: &str) -> String {
|
||||||
|
let mut escaped = String::with_capacity(text.len() + 8);
|
||||||
|
let mut letters = text.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(letter) = letters.next() {
|
||||||
|
match letter {
|
||||||
|
'\\' => escaped.push_str("\\\\"),
|
||||||
|
',' => escaped.push_str("\\,"),
|
||||||
|
';' => escaped.push_str("\\;"),
|
||||||
|
'\n' => escaped.push_str("\\n"),
|
||||||
|
'\r' => {
|
||||||
|
letters.next_if_eq(&'\n');
|
||||||
|
escaped.push_str("\\n");
|
||||||
|
}
|
||||||
|
other => escaped.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
escaped
|
||||||
|
}
|
||||||
|
|
||||||
|
// one content line folded so no line is longer than 75 octets (RFC 5545 3.1), never inside a utf-8 character, ending in CRLF
|
||||||
|
pub fn ics_fold(line: &str) -> String {
|
||||||
|
let mut folded = String::with_capacity(line.len() + line.len() / 64 * 3 + 2);
|
||||||
|
let mut used = 0;
|
||||||
|
|
||||||
|
for letter in line.chars() {
|
||||||
|
if used + letter.len_utf8() > 75 {
|
||||||
|
folded.push_str("\r\n ");
|
||||||
|
used = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
folded.push(letter);
|
||||||
|
used += letter.len_utf8();
|
||||||
|
}
|
||||||
|
|
||||||
|
folded.push_str("\r\n");
|
||||||
|
|
||||||
|
folded
|
||||||
|
}
|
||||||
|
|
||||||
|
// when an event happens, as written: whole days from `first` (days since 1970) for `days` days, or utc unix seconds
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum When {
|
||||||
|
AllDay { first: i64, days: i64 },
|
||||||
|
Timed { start: i64, end: i64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl When {
|
||||||
|
fn lines(self) -> [String; 2] {
|
||||||
|
match self {
|
||||||
|
Self::AllDay { first, days } => [format!("DTSTART;VALUE=DATE:{}", ics_date(first)), format!("DTEND;VALUE=DATE:{}", ics_date(first + days.max(1)))],
|
||||||
|
Self::Timed { start, end } => [format!("DTSTART:{}", dav_time(start)), format!("DTEND:{}", dav_time(end.max(start)))],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRODID: &str = "PRODID:-//Magic Island//amane-config//EN";
|
||||||
|
|
||||||
|
// a new calendar resource holding one event, `stamp` (unix seconds) as its creation time
|
||||||
|
pub fn ics_new(uid: &str, stamp: i64, summary: &str, location: &str, when: When) -> String {
|
||||||
|
let [start, end] = when.lines();
|
||||||
|
let stamp = dav_time(stamp);
|
||||||
|
|
||||||
|
let mut lines = vec![
|
||||||
|
String::from("BEGIN:VCALENDAR"),
|
||||||
|
String::from("VERSION:2.0"),
|
||||||
|
String::from(PRODID),
|
||||||
|
String::from("CALSCALE:GREGORIAN"),
|
||||||
|
String::from("BEGIN:VEVENT"),
|
||||||
|
format!("UID:{}", ics_escape(uid)),
|
||||||
|
format!("DTSTAMP:{stamp}"),
|
||||||
|
format!("CREATED:{stamp}"),
|
||||||
|
format!("LAST-MODIFIED:{stamp}"),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
format!("SUMMARY:{}", ics_escape(summary.trim())),
|
||||||
|
];
|
||||||
|
|
||||||
|
if !location.trim().is_empty() {
|
||||||
|
lines.push(format!("LOCATION:{}", ics_escape(location.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.extend(["SEQUENCE:0", "END:VEVENT", "END:VCALENDAR"].map(String::from));
|
||||||
|
|
||||||
|
lines.iter().map(|line| ics_fold(line)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// what an edit sets in an existing event; `when` none keeps its days and times
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Change {
|
||||||
|
pub summary: String,
|
||||||
|
pub location: String,
|
||||||
|
pub when: Option<When>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* an existing calendar resource with the change made, `stamp` the time of the edit. every
|
||||||
|
* VEVENT in it (a series and its moved instances) gets the summary and location; the days
|
||||||
|
* and times go only to VEVENTs without a RECURRENCE-ID, replacing DTSTART, DTEND and
|
||||||
|
* DURATION. SEQUENCE goes up, DTSTAMP and LAST-MODIFIED are the stamp, Apple's structured
|
||||||
|
* location goes when the location changes. alarms and everything else stay as they were.
|
||||||
|
* none when the text holds no VEVENT
|
||||||
|
*/
|
||||||
|
pub fn ics_change(ics: &str, stamp: i64, change: &Change) -> Option<String> {
|
||||||
|
let stamp = dav_time(stamp);
|
||||||
|
let summary = format!("SUMMARY:{}", ics_escape(change.summary.trim()));
|
||||||
|
let location = (!change.location.trim().is_empty()).then(|| format!("LOCATION:{}", ics_escape(change.location.trim())));
|
||||||
|
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut events = 0;
|
||||||
|
|
||||||
|
// the VEVENT being copied: its lines so far, how deep in its own blocks (VALARM) a line is
|
||||||
|
let mut event: Option<Vec<String>> = None;
|
||||||
|
let mut nested = 0;
|
||||||
|
|
||||||
|
for line in unfolded(ics) {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((head, value)) = content_line(&line) else {
|
||||||
|
match event.as_mut() {
|
||||||
|
Some(lines) => lines.push(line),
|
||||||
|
None => out.push(line),
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = head.split(';').next().unwrap_or(head).to_ascii_uppercase();
|
||||||
|
|
||||||
|
let Some(lines) = event.as_mut() else {
|
||||||
|
if key == "BEGIN" && value.trim().eq_ignore_ascii_case("VEVENT") {
|
||||||
|
event = Some(vec![line]);
|
||||||
|
nested = 0;
|
||||||
|
} else {
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match key.as_str() {
|
||||||
|
"BEGIN" => nested += 1,
|
||||||
|
"END" if nested > 0 => nested -= 1,
|
||||||
|
"END" => {
|
||||||
|
lines.push(line);
|
||||||
|
out.extend(changed_event(lines, &stamp, &summary, location.as_deref(), change));
|
||||||
|
event = None;
|
||||||
|
events += 1;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
(events > 0).then(|| out.iter().map(|line| ics_fold(line)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
// one VEVENT's lines (BEGIN to END) with the change made, see ics_change
|
||||||
|
fn changed_event(lines: &[String], stamp: &str, summary: &str, location: Option<&str>, change: &Change) -> Vec<String> {
|
||||||
|
let key_of = |line: &str| content_line(line).map(|(head, _)| head.split(';').next().unwrap_or(head).to_ascii_uppercase()).unwrap_or_default();
|
||||||
|
|
||||||
|
// its own properties come before its first nested block (or its END)
|
||||||
|
let body_end = lines.iter().skip(1).position(|line| matches!(key_of(line).as_str(), "BEGIN" | "END")).map_or(lines.len(), |at| at + 1);
|
||||||
|
let (own, rest) = lines.split_at(body_end);
|
||||||
|
|
||||||
|
let instance = own.iter().any(|line| key_of(line) == "RECURRENCE-ID");
|
||||||
|
let when = change.when.filter(|_| !instance);
|
||||||
|
|
||||||
|
let old_location = own.iter().find(|line| key_of(line) == "LOCATION").and_then(|line| content_line(line)).map(|(_, value)| ics_text(value)).unwrap_or_default();
|
||||||
|
let moved = old_location.trim() != change.location.trim();
|
||||||
|
|
||||||
|
let sequence = own.iter().find(|line| key_of(line) == "SEQUENCE").and_then(|line| content_line(line)?.1.trim().parse::<u32>().ok()).map_or(1, |number| number + 1);
|
||||||
|
|
||||||
|
let mut kept: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for line in own {
|
||||||
|
let key = key_of(line);
|
||||||
|
|
||||||
|
let dropped = match key.as_str() {
|
||||||
|
"SUMMARY" | "LOCATION" | "SEQUENCE" | "DTSTAMP" | "LAST-MODIFIED" => true,
|
||||||
|
"X-APPLE-STRUCTURED-LOCATION" => moved,
|
||||||
|
"DTSTART" | "DTEND" | "DURATION" => when.is_some(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !dropped {
|
||||||
|
kept.push(line.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kept.push(format!("DTSTAMP:{stamp}"));
|
||||||
|
kept.push(format!("LAST-MODIFIED:{stamp}"));
|
||||||
|
kept.push(format!("SEQUENCE:{sequence}"));
|
||||||
|
|
||||||
|
if let Some(when) = when {
|
||||||
|
kept.extend(when.lines());
|
||||||
|
}
|
||||||
|
|
||||||
|
kept.push(summary.to_string());
|
||||||
|
kept.extend(location.map(String::from));
|
||||||
|
kept.extend(rest.iter().cloned());
|
||||||
|
|
||||||
|
kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// a contact's birthday; `year` none when the card leaves it out (Apple writes 1604 with X-APPLE-OMIT-YEAR)
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Birthday {
|
||||||
|
pub name: String,
|
||||||
|
pub month: u32,
|
||||||
|
pub day: u32,
|
||||||
|
pub year: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the birthdays in vcards (one or many in a text, 3.0 or 4.0): BDAY as 19900315, 1990-03-15,
|
||||||
|
* --0315, --03-15, with VALUE=date or a time after it, and Apple's year-less 1604-03-15 with
|
||||||
|
* X-APPLE-OMIT-YEAR=1604. the name is FN, else N's given and family names, else ORG
|
||||||
|
*/
|
||||||
|
pub fn vcard_birthdays(text: &str) -> Vec<Birthday> {
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Card {
|
||||||
|
full: String,
|
||||||
|
parts: String,
|
||||||
|
org: String,
|
||||||
|
date: Option<(Option<i64>, u32, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut birthdays = Vec::new();
|
||||||
|
let mut card: Option<Card> = None;
|
||||||
|
|
||||||
|
for line in unfolded(text) {
|
||||||
|
let Some((head, value)) = content_line(&line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut params = head.split(';');
|
||||||
|
|
||||||
|
// grouped properties like item1.X-ABDATE carry their group before a dot
|
||||||
|
let name = params.next().unwrap_or("").rsplit('.').next().unwrap_or("").to_ascii_uppercase();
|
||||||
|
|
||||||
|
match (name.as_str(), card.as_mut()) {
|
||||||
|
("BEGIN", _) if value.trim().eq_ignore_ascii_case("VCARD") => card = Some(Card::default()),
|
||||||
|
("END", Some(_)) if value.trim().eq_ignore_ascii_case("VCARD") => {
|
||||||
|
let Some(done) = card.take() else { continue };
|
||||||
|
let Some((year, month, day)) = done.date else { continue };
|
||||||
|
|
||||||
|
let given_family = {
|
||||||
|
let fields: Vec<String> = done.parts.split(';').map(ics_text).collect();
|
||||||
|
let given = fields.get(1).map_or("", |given| given.trim());
|
||||||
|
let family = fields.first().map_or("", |family| family.trim());
|
||||||
|
|
||||||
|
format!("{given} {family}").trim().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = [done.full.trim().to_string(), given_family, done.org.split(';').next().map(ics_text).unwrap_or_default().trim().to_string()].into_iter().find(|name| !name.is_empty());
|
||||||
|
|
||||||
|
if let Some(name) = name {
|
||||||
|
birthdays.push(Birthday { name, month, day, year });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("FN", Some(card)) => card.full = ics_text(value),
|
||||||
|
("N", Some(card)) => card.parts = value.to_string(),
|
||||||
|
("ORG", Some(card)) => card.org = value.to_string(),
|
||||||
|
("BDAY", Some(card)) => {
|
||||||
|
let omitted = params.find_map(|param| {
|
||||||
|
let (key, number) = param.split_once('=')?;
|
||||||
|
|
||||||
|
key.trim().eq_ignore_ascii_case("X-APPLE-OMIT-YEAR").then(|| number.trim().trim_matches('"').parse::<i64>().ok())?
|
||||||
|
});
|
||||||
|
|
||||||
|
card.date = birthday_date(value, omitted);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
birthdays
|
||||||
|
}
|
||||||
|
|
||||||
|
// a BDAY value as (year if known, month, day)
|
||||||
|
fn birthday_date(value: &str, omitted_year: Option<i64>) -> Option<(Option<i64>, u32, u32)> {
|
||||||
|
let date = value.trim().split(['T', 't']).next().unwrap_or("");
|
||||||
|
|
||||||
|
let (year, digits) = match date.strip_prefix("--") {
|
||||||
|
Some(rest) => (None, rest.replace('-', "")),
|
||||||
|
None => {
|
||||||
|
let digits = date.replace('-', "");
|
||||||
|
let year: i64 = digits.get(..4)?.parse().ok()?;
|
||||||
|
|
||||||
|
(Some(year), digits.get(4..)?.to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if digits.len() != 4 || !digits.chars().all(|digit| digit.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let month: u32 = digits.get(..2)?.parse().ok()?;
|
||||||
|
let day: u32 = digits.get(2..)?.parse().ok()?;
|
||||||
|
|
||||||
|
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((year.filter(|year| Some(*year) != omitted_year), month, day))
|
||||||
|
}
|
||||||
|
|
||||||
|
// easter sunday of a year in the gregorian calendar (the anonymous gregorian computus), as days since 1970
|
||||||
|
pub fn easter(year: i64) -> i64 {
|
||||||
|
let golden = year.rem_euclid(19);
|
||||||
|
let (century, rest) = (year.div_euclid(100), year.rem_euclid(100));
|
||||||
|
let (leap_centuries, century_rest) = (century / 4, century % 4);
|
||||||
|
let correction = (century + 8) / 25;
|
||||||
|
let moon = (century - correction + 1) / 3;
|
||||||
|
let epact = (19 * golden + century - leap_centuries - moon + 15) % 30;
|
||||||
|
let (leap_years, year_rest) = (rest / 4, rest % 4);
|
||||||
|
let weekday = (32 + 2 * century_rest + 2 * leap_years - epact - year_rest) % 7;
|
||||||
|
let shift = (golden + 11 * epact + 22 * weekday) / 451;
|
||||||
|
let month = (epact + weekday - 7 * shift + 114) / 31;
|
||||||
|
let day = (epact + weekday - 7 * shift + 114) % 31 + 1;
|
||||||
|
|
||||||
|
days_from_civil(year, month as u32, day as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATES: [&str; 16] = ["BW", "BY", "BE", "BB", "HB", "HH", "HE", "MV", "NI", "NW", "RP", "SL", "SN", "ST", "SH", "TH"];
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the public holidays (gesetzliche Feiertage) of a German state in a year, as (days since
|
||||||
|
* 1970, German name), in date order: the nationwide ones and the state's own. `state` is
|
||||||
|
* its code (BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH); empty gives
|
||||||
|
* none, an unknown code only the nationwide ones. school holidays are not public holidays.
|
||||||
|
* Mariä Himmelfahrt counts for all of BY although it's only official in its mostly catholic
|
||||||
|
* places, the majority; regional ones (Augsburg's Friedensfest, Fronleichnam in parts of
|
||||||
|
* SN and TH) are left out
|
||||||
|
*/
|
||||||
|
pub fn holidays(year: i64, state: &str) -> Vec<(i64, &'static str)> {
|
||||||
|
let state = state.trim().to_ascii_uppercase();
|
||||||
|
|
||||||
|
if state.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let known = STATES.contains(&state.as_str());
|
||||||
|
let here = |states: &[&str]| known && states.contains(&state.as_str());
|
||||||
|
let date = |month: u32, day: u32| days_from_civil(year, month, day);
|
||||||
|
let easter = easter(year);
|
||||||
|
|
||||||
|
let mut days: Vec<(i64, &'static str)> = vec![
|
||||||
|
(date(1, 1), "Neujahr"),
|
||||||
|
(easter - 2, "Karfreitag"),
|
||||||
|
(easter + 1, "Ostermontag"),
|
||||||
|
(date(5, 1), "Tag der Arbeit"),
|
||||||
|
(easter + 39, "Christi Himmelfahrt"),
|
||||||
|
(easter + 50, "Pfingstmontag"),
|
||||||
|
(date(12, 25), "1. Weihnachtstag"),
|
||||||
|
(date(12, 26), "2. Weihnachtstag"),
|
||||||
|
];
|
||||||
|
|
||||||
|
if year >= 1990 {
|
||||||
|
days.push((date(10, 3), "Tag der Deutschen Einheit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if here(&["BW", "BY", "ST"]) {
|
||||||
|
days.push((date(1, 6), "Heilige Drei Könige"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (here(&["BE"]) && year >= 2019) || (here(&["MV"]) && year >= 2023) {
|
||||||
|
days.push((date(3, 8), "Internationaler Frauentag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if here(&["BB", "HE"]) {
|
||||||
|
days.push((easter, "Ostersonntag"));
|
||||||
|
days.push((easter + 49, "Pfingstsonntag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if here(&["BE"]) && (year == 2020 || year == 2025) {
|
||||||
|
days.push((date(5, 8), "Tag der Befreiung"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if here(&["BW", "BY", "HE", "NW", "RP", "SL"]) {
|
||||||
|
days.push((easter + 60, "Fronleichnam"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if here(&["BY", "SL"]) {
|
||||||
|
days.push((date(8, 15), "Mariä Himmelfahrt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if here(&["TH"]) && year >= 2019 {
|
||||||
|
days.push((date(9, 20), "Weltkindertag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if year == 2017 || here(&["BB", "MV", "SN", "ST", "TH"]) || (here(&["HB", "HH", "NI", "SH"]) && year >= 2018) {
|
||||||
|
days.push((date(10, 31), "Reformationstag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if here(&["BW", "BY", "NW", "RP", "SL"]) {
|
||||||
|
days.push((date(11, 1), "Allerheiligen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// the wednesday before 23 november; everywhere until 1994
|
||||||
|
if here(&["SN"]) || year <= 1994 {
|
||||||
|
let before = date(11, 22);
|
||||||
|
|
||||||
|
days.push((before - (i64::from(weekday(before)) + 5) % 7, "Buß- und Bettag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
days.sort_by_key(|(day, _)| *day);
|
||||||
|
|
||||||
|
days
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2346,4 +2927,254 @@ Sink #80
|
|||||||
assert_eq!(dav_time(1_792_146_607), "20261016T103007Z");
|
assert_eq!(dav_time(1_792_146_607), "20261016T103007Z");
|
||||||
assert_eq!(dav_time(-1), "19691231T235959Z");
|
assert_eq!(dav_time(-1), "19691231T235959Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_dav_discovery_and_reports() {
|
||||||
|
// icloud's principal, its calendar home on another host, and a carddav home
|
||||||
|
let principal = r#"<?xml version="1.0" encoding="UTF-8"?><multistatus xmlns="DAV:"><response><href>/</href><propstat><prop><current-user-principal><href>/123456789/principal/</href></current-user-principal></prop><status>HTTP/1.1 200 OK</status></propstat></response></multistatus>"#;
|
||||||
|
let home = r#"<multistatus xmlns="DAV:"><response><href>/123456789/principal/</href><propstat><prop><calendar-home-set xmlns="urn:ietf:params:xml:ns:caldav"><href xmlns="DAV:">https://p42-caldav.icloud.com:443/123456789/calendars/</href></calendar-home-set></prop><status>HTTP/1.1 200 OK</status></propstat></response></multistatus>"#;
|
||||||
|
let cards = r#"<d:multistatus xmlns:d="DAV:"><d:response><d:href>/123456789/principal/</d:href><d:propstat><d:prop><card:addressbook-home-set xmlns:card="urn:ietf:params:xml:ns:carddav"><d:href>https://p42-contacts.icloud.com:443/123456789/carddavhome/</d:href></card:addressbook-home-set></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#;
|
||||||
|
|
||||||
|
assert_eq!(dav_href(principal, "current-user-principal").as_deref(), Some("/123456789/principal/"));
|
||||||
|
assert_eq!(dav_href(home, "calendar-home-set").as_deref(), Some("https://p42-caldav.icloud.com:443/123456789/calendars/"));
|
||||||
|
assert_eq!(dav_href(cards, "addressbook-home-set").as_deref(), Some("https://p42-contacts.icloud.com:443/123456789/carddavhome/"));
|
||||||
|
assert_eq!(dav_href(principal, "calendar-home-set"), None);
|
||||||
|
|
||||||
|
let books = r#"<multistatus xmlns="DAV:"><response><href>/123456789/carddavhome/</href><propstat><prop><resourcetype><collection/></resourcetype></prop><status>HTTP/1.1 200 OK</status></propstat></response><response><href>/123456789/carddavhome/card/</href><propstat><prop><resourcetype><collection/><addressbook xmlns="urn:ietf:params:xml:ns:carddav"/></resourcetype></prop><status>HTTP/1.1 200 OK</status></propstat></response></multistatus>"#;
|
||||||
|
|
||||||
|
assert_eq!(dav_addressbooks(books), ["/123456789/carddavhome/card/"]);
|
||||||
|
|
||||||
|
// a calendar shared read-only, the user's own with every right, one that doesn't say
|
||||||
|
let rights = r#"<multistatus xmlns="DAV:"><response><href>/1/calendars/shared/</href><propstat><prop><current-user-privilege-set><privilege><read/></privilege><privilege><read-current-user-privilege-set/></privilege></current-user-privilege-set></prop><status>HTTP/1.1 200 OK</status></propstat></response><response><href>/1/calendars/home/</href><propstat><prop><current-user-privilege-set><privilege><all/></privilege><privilege><read/></privilege><privilege><write/></privilege></current-user-privilege-set></prop><status>HTTP/1.1 200 OK</status></propstat></response><response><href>/1/calendars/work/</href><propstat><prop><current-user-privilege-set/></prop><status>HTTP/1.1 404 Not Found</status></propstat></response></multistatus>"#;
|
||||||
|
|
||||||
|
assert_eq!(dav_read_only(rights), ["/1/calendars/shared/"]);
|
||||||
|
|
||||||
|
// a calendar-query answer: icloud's cdata, entity-escaped data with , a 404 one left out
|
||||||
|
let report = "<?xml version='1.0' encoding='UTF-8'?><multistatus xmlns='DAV:'><response><href>/123456789/calendars/home/6B1C9E6A.ics</href><propstat><prop><getetag>\"mfe0k8mi\"</getetag><calendar-data xmlns='urn:ietf:params:xml:ns:caldav'><![CDATA[BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:6B1C9E6A\r\nDTSTART:20261016T090000Z\r\nSUMMARY:A & B <c>\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n]]></calendar-data></prop><status>HTTP/1.1 200 OK</status></propstat></response>\
|
||||||
|
<response><href>/123456789/calendars/home/x2.ics</href><propstat><prop><getetag>"abc"</getetag><C:calendar-data xmlns:C='urn:ietf:params:xml:ns:caldav'>BEGIN:VCALENDAR \nSUMMARY:Tom & Jerry \nEND:VCALENDAR \n</C:calendar-data></prop><status>HTTP/1.1 200 OK</status></propstat></response>\
|
||||||
|
<response><href>/123456789/calendars/home/gone.ics</href><propstat><prop><calendar-data/></prop><status>HTTP/1.1 404 Not Found</status></propstat></response></multistatus>";
|
||||||
|
|
||||||
|
let found = dav_resources(report, "calendar-data");
|
||||||
|
|
||||||
|
assert_eq!(found.len(), 2);
|
||||||
|
assert_eq!((found[0].href.as_str(), found[0].etag.as_str()), ("/123456789/calendars/home/6B1C9E6A.ics", "\"mfe0k8mi\""));
|
||||||
|
assert!(found[0].data.starts_with("BEGIN:VCALENDAR\r\n") && found[0].data.contains("SUMMARY:A & B <c>\r\n"));
|
||||||
|
assert_eq!((found[1].etag.as_str(), found[1].data.as_str()), ("\"abc\"", "BEGIN:VCALENDAR\r\nSUMMARY:Tom & Jerry\r\nEND:VCALENDAR"));
|
||||||
|
|
||||||
|
assert_eq!(curl_answer("<xml/>\nline\n207\n\"etag-1\""), ("<xml/>\nline", "207", "\"etag-1\""));
|
||||||
|
assert_eq!(curl_answer("\n000\n"), ("", "000", ""));
|
||||||
|
assert_eq!(curl_answer(""), ("", "", ""));
|
||||||
|
assert_eq!(curl_answer("ok\n204\n%header{etag}"), ("ok", "204", ""));
|
||||||
|
assert_eq!(curl_quoted("ann@icloud.com:ab\"c\\d"), "\"ann@icloud.com:ab\\\"c\\\\d\"");
|
||||||
|
assert_eq!(curl_quoted("BEGIN:VCALENDAR\r\n\tX"), "\"BEGIN:VCALENDAR\\r\\n\\tX\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_offsets_and_typed_times() {
|
||||||
|
assert_eq!(["+0200\n", "-0930", "+0000", "0100", "+02", "bogus", ""].map(zone_offset), [Some(7_200), Some(-34_200), Some(0), Some(3_600), None, None, None]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
["9", "930", "0930", "9:30", "09.30", "9h30", "23:59", "0:00", "1:5", " 18:45 "].map(clock_minutes),
|
||||||
|
[Some(540), Some(570), Some(570), Some(570), Some(570), Some(570), Some(1_439), Some(0), Some(65), Some(1_125)]
|
||||||
|
);
|
||||||
|
assert_eq!(["24:00", "12:60", "", "ab", "12345", ":30", "9:", "9:300", "-1"].map(clock_minutes), [None; 9]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_series_and_instances() {
|
||||||
|
// icloud's answer to an expanded report: an instance of a series in utc, and an all-day event
|
||||||
|
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN\r\n\
|
||||||
|
BEGIN:VEVENT\r\nUID:series-1\r\nDTSTART:20261019T080000Z\r\nDTEND:20261019T083000Z\r\nRECURRENCE-ID:20261019T070000Z\r\nSUMMARY:Stand-up\r\nEND:VEVENT\r\n\
|
||||||
|
BEGIN:VEVENT\r\nUID:one-off\r\nDTSTART;VALUE=DATE:20261020\r\nDTEND;VALUE=DATE:20261021\r\nSUMMARY:Day off\r\nEND:VEVENT\r\n\
|
||||||
|
BEGIN:VEVENT\r\nUID:weekly\r\nDTSTART:20261021T100000Z\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Unexpanded\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||||
|
|
||||||
|
let day = |month, day| days_from_civil(2026, month, day) * 86_400;
|
||||||
|
let found = ics_occurrences(ics, 7_200);
|
||||||
|
|
||||||
|
assert_eq!(found.iter().map(|occurrence| (occurrence.uid.as_str(), occurrence.recurring)).collect::<Vec<_>>(), [("series-1", true), ("one-off", false), ("weekly", true)]);
|
||||||
|
assert_eq!((found[0].event.start, found[0].event.end, found[0].event.all_day), (day(10, 19) + 8 * 3_600, day(10, 19) + 8 * 3_600 + 1_800, false));
|
||||||
|
assert_eq!((found[1].event.start, found[1].event.end, found[1].event.all_day), (day(10, 20) - 7_200, day(10, 21) - 7_200, true));
|
||||||
|
assert_eq!(ics_events(ics, 7_200), found.iter().map(|occurrence| occurrence.event.clone()).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_icalendar() {
|
||||||
|
assert_eq!(ics_escape("a, b; c\\d\ne\r\nf"), "a\\, b\\; c\\\\d\\ne\\nf");
|
||||||
|
assert_eq!(ics_text(&ics_escape("Tom, Jerry; \\ and\nmore")), "Tom, Jerry; \\ and\nmore");
|
||||||
|
assert_eq!(ics_date(days_from_civil(2026, 10, 16)), "20261016");
|
||||||
|
|
||||||
|
// 100 letters: 75 on the first line, a space and the other 25 on the next
|
||||||
|
let long = "X".repeat(100);
|
||||||
|
|
||||||
|
assert_eq!(ics_fold(&long), format!("{}\r\n {}\r\n", "X".repeat(75), "X".repeat(25)));
|
||||||
|
assert_eq!(ics_fold("SHORT:yes"), "SHORT:yes\r\n");
|
||||||
|
|
||||||
|
// two-byte letters are never cut, and no line passes 75 octets
|
||||||
|
let wide = format!("SUMMARY:{}", "ä".repeat(80));
|
||||||
|
let folded = ics_fold(&wide);
|
||||||
|
|
||||||
|
assert!(folded.split("\r\n").all(|line| line.len() <= 75));
|
||||||
|
assert_eq!(folded.split("\r\n").next().map(str::len), Some(74));
|
||||||
|
assert_eq!(unfolded(&folded), [wide]);
|
||||||
|
|
||||||
|
let stamp = 1_792_022_400;
|
||||||
|
let start = days_from_civil(2026, 10, 16) * 86_400 + 9 * 3_600;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ics_new("ABC-1", stamp, "Lunch, with Ann; Bob ", "Café Einstein", When::Timed { start, end: start + 5_400 }),
|
||||||
|
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Magic Island//amane-config//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:ABC-1\r\nDTSTAMP:20261015T000000Z\r\nCREATED:20261015T000000Z\r\n\
|
||||||
|
LAST-MODIFIED:20261015T000000Z\r\nDTSTART:20261016T090000Z\r\nDTEND:20261016T103000Z\r\nSUMMARY:Lunch\\, with Ann\\; Bob\r\nLOCATION:Café Einstein\r\nSEQUENCE:0\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
// an all-day event over two days reads back; no location, no LOCATION line
|
||||||
|
let trip = ics_new("T-2", stamp, "Trip", " ", When::AllDay { first: days_from_civil(2026, 10, 16), days: 2 });
|
||||||
|
|
||||||
|
assert!(trip.contains("\r\nDTSTART;VALUE=DATE:20261016\r\nDTEND;VALUE=DATE:20261018\r\n") && !trip.contains("LOCATION"));
|
||||||
|
assert_eq!(
|
||||||
|
ics_occurrences(&trip, 0),
|
||||||
|
[Occurrence {
|
||||||
|
event: Event { start: days_from_civil(2026, 10, 16) * 86_400, end: days_from_civil(2026, 10, 18) * 86_400, all_day: true, summary: String::from("Trip"), location: String::new() },
|
||||||
|
uid: String::from("T-2"),
|
||||||
|
recurring: false
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changes_icalendar_events() {
|
||||||
|
// an event as apple calendar stores it on icloud: a zone, a structured location, an alarm
|
||||||
|
let stored = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\n\
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nDTSTART:19810329T020000\r\nTZNAME:CEST\r\nTZOFFSETTO:+0200\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n\
|
||||||
|
BEGIN:VEVENT\r\nCREATED:20261001T080000Z\r\nDTEND;TZID=Europe/Berlin:20261016T120000\r\nDTSTAMP:20261001T080000Z\r\nDTSTART;TZID=Europe/Berlin:20261016T110000\r\n\
|
||||||
|
LAST-MODIFIED:20261001T080000Z\r\nLOCATION:Café Einstein\\nUnter den Linden 42\r\nSEQUENCE:2\r\nSUMMARY:Coffee\r\nUID:6B1C9E6A-1F7A-4C8E-9C51-0E3F2D8A7B11\r\n\
|
||||||
|
X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-ADDRESS=Unter den Linden 42;X-APPLE-RADIUS=70;X-TITLE=\"Café Einst\r\n ein\":geo:52.517,13.389\r\n\
|
||||||
|
BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nUID:ALARM-1\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||||
|
|
||||||
|
let stamp = 1_792_022_400;
|
||||||
|
let start = days_from_civil(2026, 10, 16) * 86_400 + 11 * 3_600;
|
||||||
|
let change = Change { summary: String::from("Coffee with Ann"), location: String::from("Home"), when: Some(When::Timed { start, end: start + 3_600 }) };
|
||||||
|
let changed = ics_change(stored, stamp, &change).unwrap_or_default();
|
||||||
|
|
||||||
|
// the zone stays as it was; the event keeps its uid, creation and alarm
|
||||||
|
assert!(changed.starts_with("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\n"));
|
||||||
|
assert!(changed.ends_with(
|
||||||
|
"BEGIN:VEVENT\r\nCREATED:20261001T080000Z\r\nUID:6B1C9E6A-1F7A-4C8E-9C51-0E3F2D8A7B11\r\nDTSTAMP:20261015T000000Z\r\nLAST-MODIFIED:20261015T000000Z\r\nSEQUENCE:3\r\n\
|
||||||
|
DTSTART:20261016T110000Z\r\nDTEND:20261016T120000Z\r\nSUMMARY:Coffee with Ann\r\nLOCATION:Home\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\n\
|
||||||
|
UID:ALARM-1\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
ics_events(&changed, 7_200),
|
||||||
|
[Event { start, end: start + 3_600, all_day: false, summary: String::from("Coffee with Ann"), location: String::from("Home") }]
|
||||||
|
);
|
||||||
|
|
||||||
|
// the same location keeps apple's structured one; no times given keeps them; an empty location drops it
|
||||||
|
let renamed = ics_change(stored, stamp, &Change { summary: String::from("Coffee"), location: String::from("Café Einstein\nUnter den Linden 42"), when: None }).unwrap_or_default();
|
||||||
|
|
||||||
|
assert!(renamed.contains("X-APPLE-STRUCTURED-LOCATION") && renamed.contains("DTSTART;TZID=Europe/Berlin:20261016T110000\r\n"));
|
||||||
|
|
||||||
|
let cleared = ics_change(stored, stamp, &Change { summary: String::from("Coffee"), location: String::new(), when: None }).unwrap_or_default();
|
||||||
|
|
||||||
|
assert!(!cleared.contains("LOCATION"));
|
||||||
|
|
||||||
|
// a series with a moved instance: both renamed, neither's days touched, each SEQUENCE up by one
|
||||||
|
let series = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Apple Inc.//iOS 18.0//EN\n\
|
||||||
|
BEGIN:VEVENT\nUID:series-1\nDTSTART;TZID=Europe/Berlin:20261005T090000\nDTEND;TZID=Europe/Berlin:20261005T093000\nRRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE;TZID=Europe/Berlin:20261012T090000\nSUMMARY:Stand-up\nDTSTAMP:20261001T080000Z\nEND:VEVENT\n\
|
||||||
|
BEGIN:VEVENT\nUID:series-1\nRECURRENCE-ID;TZID=Europe/Berlin:20261019T090000\nDTSTART;TZID=Europe/Berlin:20261019T100000\nDTEND;TZID=Europe/Berlin:20261019T103000\nSUMMARY:Stand-up\nSEQUENCE:1\nDTSTAMP:20261001T080000Z\nEND:VEVENT\n\
|
||||||
|
END:VCALENDAR\n";
|
||||||
|
|
||||||
|
let renamed = ics_change(series, stamp, &Change { summary: String::from("Daily, sync"), location: String::new(), when: None }).unwrap_or_default();
|
||||||
|
let read = ics_occurrences(&renamed, 7_200);
|
||||||
|
|
||||||
|
assert_eq!(read.iter().map(|occurrence| (occurrence.event.summary.as_str(), occurrence.recurring)).collect::<Vec<_>>(), [("Daily, sync", true), ("Daily, sync", true)]);
|
||||||
|
assert_eq!(read.iter().map(|occurrence| occurrence.event.start).collect::<Vec<_>>(), ics_occurrences(series, 7_200).iter().map(|occurrence| occurrence.event.start).collect::<Vec<_>>());
|
||||||
|
assert!(renamed.contains("RRULE:FREQ=WEEKLY;BYDAY=MO\r\n") && renamed.contains("EXDATE;TZID=Europe/Berlin:20261012T090000\r\n"));
|
||||||
|
assert_eq!(renamed.matches("SEQUENCE:1\r\n").count() + renamed.matches("SEQUENCE:2\r\n").count(), 2);
|
||||||
|
|
||||||
|
// times given to a series go to its first VEVENT only, never to a moved instance
|
||||||
|
let moved = ics_change(series, stamp, &Change { summary: String::from("x"), location: String::new(), when: Some(When::AllDay { first: days_from_civil(2026, 10, 6), days: 1 }) }).unwrap_or_default();
|
||||||
|
|
||||||
|
assert_eq!(moved.matches("DTSTART;VALUE=DATE:20261006").count(), 1);
|
||||||
|
assert!(moved.contains("DTSTART;TZID=Europe/Berlin:20261019T100000"));
|
||||||
|
|
||||||
|
assert_eq!(ics_change("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", stamp, &change), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_birthdays_from_vcards() {
|
||||||
|
// icloud's vcard 3.0 with a folded name, apple's year-less date, vcard 4.0 forms, fallbacks
|
||||||
|
let cards = "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Apple Inc.//iOS 18.0//EN\r\nN:Müller;Anna;;;\r\nFN:Anna Mül\r\n ler\r\nBDAY;VALUE=date:1990-03-15\r\nEND:VCARD\r\n\
|
||||||
|
BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;;;\r\nFN:Smith\\, John\r\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-07-02\r\nEND:VCARD\r\n\
|
||||||
|
BEGIN:VCARD\nVERSION:4.0\nFN:Lena\nBDAY:--0704\nEND:VCARD\n\
|
||||||
|
BEGIN:VCARD\nVERSION:4.0\nFN:Max\nBDAY:--07-05\nEND:VCARD\n\
|
||||||
|
BEGIN:VCARD\nVERSION:3.0\nN:Doe;Jane;;;\nBDAY:19851224\nEND:VCARD\n\
|
||||||
|
BEGIN:VCARD\nVERSION:3.0\nORG:ACME GmbH;Sales\nBDAY:2001-10-16T00:00:00Z\nEND:VCARD\n\
|
||||||
|
BEGIN:VCARD\nVERSION:3.0\nFN:No Birthday\nTEL:+49 89 1234\nEND:VCARD\n\
|
||||||
|
BEGIN:VCARD\nVERSION:3.0\nFN:Broken\nBDAY:1990-13-40\nEND:VCARD\n\
|
||||||
|
BEGIN:VCARD\nVERSION:3.0\nFN:Leap\nbday;value=date:2000-02-29\nEND:VCARD\n";
|
||||||
|
|
||||||
|
let birthday = |name: &str, month, day, year| Birthday { name: String::from(name), month, day, year };
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
vcard_birthdays(cards),
|
||||||
|
[
|
||||||
|
birthday("Anna Müller", 3, 15, Some(1990)),
|
||||||
|
birthday("Smith, John", 7, 2, None),
|
||||||
|
birthday("Lena", 7, 4, None),
|
||||||
|
birthday("Max", 7, 5, None),
|
||||||
|
birthday("Jane Doe", 12, 24, Some(1985)),
|
||||||
|
birthday("ACME GmbH", 10, 16, Some(2001)),
|
||||||
|
birthday("Leap", 2, 29, Some(2000)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(vcard_birthdays("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn computes_german_holidays() {
|
||||||
|
let date = |year, month, day| days_from_civil(year, month, day);
|
||||||
|
|
||||||
|
// known easter sundays, the earliest and latest possible among them
|
||||||
|
for (year, month, day) in [(2024, 3, 31), (2025, 4, 20), (2026, 4, 5), (2027, 3, 28), (2000, 4, 23), (2008, 3, 23), (2011, 4, 24), (1818, 3, 22), (2038, 4, 25), (2285, 3, 22)] {
|
||||||
|
assert_eq!(easter(year), date(year, month, day), "easter {year}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let bavaria = holidays(2026, "BY");
|
||||||
|
let named = |name: &str| bavaria.iter().find(|(_, holiday)| *holiday == name).map(|(day, _)| *day);
|
||||||
|
|
||||||
|
assert_eq!(bavaria.len(), 13);
|
||||||
|
assert_eq!(named("Karfreitag"), Some(date(2026, 4, 3)));
|
||||||
|
assert_eq!(named("Ostermontag"), Some(date(2026, 4, 6)));
|
||||||
|
assert_eq!(named("Christi Himmelfahrt"), Some(date(2026, 5, 14)));
|
||||||
|
assert_eq!(named("Pfingstmontag"), Some(date(2026, 5, 25)));
|
||||||
|
assert_eq!(named("Fronleichnam"), Some(date(2026, 6, 4)));
|
||||||
|
assert_eq!(named("Heilige Drei Könige"), Some(date(2026, 1, 6)));
|
||||||
|
assert_eq!(named("Mariä Himmelfahrt"), Some(date(2026, 8, 15)));
|
||||||
|
assert_eq!(named("Allerheiligen"), Some(date(2026, 11, 1)));
|
||||||
|
assert_eq!(named("Reformationstag"), None);
|
||||||
|
assert!(bavaria.windows(2).all(|pair| pair[0].0 < pair[1].0));
|
||||||
|
|
||||||
|
// berlin in 2025: women's day and the one-off day of liberation
|
||||||
|
let berlin: Vec<&str> = holidays(2025, "be").iter().map(|(_, name)| *name).collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
berlin,
|
||||||
|
["Neujahr", "Internationaler Frauentag", "Karfreitag", "Ostermontag", "Tag der Arbeit", "Tag der Befreiung", "Christi Himmelfahrt", "Pfingstmontag", "Tag der Deutschen Einheit", "1. Weihnachtstag", "2. Weihnachtstag"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// saxony's day of repentance is the wednesday before 23 november, 22 november itself in 2023
|
||||||
|
let repentance = |year| holidays(year, "SN").into_iter().find(|(_, name)| *name == "Buß- und Bettag").map(|(day, _)| day);
|
||||||
|
|
||||||
|
assert_eq!([2023, 2024, 2025, 2026].map(repentance), [Some(date(2023, 11, 22)), Some(date(2024, 11, 20)), Some(date(2025, 11, 19)), Some(date(2026, 11, 18))]);
|
||||||
|
assert_eq!(holidays(2026, "SN").iter().filter(|(_, name)| *name == "Reformationstag").count(), 1);
|
||||||
|
|
||||||
|
// brandenburg and hesse keep easter and whit sunday; thuringia children's day; the north the reformation since 2018
|
||||||
|
assert!(holidays(2026, "BB").contains(&(date(2026, 4, 5), "Ostersonntag")) && holidays(2026, "HE").contains(&(date(2026, 5, 24), "Pfingstsonntag")));
|
||||||
|
assert!(holidays(2026, "TH").contains(&(date(2026, 9, 20), "Weltkindertag")) && !holidays(2018, "TH").iter().any(|(_, name)| *name == "Weltkindertag"));
|
||||||
|
assert!(holidays(2018, "HH").contains(&(date(2018, 10, 31), "Reformationstag")) && !holidays(2016, "HH").iter().any(|(_, name)| *name == "Reformationstag"));
|
||||||
|
assert!(holidays(2017, "BY").contains(&(date(2017, 10, 31), "Reformationstag")));
|
||||||
|
|
||||||
|
// none without a state, the nationwide nine for an unknown code
|
||||||
|
assert!(holidays(2026, "").is_empty());
|
||||||
|
assert_eq!(holidays(2026, "XX").len(), 9);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+382
-92
@@ -8,20 +8,27 @@ use amane::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::config::appearance::{motion, overlays, text};
|
use crate::config::appearance::{motion, overlays, text};
|
||||||
use crate::config::island::{card as island_card, pill as island_pill};
|
use crate::config::island::pill as island_pill;
|
||||||
use crate::config::notifications::history;
|
use crate::config::notifications::history;
|
||||||
use crate::config::pills::{self as settings, layout};
|
use crate::config::pills::{self as settings, colors as hues, layout};
|
||||||
use crate::edge::{self, Edge};
|
use crate::edge::{self, Edge};
|
||||||
use crate::glyph::{Glyph, icon};
|
use crate::glyph::{Glyph, icon};
|
||||||
use crate::island::{self, CLEAR, mix, phases, veiled};
|
use crate::island::{self, CLEAR, mix, phases, veiled};
|
||||||
|
use crate::parse::Sky;
|
||||||
use crate::{ai, home, notices, stats, surface, theme};
|
use crate::{ai, home, notices, stats, surface, theme};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* four small one-icon pills beside the island, each opening into a card the way the
|
* four small one-icon pills beside the island, each opening into a big card in the
|
||||||
* island does: left of it the AI chat and the system monitor, right of it home and
|
* middle of the top of the screen. while a card is open the row becomes a carousel:
|
||||||
* notifications. one window per side and monitor (magic-pods-left|right) placed beside
|
* every other pill and the island's pill line up left and right of the card in their
|
||||||
* the island's pill; while the island opens they move out of its way. one card at a
|
* usual order ([AI][System] island [Home][Bell]), and clicking one switches to its card.
|
||||||
* time across the shell: opening one collapses the island and the other way round
|
* a closing card shrinks back into its own pill's place.
|
||||||
|
*
|
||||||
|
* one window per monitor (magic-pods), as wide as the monitor and anchored to its left
|
||||||
|
* edge, so it never moves: only its height changes, at the top edge. while a card is
|
||||||
|
* open it also draws the island's pill (island::pill_face), which the island's own
|
||||||
|
* window leaves out meanwhile (holds_island); the two overlap for a moment at each
|
||||||
|
* hand-over, at the same place, so the pill never blinks
|
||||||
*/
|
*/
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||||
pub enum Pod {
|
pub enum Pod {
|
||||||
@@ -31,15 +38,18 @@ pub enum Pod {
|
|||||||
Notifications,
|
Notifications,
|
||||||
}
|
}
|
||||||
|
|
||||||
// left of the island, outermost first; right of it, innermost first
|
// the row, left to right
|
||||||
const LEFT: [Pod; 2] = [Pod::Ai, Pod::Monitor];
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
const RIGHT: [Pod; 2] = [Pod::Home, Pod::Notifications];
|
enum Item {
|
||||||
|
Pod(Pod),
|
||||||
|
Island,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW: [Item; 5] = [Item::Pod(Pod::Ai), Item::Pod(Pod::Monitor), Item::Island, Item::Pod(Pod::Home), Item::Pod(Pod::Notifications)];
|
||||||
|
|
||||||
|
const PODS: [Pod; 4] = [Pod::Ai, Pod::Monitor, Pod::Home, Pod::Notifications];
|
||||||
|
|
||||||
impl Pod {
|
impl Pod {
|
||||||
fn left(self) -> bool {
|
|
||||||
matches!(self, Self::Ai | Self::Monitor)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn glyph(self) -> Glyph {
|
fn glyph(self) -> Glyph {
|
||||||
match self {
|
match self {
|
||||||
Self::Ai => Glyph::Sparkle,
|
Self::Ai => Glyph::Sparkle,
|
||||||
@@ -59,9 +69,27 @@ impl Pod {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// its own colour; home's follows the sky
|
||||||
|
fn hue(self) -> Color {
|
||||||
|
match self {
|
||||||
|
Self::Ai => hues::ai(),
|
||||||
|
Self::Monitor => hues::monitor(),
|
||||||
|
Self::Notifications => hues::notifications(),
|
||||||
|
Self::Home => match home::sky() {
|
||||||
|
Some((Sky::Clear | Sky::PartlyCloudy, true)) => hues::home_clear_day(),
|
||||||
|
Some((Sky::Clear | Sky::PartlyCloudy, false)) => hues::home_clear_night(),
|
||||||
|
Some((Sky::Cloudy, _)) | None => hues::home_cloudy(),
|
||||||
|
Some((Sky::Fog, _)) => hues::home_fog(),
|
||||||
|
Some((Sky::Rain, _)) => hues::home_rain(),
|
||||||
|
Some((Sky::Snow, _)) => hues::home_snow(),
|
||||||
|
Some((Sky::Storm, _)) => hues::home_storm(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// whether its card takes typing: then it has the keyboard and stays open when the pointer leaves
|
// whether its card takes typing: then it has the keyboard and stays open when the pointer leaves
|
||||||
fn types(self) -> bool {
|
fn types(self) -> bool {
|
||||||
self == Self::Ai || (self == Self::Home && home::signing_in())
|
self == Self::Ai || (self == Self::Home && home::typing())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn word(self) -> &'static str {
|
fn word(self) -> &'static str {
|
||||||
@@ -74,16 +102,25 @@ impl Pod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn from_word(word: &str) -> Option<Self> {
|
fn from_word(word: &str) -> Option<Self> {
|
||||||
[Self::Ai, Self::Monitor, Self::Home, Self::Notifications].into_iter().find(|pod| pod.word() == word)
|
PODS.into_iter().find(|pod| pod.word() == word)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the wallpaper's surfaces with some of a colour mixed in, keeping their see-through
|
||||||
|
fn tinted(colors: &[Color; 3], hue: Color, amount: f32) -> [Color; 3] {
|
||||||
|
colors.map(|color| Color::blend(color, Color::rgba(hue.red(), hue.green(), hue.blue(), color.alpha()), amount))
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Pods {
|
pub struct Pods {
|
||||||
// the card open or closing, and the monitor it's on; `shown` whether it's opening or open
|
// the card open or closing, and the monitor it's on; `shown` whether it's opening or open
|
||||||
card: Option<(Pod, String)>,
|
card: Option<(Pod, String)>,
|
||||||
shown: bool,
|
shown: bool,
|
||||||
timeline: Animation,
|
timeline: Animation,
|
||||||
|
|
||||||
|
// a switch from one open card straight to another: the one before, and the switch's progress
|
||||||
|
previous: Option<Pod>,
|
||||||
|
switch: Animation,
|
||||||
|
|
||||||
hover: HashMap<Pod, Animation>,
|
hover: HashMap<Pod, Animation>,
|
||||||
|
|
||||||
// the pointer over the card, and a count every arrival and departure bumps, so a late close does nothing
|
// the pointer over the card, and a count every arrival and departure bumps, so a late close does nothing
|
||||||
@@ -93,7 +130,7 @@ pub struct Pods {
|
|||||||
|
|
||||||
impl Service for Pods {
|
impl Service for Pods {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self { card: None, shown: false, timeline: timeline(), hover: HashMap::new(), inside: false, moves: 0 }
|
Self { card: None, shown: false, timeline: timeline(), previous: None, switch: switching(1.0), hover: HashMap::new(), inside: false, moves: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// changes only through input
|
// changes only through input
|
||||||
@@ -104,15 +141,34 @@ fn timeline() -> Animation {
|
|||||||
Animation::new(0.0).duration(Duration::from_millis(layout::open_ms())).easing(Easing::Linear)
|
Animation::new(0.0).duration(Duration::from_millis(layout::open_ms())).easing(Easing::Linear)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn switching(from: f32) -> Animation {
|
||||||
|
Animation::new(from).duration(Duration::from_millis(layout::open_ms())).easing(Easing::InOut)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn open(pod: Pod, monitor: &str) {
|
pub fn open(pod: Pod, monitor: &str) {
|
||||||
island::collapse();
|
island::collapse();
|
||||||
|
|
||||||
let mut pods = Pods::write();
|
let mut pods = Pods::write();
|
||||||
|
|
||||||
// another card, or this one on another monitor: it grows from its own pill
|
let open_here = pods.shown && pods.timeline.value() > 0.5;
|
||||||
if pods.card.as_ref().is_none_or(|(open, on)| *open != pod || on != monitor) {
|
let current = pods.card.as_ref().filter(|(_, on)| on == monitor).map(|(open, _)| *open);
|
||||||
|
|
||||||
|
match current {
|
||||||
|
Some(open) if open == pod => {}
|
||||||
|
// another card while one is open here: the open one turns into it
|
||||||
|
Some(open) if open_here => {
|
||||||
|
pods.previous = Some(open);
|
||||||
|
pods.switch = switching(0.0);
|
||||||
|
pods.switch.to(1.0);
|
||||||
|
pods.card = Some((pod, monitor.to_string()));
|
||||||
|
}
|
||||||
|
// another card, or one on another monitor: it grows from its own pill
|
||||||
|
_ => {
|
||||||
pods.card = Some((pod, monitor.to_string()));
|
pods.card = Some((pod, monitor.to_string()));
|
||||||
pods.timeline = timeline();
|
pods.timeline = timeline();
|
||||||
|
pods.previous = None;
|
||||||
|
pods.switch = switching(1.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pods.shown = true;
|
pods.shown = true;
|
||||||
@@ -152,6 +208,20 @@ pub fn open_card() -> Option<Pod> {
|
|||||||
pods.card.as_ref().filter(|_| pods.shown).map(|(pod, _)| *pod)
|
pods.card.as_ref().filter(|_| pods.shown).map(|(pod, _)| *pod)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* whether this window draws the island's pill on `monitor` now: a little past the start
|
||||||
|
* of opening and until a little before the end of closing. this window draws it from
|
||||||
|
* the first frame to the last, so the two overlap at each hand-over, at the same place
|
||||||
|
*/
|
||||||
|
pub fn holds_island(monitor: &str) -> bool {
|
||||||
|
let pods = Pods::read();
|
||||||
|
|
||||||
|
pods.card.as_ref().is_some_and(|(_, on)| on == monitor) && pods.timeline.value() > HAND_OVER
|
||||||
|
}
|
||||||
|
|
||||||
|
// a share of the open timeline (420 ms by default: 0.08 is about two frames)
|
||||||
|
const HAND_OVER: f32 = 0.08;
|
||||||
|
|
||||||
fn hovering(pod: Pod) -> impl Fn(bool) + 'static {
|
fn hovering(pod: Pod) -> impl Fn(bool) + 'static {
|
||||||
move |inside| {
|
move |inside| {
|
||||||
edge::over_bar(inside);
|
edge::over_bar(inside);
|
||||||
@@ -195,116 +265,334 @@ fn over_card(inside: bool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn left(monitor: &Monitor) -> LayerWindow {
|
// the island's pill in the carousel: the card closes back into place, then the island opens
|
||||||
side(monitor, true)
|
fn island_tapped(monitor: String) {
|
||||||
|
close();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_millis(layout::open_ms()));
|
||||||
|
|
||||||
|
island::tap(&monitor);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn right(monitor: &Monitor) -> LayerWindow {
|
// an item's width in the carousel
|
||||||
side(monitor, false)
|
fn item_width(item: Item) -> f32 {
|
||||||
|
match item {
|
||||||
|
Item::Pod(_) => island_pill::height(),
|
||||||
|
// beside an open card the island's pill shrinks to its cover or its bars
|
||||||
|
Item::Island => island::small_width(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn side(monitor: &Monitor, left: bool) -> LayerWindow {
|
// an item's left edge in the row with nothing open, around the island as wide as it is now
|
||||||
|
fn closed_x(item: Item, middle: f32, island_w: f32) -> f32 {
|
||||||
|
let (h, spacing, gap) = (island_pill::height(), layout::spacing(), layout::gap());
|
||||||
|
|
||||||
|
match item {
|
||||||
|
Item::Island => middle - island_pill::width() / 2.0,
|
||||||
|
Item::Pod(Pod::Monitor) => middle - island_w / 2.0 - gap - h,
|
||||||
|
Item::Pod(Pod::Ai) => middle - island_w / 2.0 - gap - 2.0 * h - spacing,
|
||||||
|
Item::Pod(Pod::Home) => middle + island_w / 2.0 + gap,
|
||||||
|
Item::Pod(Pod::Notifications) => middle + island_w / 2.0 + gap + h + spacing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index(item: Item) -> usize {
|
||||||
|
ROW.iter().position(|other| *other == item).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the two items on each side of `open`'s card: the other four in the row's order, the
|
||||||
|
* first two on its left and the last two on its right, so the left-most stays left-most
|
||||||
|
* (around Home: AI and System on its left, the island and the bell on its right; around
|
||||||
|
* AI: System and the island on its left, Home and the bell on its right)
|
||||||
|
*/
|
||||||
|
fn beside(open: Pod) -> ([Item; 2], [Item; 2]) {
|
||||||
|
let others: Vec<Item> = ROW.into_iter().filter(|item| *item != Item::Pod(open)).collect();
|
||||||
|
|
||||||
|
([others[0], others[1]], [others[2], others[3]])
|
||||||
|
}
|
||||||
|
|
||||||
|
// an item's left edge in the carousel around `open`'s card, `card_w` wide, in the middle, and its side of the card (-1, 0 for the card's own, 1)
|
||||||
|
fn carousel(item: Item, open: Pod, card_w: f32, middle: f32) -> (f32, i8) {
|
||||||
|
// the card's own pill, while it fades into the card or out of it, sits in the card's middle
|
||||||
|
if item == Item::Pod(open) {
|
||||||
|
return (middle - item_width(item) / 2.0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (spacing, gap) = (layout::spacing(), layout::gap());
|
||||||
|
let (left, right) = beside(open);
|
||||||
|
|
||||||
|
match left.iter().position(|other| *other == item) {
|
||||||
|
// leftwards from the card, past the one between
|
||||||
|
Some(k) => {
|
||||||
|
let between: f32 = left[k + 1..].iter().map(|other| item_width(*other) + spacing).sum();
|
||||||
|
|
||||||
|
(middle - card_w / 2.0 - gap - between - item_width(item), -1)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let k = right.iter().position(|other| *other == item).unwrap_or(0);
|
||||||
|
let between: f32 = right[..k].iter().map(|other| item_width(*other) + spacing).sum();
|
||||||
|
|
||||||
|
(middle + card_w / 2.0 + gap + between, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* an item's side of the middle in the closed row, where every card opens: AI and System
|
||||||
|
* left, the island in it (0, so it slides out to whichever side it goes), Home and the
|
||||||
|
* bell right. no item changes sides on opening, so none crosses the card
|
||||||
|
*/
|
||||||
|
fn closed_side(item: Item) -> i8 {
|
||||||
|
(index(item) as i8 - index(Item::Island) as i8).signum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* from one place to another by `k`, with how much it shows: sliding when it stays on its
|
||||||
|
* side of the card; changing sides it fades out where it is over the first half and in at
|
||||||
|
* its new place over the second, so it never crosses the card
|
||||||
|
*/
|
||||||
|
fn travel(from: (f32, i8), to: (f32, i8), k: f32) -> (f32, f32) {
|
||||||
|
if from.1 == to.1 || from.1 == 0 || to.1 == 0 {
|
||||||
|
(mix(from.0, to.0, k), 1.0)
|
||||||
|
} else if k < 0.5 {
|
||||||
|
(from.0, 1.0 - 2.0 * k)
|
||||||
|
} else {
|
||||||
|
(to.0, 2.0 * k - 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// what both windows need to know about this monitor's card this frame
|
||||||
|
struct Scene {
|
||||||
|
// the pod and its timeline, while it's at all open here
|
||||||
|
card: Option<(Pod, f32)>,
|
||||||
|
grow: f32,
|
||||||
|
pill_shown: f32,
|
||||||
|
reveal: f32,
|
||||||
|
switch: f32,
|
||||||
|
previous: Option<Pod>,
|
||||||
|
middle: f32,
|
||||||
|
island_w: f32,
|
||||||
|
// the row's move into the carousel: it starts only after the hand-over (see holds_island), so the island's pill never shows twice
|
||||||
|
row_grow: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scene(state: &Pods, monitor: &Monitor) -> Scene {
|
||||||
|
let here = monitor.name.as_str();
|
||||||
|
let card = state.card.as_ref().filter(|(_, on)| on == here).map(|(pod, _)| (*pod, state.timeline.value())).filter(|(_, t)| *t > 0.0);
|
||||||
|
let (grow, pill_shown, reveal) = card.map_or((0.0, 1.0, 0.0), |(_, t)| phases(t));
|
||||||
|
|
||||||
|
// read only here: a running animation redraws every window that reads it
|
||||||
|
let switch = if card.is_some() { state.switch.value() } else { 1.0 };
|
||||||
|
let previous = state.previous.filter(|_| switch < 1.0 && card.is_some());
|
||||||
|
|
||||||
|
let row_grow = card.map_or(0.0, |(_, t)| phases(((t - 2.0 * HAND_OVER) / (1.0 - 2.0 * HAND_OVER)).max(0.0)).0);
|
||||||
|
|
||||||
|
Scene { card, grow, pill_shown, reveal, switch, previous, middle: monitor.width as f32 / 2.0, island_w: island::width_on(here), row_grow }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scene {
|
||||||
|
// an item's left edge on the monitor and how much it shows: between the closed row and the carousel by `row_grow`, between two carousels by `switch`
|
||||||
|
fn place(&self, item: Item) -> (f32, f32) {
|
||||||
|
let closed = closed_x(item, self.middle, self.island_w);
|
||||||
|
|
||||||
|
let Some((open, _)) = self.card else { return (closed, 1.0) };
|
||||||
|
|
||||||
|
let now = carousel(item, open, open.size().0, self.middle);
|
||||||
|
|
||||||
|
let (open_x, open_shown, open_side) = match self.previous {
|
||||||
|
Some(before) => {
|
||||||
|
let from = carousel(item, before, before.size().0, self.middle);
|
||||||
|
let (x, shown) = travel(from, now, self.switch);
|
||||||
|
|
||||||
|
(x, shown, if self.switch < 0.5 { from.1 } else { now.1 })
|
||||||
|
}
|
||||||
|
None => (now.0, 1.0, now.1),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (x, shown) = travel((closed, closed_side(item)), (open_x, open_side), self.row_grow);
|
||||||
|
|
||||||
|
(x, shown * open_shown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the row of pills: as wide as the monitor and as tall as a pill, never changing. amane
|
||||||
|
* repaints a whole window every frame and hyprland then recomposites (and re-blurs)
|
||||||
|
* everything under it, so a window as tall as the cards ran the animations at 15 fps;
|
||||||
|
* the cards have their own window (card_view)
|
||||||
|
*/
|
||||||
|
pub fn row_view(monitor: &Monitor) -> LayerWindow {
|
||||||
theme::begin();
|
theme::begin();
|
||||||
|
|
||||||
let here = monitor.name.as_str();
|
let here = monitor.name.as_str();
|
||||||
let pods = if left { LEFT } else { RIGHT };
|
let wallpaper = theme::now().surfaces();
|
||||||
let colors = theme::now().surfaces();
|
|
||||||
|
|
||||||
let state = Pods::read();
|
let state = Pods::read();
|
||||||
|
let scene = scene(&state, monitor);
|
||||||
|
let (h, top, shadow) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow());
|
||||||
|
|
||||||
// this side's card on this monitor, while it's at all open
|
// with the bar on a fullscreen monitor, unless a card is open
|
||||||
let card = state.card.as_ref().filter(|(pod, on)| pod.left() == left && on == here).map(|(pod, _)| (*pod, state.timeline.value())).filter(|(_, t)| *t > 0.0);
|
let slide = if scene.card.is_some() { 0.0 } else { edge::hidden_by(Edge::read().shown(here), 0.0, edge::hide_h()) };
|
||||||
|
|
||||||
// with the bar on a fullscreen monitor, unless its card is open; out of the island's way while it opens
|
|
||||||
let slide = if card.is_some() { 0.0 } else { edge::hidden_by(Edge::read().shown(here), 0.0, edge::hide_h()) };
|
|
||||||
let shift = (island::width_on(here) - island_pill::width()) / 2.0;
|
|
||||||
|
|
||||||
let (h, top, shadow, spacing) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow(), layout::spacing());
|
|
||||||
let count = pods.len() as f32;
|
|
||||||
let group_w = count * h + (count - 1.0) * spacing;
|
|
||||||
|
|
||||||
let (card_w, card_h) = card.map_or((0.0, 0.0), |(pod, _)| pod.size());
|
|
||||||
|
|
||||||
// room for the pills to move away from the open island, or for the card
|
|
||||||
let room = (island_card::width() - island_pill::width()) / 2.0;
|
|
||||||
let inner_w = (group_w + room).max(card_w);
|
|
||||||
let window_h = top + h.max(card_h) + shadow;
|
|
||||||
|
|
||||||
// the group's edge nearest the island, in the window, and where the window starts on the monitor
|
|
||||||
let near = if left { shadow + inner_w } else { shadow };
|
|
||||||
let middle = monitor.width as f32 / 2.0;
|
|
||||||
let beside = island_pill::width() / 2.0 + layout::gap();
|
|
||||||
let start = if left { middle - beside - near } else { middle + beside - near };
|
|
||||||
|
|
||||||
let window = LayerWindow::new()
|
let window = LayerWindow::new()
|
||||||
.width(inner_w + 2.0 * shadow)
|
.width(monitor.width as f32)
|
||||||
.height(window_h)
|
.height(top + h + shadow)
|
||||||
.anchor_vertical(Vertical::Top)
|
.anchor_vertical(Vertical::Top)
|
||||||
.anchor_horizontal(Horizontal::Left)
|
.anchor_horizontal(Horizontal::Left)
|
||||||
.margin(Margin { top: 0, right: 0, bottom: 0, left: start.round() as i32 })
|
.margin(Margin { top: 0, right: 0, bottom: 0, left: 0 })
|
||||||
.layer(Layer::Overlay)
|
.layer(Layer::Overlay)
|
||||||
.space(Zone::Ignore)
|
.space(Zone::Ignore)
|
||||||
.namespace(if left { "magic-pods-left" } else { "magic-pods-right" });
|
.namespace("magic-pods");
|
||||||
|
|
||||||
// empty and click-through rather than hidden: see the island's view for why
|
// empty and click-through rather than hidden: see the island's view for why
|
||||||
if slide <= -edge::hide_h() {
|
if slide <= -edge::hide_h() {
|
||||||
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// a pill's left edge in the window; `k` counts from the island outwards
|
let (card, previous, switch) = (scene.card, scene.previous, scene.switch);
|
||||||
let pill_x = |pod: Pod| {
|
|
||||||
let at = pods.iter().position(|other| *other == pod).unwrap_or(0) as f32;
|
|
||||||
let k = if left { count - 1.0 - at } else { at };
|
|
||||||
|
|
||||||
if left { near - shift - (k + 1.0) * h - k * spacing } else { near + shift + k * (h + spacing) }
|
|
||||||
};
|
|
||||||
|
|
||||||
let (grow, pill_shown, reveal) = card.map_or((0.0, 1.0, 0.0), |(_, t)| phases(t));
|
|
||||||
|
|
||||||
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
let mut regions: Vec<InputArea> = Vec::new();
|
let mut regions: Vec<InputArea> = Vec::new();
|
||||||
|
|
||||||
for pod in pods {
|
for item in ROW {
|
||||||
// the open one becomes the card; the others fade while it grows over them
|
let (x, seen) = scene.place(item);
|
||||||
if card.is_some_and(|(open, _)| open == pod) || grow >= 1.0 {
|
|
||||||
|
if seen <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match item {
|
||||||
|
Item::Island => {
|
||||||
|
// the island's own window draws its pill while nothing is open here
|
||||||
|
if card.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// it shrinks to its cover (or its bars) as it moves aside
|
||||||
|
let small = scene.row_grow;
|
||||||
|
let width = mix(island_pill::width(), island::small_width(), small);
|
||||||
|
let on = here.to_string();
|
||||||
|
let pill = island::pill_face(here, small).cursor(Pointer).on_hover(edge::over_bar).on_click(move |_| island_tapped(on.clone()));
|
||||||
|
let pill = if seen < 1.0 { pill.opacity(seen) } else { pill };
|
||||||
|
|
||||||
|
regions.push(InputArea { x: x as i32, y: top as i32, width: width.ceil() as i32, height: h.ceil() as i32 });
|
||||||
|
layers.push(Box::new(pill.translate(x, top)));
|
||||||
|
}
|
||||||
|
Item::Pod(pod) => {
|
||||||
|
// the open one is the card, drawn last. in a switch, the one switched to slides into
|
||||||
|
// the card and fades over the first half; the one switched away from fades back in as a pill
|
||||||
|
let shown = match previous {
|
||||||
|
Some(_) if card.is_some_and(|(open, _)| open == pod) => 1.0 - 2.0 * switch,
|
||||||
|
// its card is in the card window, which starts drawing at HAND_OVER; the pill stays a little longer under it
|
||||||
|
_ if card.is_some_and(|(open, t)| open == pod && t > 2.0 * HAND_OVER) => continue,
|
||||||
|
Some(before) if before == pod => switch,
|
||||||
|
_ => 1.0,
|
||||||
|
} * seen;
|
||||||
|
|
||||||
|
if shown <= 0.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let x = pill_x(pod);
|
|
||||||
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
||||||
let pill = pill(pod, lit, &colors, here);
|
let pill = pill(pod, lit, &wallpaper, here);
|
||||||
let pill = if grow > 0.0 { pill.opacity(1.0 - grow) } else { pill };
|
let pill = if shown < 1.0 { pill.opacity(shown) } else { pill };
|
||||||
|
|
||||||
regions.push(InputArea { x: x as i32, y: (top + slide) as i32, width: h.ceil() as i32, height: h.ceil() as i32 });
|
regions.push(InputArea { x: x as i32, y: (top + slide) as i32, width: h.ceil() as i32, height: h.ceil() as i32 });
|
||||||
layers.push(Box::new(pill.translate(x, top + slide)));
|
layers.push(Box::new(pill.translate(x, top + slide)));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let typing = card.is_some_and(|(pod, _)| pod.types() && state.shown);
|
window.input_region(regions).child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent)))
|
||||||
|
}
|
||||||
|
|
||||||
if let Some((pod, _)) = card {
|
/*
|
||||||
// from the pill to the card, its edge nearest the island staying put
|
* the open card, in the middle: a window as big as the biggest card while one is open
|
||||||
let from_x = pill_x(pod);
|
* here, and an empty pixel otherwise. it changes size only while it shows nothing (the
|
||||||
let to_x = if left { near - shift - card_w } else { near + shift };
|
* card starts and ends as its pill, which the row window draws under it meanwhile), so
|
||||||
|
* hyprland's squashed first frame at a new size is never seen
|
||||||
|
*/
|
||||||
|
pub fn card_view(monitor: &Monitor) -> LayerWindow {
|
||||||
|
theme::begin();
|
||||||
|
|
||||||
let width = mix(h, card_w, grow);
|
let wallpaper = theme::now().surfaces();
|
||||||
let height = mix(h, card_h, grow);
|
let state = Pods::read();
|
||||||
|
let scene = scene(&state, monitor);
|
||||||
|
let (h, top, shadow) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow());
|
||||||
|
|
||||||
|
let window = |width: f32, height: f32| LayerWindow::new().width(width).height(height).anchor_vertical(Vertical::Top).layer(Layer::Overlay).space(Zone::Ignore).namespace("magic-pods-card");
|
||||||
|
|
||||||
|
let Some((pod, t)) = scene.card else {
|
||||||
|
return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||||
|
};
|
||||||
|
|
||||||
|
let widest = PODS.iter().map(|pod| pod.size().0).fold(h, f32::max);
|
||||||
|
let tallest = [settings::ai::height(), stats::tallest(), home::height(), history::height()].into_iter().fold(h, f32::max);
|
||||||
|
let window_w = (widest + 2.0 * shadow).round();
|
||||||
|
|
||||||
|
// centred by hyprland: its left edge on the monitor
|
||||||
|
let left = ((monitor.width as f32 - window_w) / 2.0).floor();
|
||||||
|
let window = window(window_w, top + tallest + shadow);
|
||||||
|
|
||||||
|
// the first frames at the new size show at the old place, so they stay empty (the row draws the pill meanwhile)
|
||||||
|
if t <= HAND_OVER {
|
||||||
|
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (grow, pill_shown, reveal, switch, previous, middle) = (scene.grow, scene.pill_shown, scene.reveal, scene.switch, scene.previous, scene.middle);
|
||||||
|
|
||||||
|
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
let mut regions: Vec<InputArea> = Vec::new();
|
||||||
|
|
||||||
|
let typing = pod.types() && state.shown;
|
||||||
|
|
||||||
|
{
|
||||||
|
let (card_w, card_h) = pod.size();
|
||||||
|
|
||||||
|
// a switch morphs the card from the one before: its size, its colour, and its face crossing over
|
||||||
|
let (full_w, full_h, hue) = match previous {
|
||||||
|
Some(before) => {
|
||||||
|
let (before_w, before_h) = before.size();
|
||||||
|
|
||||||
|
(mix(before_w, card_w, switch), mix(before_h, card_h, switch), Color::blend(before.hue(), pod.hue(), switch))
|
||||||
|
}
|
||||||
|
None => (card_w, card_h, pod.hue()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// from its pill's place in the closed row to the middle, and back into that place; in the window
|
||||||
|
let from_x = closed_x(Item::Pod(pod), middle, scene.island_w) - left;
|
||||||
|
let to_x = middle - full_w / 2.0 - left;
|
||||||
|
|
||||||
|
let width = mix(h, full_w, grow);
|
||||||
|
let height = mix(h, full_h, grow);
|
||||||
let x = mix(from_x, to_x, grow);
|
let x = mix(from_x, to_x, grow);
|
||||||
let radius = mix(h / 2.0, layout::radius(), grow);
|
let radius = mix(h / 2.0, layout::radius(), grow);
|
||||||
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
||||||
|
let colors = tinted(&wallpaper, hue, mix(hues::pill_mix(), hues::card_mix(), grow));
|
||||||
|
|
||||||
let face: Rectangle = if reveal > 0.0 {
|
let face: Rectangle = match previous {
|
||||||
veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors)
|
// the card before fades out over the first half of the switch, this one in over the second
|
||||||
} else {
|
Some(before) if switch < 0.5 => {
|
||||||
|
let (before_w, before_h) = before.size();
|
||||||
|
|
||||||
|
veiled(content(before, before_w, before_h), width, height, radius, 1.0 - 2.0 * switch, &colors)
|
||||||
|
}
|
||||||
|
Some(_) => veiled(content(pod, card_w, card_h), width, height, radius, 2.0 * switch - 1.0, &colors),
|
||||||
|
None if reveal > 0.0 => veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors),
|
||||||
|
None => {
|
||||||
// the pill's icon fading as the shape grows
|
// the pill's icon fading as the shape grows
|
||||||
let tint = Color::blend(CLEAR, theme::now().text, pill_shown);
|
let tint = Color::blend(CLEAR, pod.hue(), pill_shown);
|
||||||
|
|
||||||
Rectangle::new().width(h).height(h).align_child(Center, Center).child(icon(pod.glyph(), layout::icon(), tint))
|
Rectangle::new().width(h).height(h).align_child(Center, Center).child(icon(pod.glyph(), layout::icon(), tint))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let face = Rectangle::new().width(width).height(height).align_child(Start, Start).child(face);
|
let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face);
|
||||||
let shape = surface::shell(width, height, radius, &colors, lit * pill_shown, face).on_hover(over_card).translate(x, top + slide);
|
let shape = surface::shell(width, height, radius, &colors, lit * pill_shown, face).on_hover(over_card).translate(x, top);
|
||||||
|
|
||||||
regions.push(InputArea { x: x as i32, y: (top + slide) as i32, width: width.ceil() as i32, height: height.ceil() as i32 });
|
regions.push(InputArea { x: x as i32, y: top as i32, width: width.ceil() as i32, height: height.ceil() as i32 });
|
||||||
layers.push(Box::new(shape));
|
layers.push(Box::new(shape));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +600,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();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -323,12 +612,14 @@ fn side(monitor: &Monitor, left: bool) -> LayerWindow {
|
|||||||
window.child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent)))
|
window.child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// a small pill: the surface with one icon; the bell carries the unread count
|
// a small pill in its own colour: the surface with one icon; the bell carries the unread count
|
||||||
fn pill(pod: Pod, lit: f32, colors: &[Color; 3], monitor: &str) -> Rectangle {
|
fn pill(pod: Pod, lit: f32, wallpaper: &[Color; 3], monitor: &str) -> Rectangle {
|
||||||
let h = island_pill::height();
|
let h = island_pill::height();
|
||||||
let theme = theme::now();
|
let theme = theme::now();
|
||||||
|
let hue = pod.hue();
|
||||||
|
let colors = tinted(wallpaper, hue, hues::pill_mix());
|
||||||
|
|
||||||
let glyph = icon(pod.glyph(), layout::icon() * (1.0 + 0.08 * lit), Color::blend(theme.dim, theme.text, 0.6 + 0.4 * lit));
|
let glyph = icon(pod.glyph(), layout::icon() * (1.0 + 0.08 * lit), Color::blend(hue, theme.text, 0.25 + 0.5 * lit));
|
||||||
let mut inside: Vec<Box<dyn Widget>> = vec![Box::new(Rectangle::new().width(h).height(h).align_child(Center, Center).child(glyph))];
|
let mut inside: Vec<Box<dyn Widget>> = vec![Box::new(Rectangle::new().width(h).height(h).align_child(Center, Center).child(glyph))];
|
||||||
|
|
||||||
let unread = if pod == Pod::Notifications { notices::unread() } else { 0 };
|
let unread = if pod == Pod::Notifications { notices::unread() } else { 0 };
|
||||||
@@ -342,7 +633,7 @@ fn pill(pod: Pod, lit: f32, colors: &[Color; 3], monitor: &str) -> Rectangle {
|
|||||||
.width(badge)
|
.width(badge)
|
||||||
.height(badge)
|
.height(badge)
|
||||||
.radius(Full)
|
.radius(Full)
|
||||||
.fill(theme.accent)
|
.fill(hue)
|
||||||
.align_child(Center, Center)
|
.align_child(Center, Center)
|
||||||
.translate(h - badge - 3.0, 3.0)
|
.translate(h - badge - 3.0, 3.0)
|
||||||
.child(Text::new(words).size(text::tiny() - 1.0).weight(Weight::Bold).color(theme.on_accent)),
|
.child(Text::new(words).size(text::tiny() - 1.0).weight(Weight::Bold).color(theme.on_accent)),
|
||||||
@@ -352,7 +643,7 @@ fn pill(pod: Pod, lit: f32, colors: &[Color; 3], monitor: &str) -> Rectangle {
|
|||||||
let content = Rectangle::new().width(h).height(h).child(Stack::new(inside).width(h).height(h));
|
let content = Rectangle::new().width(h).height(h).child(Stack::new(inside).width(h).height(h));
|
||||||
let on = monitor.to_string();
|
let on = monitor.to_string();
|
||||||
|
|
||||||
surface::shell(h, h, h / 2.0, colors, lit, content).cursor(Pointer).on_hover(hovering(pod)).on_click(move |_| toggle(pod, &on))
|
surface::shell(h, h, h / 2.0, &colors, lit, content).cursor(Pointer).on_hover(hovering(pod)).on_click(move |_| toggle(pod, &on))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn content(pod: Pod, width: f32, height: f32) -> Rectangle {
|
fn content(pod: Pod, width: f32, height: f32) -> Rectangle {
|
||||||
@@ -471,9 +762,9 @@ pub fn command(arguments: &[String]) -> String {
|
|||||||
let card = pods.card.as_ref().filter(|_| pods.shown).map_or("none", |(pod, _)| pod.word());
|
let card = pods.card.as_ref().filter(|_| pods.shown).map_or("none", |(pod, _)| pod.word());
|
||||||
let on = pods.card.as_ref().map_or("", |(_, on)| on.as_str());
|
let on = pods.card.as_ref().map_or("", |(_, on)| on.as_str());
|
||||||
|
|
||||||
format!("card={card} monitor={on} timeline={:.2} unread={}", pods.timeline.value(), notices::unread())
|
format!("card={card} monitor={on} timeline={:.2} switch={:.2} unread={}", pods.timeline.value(), pods.switch.value(), notices::unread())
|
||||||
}
|
}
|
||||||
_ => String::from("usage: pods open|toggle <ai|monitor|home|notifications> [monitor] | close | ask <question> | notices list|dismiss <id>|clear | status"),
|
_ => String::from("usage: pods open|toggle <ai|monitor|home|notifications> [monitor] | close | ask <question> | processes | notices list|dismiss <id>|clear | status"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,4 +772,3 @@ pub fn command(arguments: &[String]) -> String {
|
|||||||
pub fn rule(width: f32) -> Rectangle {
|
pub fn rule(width: f32) -> Rectangle {
|
||||||
Rectangle::new().width(width).height(1.0).fill(overlays::track())
|
Rectangle::new().width(width).height(1.0).fill(overlays::track())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+49
-10
@@ -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({
|
||||||
|
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()));
|
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)
|
||||||
|
|||||||
+259
-77
@@ -4,21 +4,23 @@ use std::process::Command;
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use amane::{Canvas, Color, Column, Path, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Text, Weight, Widget};
|
use amane::{Blend, Canvas, Center, Color, Column, Full, Path, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, Weight, Widget};
|
||||||
|
|
||||||
use crate::config::appearance::text;
|
use crate::config::appearance::{overlays, text};
|
||||||
use crate::config::pills::{layout, monitor as settings};
|
use crate::config::pills::{layout, monitor as settings};
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::{Glyph, glyph_shapes};
|
||||||
use crate::island::faded;
|
use crate::island::faded;
|
||||||
use crate::parse;
|
use crate::parse;
|
||||||
use crate::system::Gpu;
|
use crate::system::Gpu;
|
||||||
use crate::{pods, theme};
|
use crate::{pods, theme};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* the system monitor card: cpu, gpu, memory, network, disk reads and writes as graphs
|
* the system monitor card: cpu, the integrated gpu, the external gpu, memory, swap,
|
||||||
* over the last readings, and the hottest temperature sensors. everything comes from
|
* network, disk reads and writes as graphs over the last readings; the external gpu's
|
||||||
* /proc and /sys on this Service's thread; no process list here, its button opens
|
* memory and the programs on it; the hottest temperature sensors in their colours.
|
||||||
* btm's in a terminal on a special workspace instead
|
* everything comes from /proc and /sys on this Service's thread (the external gpu from
|
||||||
|
* system::Gpu, which reads only while this card is open); no process list here, its
|
||||||
|
* button opens btm's in a terminal on a special workspace instead
|
||||||
*/
|
*/
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Stats {
|
pub struct Stats {
|
||||||
@@ -27,15 +29,19 @@ pub struct Stats {
|
|||||||
// the integrated gpu's clock as a share of its highest, and in words
|
// the integrated gpu's clock as a share of its highest, and in words
|
||||||
gpu: Vec<f32>,
|
gpu: Vec<f32>,
|
||||||
gpu_words: String,
|
gpu_words: String,
|
||||||
|
// the external gpu's busy share
|
||||||
|
egpu: Vec<f32>,
|
||||||
ram: Vec<f32>,
|
ram: Vec<f32>,
|
||||||
ram_words: String,
|
ram_words: String,
|
||||||
|
swap: Vec<f32>,
|
||||||
|
swap_words: String,
|
||||||
// bytes a second
|
// bytes a second
|
||||||
down: Vec<f32>,
|
down: Vec<f32>,
|
||||||
up: Vec<f32>,
|
up: Vec<f32>,
|
||||||
read: Vec<f32>,
|
read: Vec<f32>,
|
||||||
write: Vec<f32>,
|
write: Vec<f32>,
|
||||||
// (sensor, °C), hottest first
|
// (sensor, its icon, °C), hottest first
|
||||||
temps: Vec<(String, f32)>,
|
temps: Vec<(String, Glyph, f32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Sample {
|
struct Sample {
|
||||||
@@ -63,7 +69,9 @@ impl Service for Stats {
|
|||||||
let busy = now.cpu.0.saturating_sub(before.cpu.0) as f32 / now.cpu.1.saturating_sub(before.cpu.1).max(1) as f32;
|
let busy = now.cpu.0.saturating_sub(before.cpu.0) as f32 / now.cpu.1.saturating_sub(before.cpu.1).max(1) as f32;
|
||||||
let memory = parse::memory(&read("/proc/meminfo")).unwrap_or_default();
|
let memory = parse::memory(&read("/proc/meminfo")).unwrap_or_default();
|
||||||
let used = memory.total_kb.saturating_sub(memory.available_kb);
|
let used = memory.total_kb.saturating_sub(memory.available_kb);
|
||||||
|
let swapped = memory.swap_total_kb.saturating_sub(memory.swap_free_kb);
|
||||||
let (clock, clock_words) = gpu_clock();
|
let (clock, clock_words) = gpu_clock();
|
||||||
|
let egpu = Gpu::read().busy as f32 / 100.0;
|
||||||
let temps = temperatures();
|
let temps = temperatures();
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -72,14 +80,17 @@ impl Service for Stats {
|
|||||||
|
|
||||||
push(&mut stats.cpu, busy, keep);
|
push(&mut stats.cpu, busy, keep);
|
||||||
push(&mut stats.gpu, clock, keep);
|
push(&mut stats.gpu, clock, keep);
|
||||||
|
push(&mut stats.egpu, egpu, keep);
|
||||||
push(&mut stats.ram, used as f32 / memory.total_kb.max(1) as f32, keep);
|
push(&mut stats.ram, used as f32 / memory.total_kb.max(1) as f32, keep);
|
||||||
|
push(&mut stats.swap, swapped as f32 / memory.swap_total_kb.max(1) as f32, keep);
|
||||||
push(&mut stats.down, rate(now.net.0, before.net.0), keep);
|
push(&mut stats.down, rate(now.net.0, before.net.0), keep);
|
||||||
push(&mut stats.up, rate(now.net.1, before.net.1), keep);
|
push(&mut stats.up, rate(now.net.1, before.net.1), keep);
|
||||||
push(&mut stats.read, rate(now.disk.0, before.disk.0) * 512.0, keep);
|
push(&mut stats.read, rate(now.disk.0, before.disk.0) * 512.0, keep);
|
||||||
push(&mut stats.write, rate(now.disk.1, before.disk.1) * 512.0, keep);
|
push(&mut stats.write, rate(now.disk.1, before.disk.1) * 512.0, keep);
|
||||||
|
|
||||||
stats.gpu_words = clock_words;
|
stats.gpu_words = clock_words;
|
||||||
stats.ram_words = format!("{:.1} of {:.1} GB", used as f32 / 1048576.0, memory.total_kb as f32 / 1048576.0);
|
stats.ram_words = format!("{} of {}", gigabytes(used), gigabytes(memory.total_kb));
|
||||||
|
stats.swap_words = if memory.swap_total_kb == 0 { String::from("no swap") } else { format!("{} of {}", gigabytes(swapped), gigabytes(memory.swap_total_kb)) };
|
||||||
stats.temps = temps;
|
stats.temps = temps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +99,10 @@ impl Service for Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn gigabytes(kb: u64) -> String {
|
||||||
|
format!("{:.1} GB", kb as f32 / 1048576.0)
|
||||||
|
}
|
||||||
|
|
||||||
fn push(history: &mut Vec<f32>, value: f32, keep: usize) {
|
fn push(history: &mut Vec<f32>, value: f32, keep: usize) {
|
||||||
history.push(value);
|
history.push(value);
|
||||||
|
|
||||||
@@ -141,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
|
||||||
fn temperatures() -> Vec<(String, f32)> {
|
const ORDER: [&str; 8] = ["CPU", "iGPU", "eGPU", "NVMe", "Memory", "Board", "ThinkPad", "Wi-Fi"];
|
||||||
let mut found: Vec<(String, f32)> = Vec::new();
|
|
||||||
|
|
||||||
for entry in fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten() {
|
// every hwmon device's hottest sensor, named for what it is, in ORDER (then by name, then by device)
|
||||||
let device = entry.path();
|
fn temperatures() -> Vec<(String, Glyph, f32)> {
|
||||||
|
let mut found: Vec<(String, Glyph, f32)> = Vec::new();
|
||||||
|
|
||||||
|
// the devices in their numbers' order (hwmon2 before hwmon10), so two of a kind keep theirs too
|
||||||
|
let mut devices: Vec<PathBuf> = fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten().map(|entry| entry.path()).collect();
|
||||||
|
|
||||||
|
devices.sort_by_key(|path| path.file_name().and_then(|name| name.to_str()).and_then(|name| name.trim_start_matches("hwmon").parse::<u32>().ok()).unwrap_or(u32::MAX));
|
||||||
|
|
||||||
|
for device in devices {
|
||||||
let kind = read(device.join("name")).trim().to_string();
|
let kind = read(device.join("name")).trim().to_string();
|
||||||
|
|
||||||
let hottest = fs::read_dir(&device)
|
let hottest = fs::read_dir(&device)
|
||||||
@@ -161,28 +183,29 @@ fn temperatures() -> Vec<(String, f32)> {
|
|||||||
|
|
||||||
let Some(celsius) = hottest else { continue };
|
let Some(celsius) = hottest else { continue };
|
||||||
|
|
||||||
let name = match kind.as_str() {
|
let (name, glyph) = match kind.as_str() {
|
||||||
"coretemp" | "k10temp" | "zenpower" => "CPU",
|
"coretemp" | "k10temp" | "zenpower" => ("CPU", Glyph::Chip),
|
||||||
"nvme" => "NVMe",
|
"nvme" => ("NVMe", Glyph::Drive),
|
||||||
"amdgpu" | "nouveau" => "eGPU",
|
"amdgpu" | "nouveau" => ("eGPU", Glyph::Gpu),
|
||||||
"xe" | "i915" => "GPU",
|
"xe" | "i915" => ("iGPU", Glyph::Gpu),
|
||||||
"acpitz" => "Board",
|
"acpitz" => ("Board", Glyph::Thermometer),
|
||||||
"thinkpad" => "ThinkPad",
|
"thinkpad" => ("ThinkPad", Glyph::Laptop),
|
||||||
"spd5118" | "jc42" => "Memory",
|
"spd5118" | "jc42" => ("Memory", Glyph::Memory),
|
||||||
other if other.starts_with("iwlwifi") => "Wi-Fi",
|
other if other.starts_with("iwlwifi") => ("Wi-Fi", Glyph::Thermometer),
|
||||||
other => other,
|
other => (other, Glyph::Thermometer),
|
||||||
};
|
};
|
||||||
|
|
||||||
found.push((name.to_string(), celsius));
|
found.push((name.to_string(), glyph, celsius));
|
||||||
}
|
}
|
||||||
|
|
||||||
found.sort_by(|a, b| b.1.total_cmp(&a.1));
|
// 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
|
||||||
let names: Vec<String> = found.iter().map(|(name, _)| name.clone()).collect();
|
let names: Vec<String> = found.iter().map(|(name, ..)| name.clone()).collect();
|
||||||
|
|
||||||
for (index, (name, _)) in found.iter_mut().enumerate() {
|
for (index, (name, ..)) in found.iter_mut().enumerate() {
|
||||||
if names.iter().filter(|other| *other == name).count() > 1 {
|
if names.iter().filter(|other| *other == name).count() > 1 {
|
||||||
let number = names[..index].iter().filter(|other| *other == name).count() + 1;
|
let number = names[..index].iter().filter(|other| *other == name).count() + 1;
|
||||||
|
|
||||||
@@ -193,6 +216,19 @@ fn temperatures() -> Vec<(String, f32)> {
|
|||||||
found
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a temperature's colour: cool below `warm`, then warm turning hot up to `hot`
|
||||||
|
fn temperature_color(celsius: f32) -> Color {
|
||||||
|
let (warm, hot) = (settings::warm(), settings::hot());
|
||||||
|
|
||||||
|
if celsius < warm {
|
||||||
|
settings::cool_color()
|
||||||
|
} else if celsius >= hot {
|
||||||
|
settings::hot_color()
|
||||||
|
} else {
|
||||||
|
Color::blend(settings::warm_color(), settings::hot_color(), (celsius - warm) / (hot - warm).max(0.1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* opens btm's process list in a terminal on a special workspace, or shows and hides that
|
* opens btm's process list in a terminal on a special workspace, or shows and hides that
|
||||||
* workspace when it runs already. the window is told apart by its class
|
* workspace when it runs already. the window is told apart by its class
|
||||||
@@ -222,19 +258,47 @@ fn label_height() -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn temp_row_height() -> f32 {
|
fn temp_row_height() -> f32 {
|
||||||
text::small() + 8.0
|
text::small() + 16.0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn temp_rows() -> usize {
|
fn temp_rows() -> usize {
|
||||||
settings::temperatures().div_ceil(2)
|
settings::temperatures().div_ceil(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// the card's height, from settings only, so the window's size doesn't follow the readings
|
fn program_row_height() -> f32 {
|
||||||
pub fn height() -> f32 {
|
text::small() + 8.0
|
||||||
let graphs = 5.0 * (label_height() + settings::graph_height()) + 4.0 * 10.0;
|
}
|
||||||
|
|
||||||
// header, graphs, rule, the temperatures' label and rows, with the column's four gaps
|
// the external gpu's rows under its graph: its memory, then the programs on it (at least one line)
|
||||||
2.0 * layout::pad() + pods::header_height() + graphs + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + 4.0 * 10.0
|
fn egpu_rows(gpu: &Gpu) -> usize {
|
||||||
|
1 + gpu.programs.len().clamp(1, settings::programs())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_count(gpu: &Gpu) -> usize {
|
||||||
|
if gpu.name.is_empty() { 6 } else { 7 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the card's height, from settings and from whether there is an external gpu and how
|
||||||
|
* many programs use it, so the window's size doesn't follow the readings
|
||||||
|
*/
|
||||||
|
pub fn height() -> f32 {
|
||||||
|
let gpu = Gpu::read();
|
||||||
|
let count = graph_count(&gpu) as f32;
|
||||||
|
let graphs = count * (label_height() + settings::graph_height()) + (count - 1.0) * 10.0;
|
||||||
|
|
||||||
|
// header, graphs, the external gpu's rows, rule, the temperatures' label and rows, with the column's gaps
|
||||||
|
let (egpu, parts) = if gpu.name.is_empty() { (0.0, 5.0) } else { (egpu_rows(&gpu) as f32 * program_row_height(), 6.0) };
|
||||||
|
|
||||||
|
2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + (parts - 1.0) * 10.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// the card's height at its tallest: an external gpu with its whole list of programs
|
||||||
|
pub fn tallest() -> f32 {
|
||||||
|
let graphs = 7.0 * (label_height() + settings::graph_height()) + 6.0 * 10.0;
|
||||||
|
let egpu = (1 + settings::programs()) as f32 * program_row_height();
|
||||||
|
|
||||||
|
2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + 5.0 * 10.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// bytes a second in words
|
// bytes a second in words
|
||||||
@@ -247,6 +311,15 @@ fn rate(bytes: f32) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// video memory in KiB, the way a person reads it
|
||||||
|
fn size(kib: u64) -> String {
|
||||||
|
match kib {
|
||||||
|
0..1024 => String::from("<1 MB"),
|
||||||
|
1024..1_048_576 => format!("{} MB", kib / 1024),
|
||||||
|
_ => format!("{:.1} GB", kib as f64 / 1_048_576.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// one graph's readings, the value at its top, its colour
|
// one graph's readings, the value at its top, its colour
|
||||||
type Series<'a> = (&'a [f32], f32, Color);
|
type Series<'a> = (&'a [f32], f32, Color);
|
||||||
|
|
||||||
@@ -287,6 +360,119 @@ fn graph(history: &[f32], top: f32, origin: (f32, f32), size: (f32, f32), color:
|
|||||||
vec![Box::new(area.line_to(last_x, y0 + height).close().fill(faded(color, 40))), Box::new(line.stroke(1.5, color))]
|
vec![Box::new(area.line_to(last_x, y0 + height).close().fill(faded(color, 40))), Box::new(line.stroke(1.5, color))]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a label on the left and its value on the right, one line
|
||||||
|
fn line_of(name: impl Into<String>, value: impl Into<String>, width: f32, height: f32, name_color: Color) -> Rectangle {
|
||||||
|
let theme = theme::now();
|
||||||
|
|
||||||
|
Rectangle::new().width(width).height(height).align_child(Start, Center).child(
|
||||||
|
Row::new(vec![
|
||||||
|
Box::new(Text::new(name).size(text::small()).color(name_color).elide()) as Box<dyn Widget>,
|
||||||
|
Box::new(Text::new(value).size(text::small()).color(theme.dim)),
|
||||||
|
])
|
||||||
|
.width(width)
|
||||||
|
.justify(SpaceBetween)
|
||||||
|
.align(Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the external gpu's memory as a bar, and the programs on it, the most memory first
|
||||||
|
fn egpu_detail(gpu: &Gpu, width: f32) -> Column {
|
||||||
|
let theme = theme::now();
|
||||||
|
let row_h = program_row_height();
|
||||||
|
|
||||||
|
let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 };
|
||||||
|
let words = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024));
|
||||||
|
let bar_w = width * 0.32;
|
||||||
|
|
||||||
|
let memory = Row::new(vec![
|
||||||
|
Box::new(Text::new("Video memory").size(text::small()).color(theme.text)) as Box<dyn Widget>,
|
||||||
|
Box::new(
|
||||||
|
Row::new(vec![
|
||||||
|
Box::new(Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)))
|
||||||
|
as Box<dyn Widget>,
|
||||||
|
Box::new(Text::new(words).size(text::small()).color(theme.dim)),
|
||||||
|
])
|
||||||
|
.gap(10.0)
|
||||||
|
.align(Center),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.width(width)
|
||||||
|
.height(row_h)
|
||||||
|
.justify(SpaceBetween)
|
||||||
|
.align(Center);
|
||||||
|
|
||||||
|
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(memory)];
|
||||||
|
let most = settings::programs();
|
||||||
|
let shown = if gpu.programs.len() > most { most - 1 } else { most };
|
||||||
|
|
||||||
|
for program in gpu.programs.iter().take(shown) {
|
||||||
|
let amount = match program.busy {
|
||||||
|
Some(busy) => format!("{} · {busy}%", size(program.vram_kib)),
|
||||||
|
None => size(program.vram_kib),
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.push(Box::new(line_of(program.name.as_str(), amount, width, row_h, theme.text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if gpu.programs.len() > most {
|
||||||
|
rows.push(Box::new(line_of(format!("and {} more", gpu.programs.len() - shown), "", width, row_h, theme.dim)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if gpu.programs.is_empty() {
|
||||||
|
rows.push(Box::new(line_of("No programs on it", "", width, row_h, theme.dim)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Column::new(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the temperatures in two columns: icon, name, value in its colour, a bar up to `hot`
|
||||||
|
fn temperature_grid(temps: &[(String, Glyph, f32)], width: f32) -> Stack {
|
||||||
|
let theme = theme::now();
|
||||||
|
let cell_w = width / 2.0 - 8.0;
|
||||||
|
let (row_h, line_h, icon) = (temp_row_height(), text::small() + 6.0, 14.0);
|
||||||
|
let bar_w = cell_w - icon - 8.0;
|
||||||
|
|
||||||
|
// every icon into one canvas behind the rows
|
||||||
|
let mut shapes: Vec<Box<dyn Shape>> = Vec::new();
|
||||||
|
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
for (row, pair) in temps.chunks(2).enumerate() {
|
||||||
|
let mut cells: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
for (column, (name, glyph, celsius)) in pair.iter().enumerate() {
|
||||||
|
let color = temperature_color(*celsius);
|
||||||
|
let share = (celsius / settings::hot().max(1.0)).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
shapes.extend(glyph_shapes(*glyph, icon, color, 0.0, (column as f32 * (cell_w + 16.0), row as f32 * row_h + (line_h - icon) / 2.0)));
|
||||||
|
|
||||||
|
cells.push(Box::new(
|
||||||
|
Rectangle::new().width(cell_w).height(row_h).padding(amane::Padding { top: 0.0, right: 0.0, bottom: 0.0, left: icon + 8.0 }).child(
|
||||||
|
Column::new(vec![
|
||||||
|
Box::new(
|
||||||
|
Row::new(vec![
|
||||||
|
Box::new(Text::new(name.as_str()).size(text::small()).color(theme.text)) as Box<dyn Widget>,
|
||||||
|
Box::new(Text::new(format!("{celsius:.0} °C")).size(text::small()).weight(Weight::Bold).color(color)),
|
||||||
|
])
|
||||||
|
.width(bar_w)
|
||||||
|
.height(line_h)
|
||||||
|
.justify(SpaceBetween)
|
||||||
|
.align(Center),
|
||||||
|
) as Box<dyn Widget>,
|
||||||
|
Box::new(Rectangle::new().width(bar_w).height(3.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(3.0)).height(3.0).radius(Full).fill(color))),
|
||||||
|
])
|
||||||
|
.gap(2.0),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(Box::new(Row::new(cells).gap(16.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let height = temp_rows() as f32 * row_h;
|
||||||
|
|
||||||
|
Stack::new(vec![Box::new(Canvas::new().width(width).height(height).shapes(shapes)) as Box<dyn Widget>, Box::new(Column::new(rows))]).width(width).height(height)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn card(width: f32, height: f32) -> Rectangle {
|
pub fn card(width: f32, height: f32) -> Rectangle {
|
||||||
let stats = Stats::read();
|
let stats = Stats::read();
|
||||||
let gpu = Gpu::read();
|
let gpu = Gpu::read();
|
||||||
@@ -300,23 +486,33 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
|||||||
let net_top = peak(&stats.down, &stats.up);
|
let net_top = peak(&stats.down, &stats.up);
|
||||||
let disk_top = peak(&stats.read, &stats.write);
|
let disk_top = peak(&stats.read, &stats.write);
|
||||||
|
|
||||||
let egpu = if gpu.name.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
let celsius = gpu.celsius.map_or(String::new(), |celsius| format!(" · {celsius} °C"));
|
|
||||||
|
|
||||||
format!(" · {} {}%{celsius}", gpu.name, gpu.busy)
|
|
||||||
};
|
|
||||||
|
|
||||||
// (name, value in words, graphs as (history, top, colour))
|
// (name, value in words, graphs as (history, top, colour))
|
||||||
let blocks: Vec<(&str, String, Vec<Series>)> = vec![
|
let mut blocks: Vec<(String, String, Vec<Series>)> = vec![
|
||||||
("CPU", format!("{:.0} %", last(&stats.cpu) * 100.0), vec![(&stats.cpu, 1.0, theme.accent)]),
|
(String::from("CPU"), format!("{:.0} %", last(&stats.cpu) * 100.0), vec![(&stats.cpu, 1.0, theme.accent)]),
|
||||||
("GPU", format!("{}{egpu}", stats.gpu_words), vec![(&stats.gpu, 1.0, theme.accent)]),
|
(String::from("iGPU"), stats.gpu_words.clone(), vec![(&stats.gpu, 1.0, theme.accent)]),
|
||||||
("Memory", stats.ram_words.clone(), vec![(&stats.ram, 1.0, theme.accent)]),
|
|
||||||
("Network", format!("↓ {} ↑ {}", rate(last(&stats.down)), rate(last(&stats.up))), vec![(&stats.down, net_top, theme.accent), (&stats.up, net_top, theme.flame)]),
|
|
||||||
("Disk", format!("read {} write {}", rate(last(&stats.read)), rate(last(&stats.write))), vec![(&stats.read, disk_top, theme.accent), (&stats.write, disk_top, theme.flame)]),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if !gpu.name.is_empty() {
|
||||||
|
let mut facts = vec![format!("{}%", gpu.busy)];
|
||||||
|
|
||||||
|
if let Some(celsius) = gpu.celsius {
|
||||||
|
facts.push(format!("{celsius} °C"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(watts) = gpu.watts {
|
||||||
|
facts.push(format!("{watts:.0} W"));
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push((gpu.name.clone(), facts.join(" · "), vec![(&stats.egpu, 1.0, theme.flame)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.extend([
|
||||||
|
(String::from("Memory"), stats.ram_words.clone(), vec![(stats.ram.as_slice(), 1.0, theme.accent)]),
|
||||||
|
(String::from("Swap"), stats.swap_words.clone(), vec![(stats.swap.as_slice(), 1.0, theme.flame)]),
|
||||||
|
(String::from("Network"), format!("↓ {} ↑ {}", rate(last(&stats.down)), rate(last(&stats.up))), vec![(stats.down.as_slice(), net_top, theme.accent), (stats.up.as_slice(), net_top, theme.flame)]),
|
||||||
|
(String::from("Disk"), format!("read {} write {}", rate(last(&stats.read)), rate(last(&stats.write))), vec![(stats.read.as_slice(), disk_top, theme.accent), (stats.write.as_slice(), disk_top, theme.flame)]),
|
||||||
|
]);
|
||||||
|
|
||||||
let (label_h, graph_h) = (label_height(), settings::graph_height());
|
let (label_h, graph_h) = (label_height(), settings::graph_height());
|
||||||
|
|
||||||
// every graph into one canvas under the labels: each canvas costs the gpu on its own
|
// every graph into one canvas under the labels: each canvas costs the gpu on its own
|
||||||
@@ -342,41 +538,27 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let graphs_h = 5.0 * (label_h + graph_h) + 4.0 * 10.0;
|
let count = graph_count(&gpu) as f32;
|
||||||
|
let graphs_h = count * (label_h + graph_h) + (count - 1.0) * 10.0;
|
||||||
|
|
||||||
let graphs = Stack::new(vec![Box::new(Canvas::new().width(width).height(graphs_h).shapes(shapes)) as Box<dyn Widget>, Box::new(Column::new(rows).gap(10.0))]).width(width).height(graphs_h);
|
let graphs = Stack::new(vec![Box::new(Canvas::new().width(width).height(graphs_h).shapes(shapes)) as Box<dyn Widget>, Box::new(Column::new(rows).gap(10.0))]).width(width).height(graphs_h);
|
||||||
|
|
||||||
// the temperatures in two columns
|
let mut parts: Vec<Box<dyn Widget>> = vec![Box::new(header), Box::new(graphs)];
|
||||||
let mut temp_rows: Vec<Box<dyn Widget>> = Vec::new();
|
|
||||||
|
|
||||||
for pair in stats.temps.chunks(2) {
|
if !gpu.name.is_empty() {
|
||||||
let cell = |(name, celsius): &(String, f32)| -> Box<dyn Widget> {
|
parts.push(Box::new(egpu_detail(&gpu, width)));
|
||||||
let hot = if *celsius >= 85.0 { crate::config::appearance::overlays::low_battery() } else { theme.text };
|
|
||||||
|
|
||||||
Box::new(
|
|
||||||
Rectangle::new().width(width / 2.0 - 8.0).height(temp_row_height()).child(
|
|
||||||
Row::new(vec![Box::new(Text::new(name.as_str()).size(text::small()).color(theme.dim)) as Box<dyn Widget>, Box::new(Text::new(format!("{celsius:.0} °C")).size(text::small()).color(hot))])
|
|
||||||
.width(width / 2.0 - 8.0)
|
|
||||||
.justify(SpaceBetween),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
temp_rows.push(Box::new(Row::new(pair.iter().map(cell).collect()).gap(16.0)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parts.push(Box::new(pods::rule(width)));
|
||||||
|
parts.push(Box::new(Rectangle::new().width(width).height(label_h).child(Text::new("Temperatures").size(text::small()).weight(Weight::Bold).color(theme.text))));
|
||||||
|
|
||||||
if stats.temps.is_empty() {
|
if stats.temps.is_empty() {
|
||||||
temp_rows.push(Box::new(Text::new(if stats.cpu.is_empty() { "Reading…" } else { "No temperature sensors" }).size(text::small()).color(theme.dim)));
|
let words = if stats.cpu.is_empty() { "Reading…" } else { "No temperature sensors" };
|
||||||
|
|
||||||
|
parts.push(Box::new(Rectangle::new().width(width).height(temp_rows() as f32 * temp_row_height()).child(Text::new(words).size(text::small()).color(theme.dim))));
|
||||||
|
} else {
|
||||||
|
parts.push(Box::new(temperature_grid(&stats.temps, width)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle::new().width(width).height(height).child(
|
Rectangle::new().width(width).height(height).child(Column::new(parts).gap(10.0))
|
||||||
Column::new(vec![
|
|
||||||
Box::new(header) as Box<dyn Widget>,
|
|
||||||
Box::new(graphs),
|
|
||||||
Box::new(pods::rule(width)),
|
|
||||||
Box::new(Rectangle::new().width(width).height(label_h).child(Text::new("Temperatures").size(text::small()).weight(Weight::Bold).color(theme.text))),
|
|
||||||
Box::new(Column::new(temp_rows)),
|
|
||||||
])
|
|
||||||
.gap(10.0),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+176
-29
@@ -1,7 +1,10 @@
|
|||||||
use amane::{Blend, Color, Gradient, Rectangle, Shadow, Stack, children};
|
use std::path::PathBuf;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::config::appearance::{frosted, liquid, solid, surface};
|
use amane::{Blend, Color, Rectangle, Stack, children};
|
||||||
use crate::island::{backdrop, faded};
|
|
||||||
|
use crate::config::appearance::{frosted, glass, liquid, solid, surface};
|
||||||
|
use crate::island::faded;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -10,48 +13,78 @@ 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();
|
if surface::design() == "glass" {
|
||||||
let base = Rectangle::new().width(width).height(height).radius(radius);
|
return glass_shell(width, height, radius, colors, lift, content);
|
||||||
|
}
|
||||||
|
|
||||||
match surface::design().as_str() {
|
let theme = theme::now();
|
||||||
|
|
||||||
|
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 +92,123 @@ 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 {
|
static GLASS: LazyLock<PathBuf> = LazyLock::new(|| shader_file("glass.wgsl", include_str!("glass.wgsl")));
|
||||||
Rectangle::new().width(width - 2.0).height(height - 2.0).radius((radius - 1.0).max(0.0)).translate(1.0, 1.0)
|
|
||||||
|
/*
|
||||||
|
* after Apple's Liquid Glass: glass.wgsl draws the shadow and the thin tint in the
|
||||||
|
* wallpaper's colours under the content, and the lens-like edge with its rim over it, so a
|
||||||
|
* photo on a card lies behind the glass; there's no border. the frosting behind is
|
||||||
|
* Hyprland's blur; the desktop isn't bent at the rim the way Apple's glass bends it, since
|
||||||
|
* a shell's shader can't see what's behind its window
|
||||||
|
*/
|
||||||
|
fn glass_shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
|
||||||
|
let (blur, drop): (f32, f32) = (22.0, 6.0);
|
||||||
|
let margin = (1.25 * blur + drop).ceil();
|
||||||
|
|
||||||
|
let mut shadow = plain(Color::BLACK);
|
||||||
|
|
||||||
|
shadow[3] = (glass::shadow() + 0.1 * lift).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
let tint = |color: Color| {
|
||||||
|
let mut tint = plain(color);
|
||||||
|
|
||||||
|
tint[3] = glass::opacity().clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
tint
|
||||||
|
};
|
||||||
|
|
||||||
|
// the under layer covers the shadow's room around the shape too, the edge only the shape
|
||||||
|
let layer = |which: f32, margin: f32| {
|
||||||
|
let values = vec![
|
||||||
|
[width, height, radius, margin],
|
||||||
|
shadow,
|
||||||
|
[blur / 2.0, 0.0, drop, lift],
|
||||||
|
[glass::edge(), glass::light(), glass::rim(), glass::dispersion()],
|
||||||
|
tint(colors[0]),
|
||||||
|
tint(colors[1]),
|
||||||
|
tint(colors[2]),
|
||||||
|
[which, 0.0, 0.0, 0.0],
|
||||||
|
];
|
||||||
|
|
||||||
|
Rectangle::new().width(width + 2.0 * margin).height(height + 2.0 * margin).translate(-margin, -margin).shader(GLASS.as_path()).shader_values(values)
|
||||||
|
};
|
||||||
|
|
||||||
|
Rectangle::new().width(width).height(height).child(Stack::new(children![layer(1.0, margin), content, layer(2.0, 0.0)]).width(width).height(height))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
+2
-3
@@ -14,7 +14,6 @@ use std::{fs, thread};
|
|||||||
|
|
||||||
use amane::{Argument, Bluetooth, Bus, Service, Value};
|
use amane::{Argument, Bluetooth, Bus, Service, Value};
|
||||||
|
|
||||||
use crate::control::Control;
|
|
||||||
use crate::parse;
|
use crate::parse;
|
||||||
use crate::config::control::{polling, services};
|
use crate::config::control::{polling, services};
|
||||||
|
|
||||||
@@ -800,13 +799,13 @@ impl Service for Gpu {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: scans every process's open files every 2 s, so only while the control centre is open
|
// ponytail: scans every process's open files every 2 s, so only while the system monitor's card is open
|
||||||
fn listen() {
|
fn listen() {
|
||||||
let mut names: HashMap<String, String> = HashMap::new();
|
let mut names: HashMap<String, String> = HashMap::new();
|
||||||
let mut engines: HashMap<u32, (u64, Instant)> = HashMap::new();
|
let mut engines: HashMap<u32, (u64, Instant)> = HashMap::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if !Control::read().expanded() {
|
if crate::pods::open_card() != Some(crate::pods::Pod::Monitor) {
|
||||||
thread::sleep(Duration::from_millis(polling::gpu_wait_ms()));
|
thread::sleep(Duration::from_millis(polling::gpu_wait_ms()));
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -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)"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Weather photos
|
||||||
|
|
||||||
|
The home card shows a photo of the weather now behind it. On first use the shell downloads the
|
||||||
|
one it needs from Wikimedia Commons (1280 px wide thumbnails) into `pills.home.photos`
|
||||||
|
(`~/.config/amane/weather` by default) as `<kind>-<day|night>.jpg`. It never replaces a file
|
||||||
|
that's already there, so any of them can be swapped for your own picture (a swapped file shows
|
||||||
|
after the shell restarts).
|
||||||
|
|
||||||
|
All of them are free to use under the licence given, with credit to their authors:
|
||||||
|
|
||||||
|
| File | Photo | Author | Licence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| clear-day.jpg | [Zugspitze, Alpspitze und Jubiläumsgrat von Kinderburg, Allmannshausen, Starnberger See](https://commons.wikimedia.org/wiki/File:Zugspitze,_Alpspitze_und_Jubil%C3%A4umsgrat_von_Kinderburg,_Allmannshausen,_Starnberger_See.jpg) | TGoeller | CC0 1.0 |
|
||||||
|
| clear-night.jpg | [Starry night at Old Uppsala, Sweden](https://commons.wikimedia.org/wiki/File:Starry_night_at_Old_Uppsala,_Sweden.jpg) | Kevin Cho | CC BY-SA 4.0 |
|
||||||
|
| partly-cloudy-day.jpg | [Cumulus humilis Schönwald im Schwarzwald 20180810](https://commons.wikimedia.org/wiki/File:Cumulus_humilis_Sch%C3%B6nwald_im_Schwarzwald_20180810.jpg) | Uoaei1 | CC BY-SA 4.0 |
|
||||||
|
| partly-cloudy-night.jpg | [Full moon over Gullmarn fjord at Holma Marina 1](https://commons.wikimedia.org/wiki/File:Full_moon_over_Gullmarn_fjord_at_Holma_Marina_1.jpg) | W.carter | CC BY-SA 4.0 |
|
||||||
|
| cloudy-day.jpg | [Vragender (NL), Vragenderveen -- 2014 -- 3102](https://commons.wikimedia.org/wiki/File:Vragender_(NL),_Vragenderveen_--_2014_--_3102.jpg) | Dietmar Rabich | CC BY-SA 4.0 |
|
||||||
|
| cloudy-night.jpg | [Stora Bornö on a cloudy night 2](https://commons.wikimedia.org/wiki/File:Stora_Born%C3%B6_on_a_cloudy_night_2.jpg) | W.carter | CC BY-SA 4.0 |
|
||||||
|
| fog-day.jpg | [Dülmen, Umland -- 2014 -- 7056](https://commons.wikimedia.org/wiki/File:D%C3%BClmen,_Umland_--_2014_--_7056.jpg) | Dietmar Rabich | CC BY-SA 4.0 |
|
||||||
|
| fog-night.jpg | [Foggy Autumn Evening on Pawia Street, Warsaw](https://commons.wikimedia.org/wiki/File:Foggy_Autumn_Evening_on_Pawia_Street,_Warsaw.jpg) | Oleslawlama | CC BY-SA 4.0 |
|
||||||
|
| rain-day.jpg | [Rain drops on window 01 ies](https://commons.wikimedia.org/wiki/File:Rain_drops_on_window_01_ies.jpg) | Frank Vincentz | CC BY-SA 3.0 |
|
||||||
|
| rain-night.jpg | [City streets; Reflections on the street surface after rain](https://commons.wikimedia.org/wiki/File:City_streets;_Reflections_on_the_street_surface_after_rain.jpg) | Tarasna0922 | CC BY-SA 4.0 |
|
||||||
|
| snow-day.jpg | [Sninský kameň (v zime) 001](https://commons.wikimedia.org/wiki/File:Sninsk%C3%BD_kame%C5%88_(v_zime)_001.jpg) | Milan Bališin | CC BY-SA 4.0 |
|
||||||
|
| snow-night.jpg | [TwinLamp@snowyNight(byHideObara)](https://commons.wikimedia.org/wiki/File:TwinLamp@snowyNight(byHideObara).jpg) | Hide Obara | CC0 1.0 |
|
||||||
|
| storm-day.jpg | [2011-07 Unwetter Schwäbisch Gmünd](https://commons.wikimedia.org/wiki/File:2011-07_Unwetter_Schw%C3%A4bisch_Gm%C3%BCnd.jpg) | Kreuzschnabel | CC BY-SA 3.0 |
|
||||||
|
| storm-night.jpg | [Lightning, Friedrichshafen (OW1A0402)](https://commons.wikimedia.org/wiki/File:Lightning,_Friedrichshafen_(OW1A0402).jpg) | Matti Blume | CC BY-SA 4.0 |
|
||||||
|
|
||||||
|
Licences: [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/),
|
||||||
|
[CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/),
|
||||||
|
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). The shell shows the photos
|
||||||
|
scaled, cropped to the card and veiled in the card's colour; the files themselves are unchanged
|
||||||
|
Wikimedia thumbnails. Authors and licences were read from each file's Commons page
|
||||||
|
(`extmetadata`) on 2026-10-15.
|
||||||
Reference in New Issue
Block a user