Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9407ee3506 | ||
|
|
84b01f7c56 | ||
|
|
9b4334835a | ||
|
|
49a0bae039 | ||
|
|
bccdb86ebb | ||
|
|
ed0eb532db | ||
|
|
d59a5bc81c | ||
|
|
e8741b1cb3 | ||
|
|
bbe184ab97 |
@@ -7,47 +7,47 @@ Handoff for agents continuing work on the Magic Island shell (Hermes, Claude Cod
|
||||
- **This repo:** `/root/amane-config` on the dev host; remote `git@server1:fabolous005/amane-config.git`.
|
||||
- **Amane library:** `/root/amane` (docs in `docs/src/*.md`). The desktop builds against its own, different copy at `~/git/amane`; type-check against a copy of that one (see Develop).
|
||||
- **Hermes skill `amane`** (`~/.hermes/skills/software-development/amane/`): distilled Amane docs plus lessons. `references/recipe-media-island.md` and `references/remote-visual-testing.md` cover this project; `scripts/` holds copies of `tools/` (keep them in sync).
|
||||
- **Desktop:** `ssh -o BatchMode=yes fabian@192.168.2.169`. Hyprland 0.56.2 with a **Lua** config (`~/.config/hypr/*.lua`; API stubs in `/usr/share/hypr/stubs/hl.meta.lua`). Intel Xe iGPU (Raptor Lake). Monitors: eDP-1 1920×1200 at 1920,620; HDMI-A-1 1920×1080 at 0,620; DP-2 1920×1080 at 3840,0; HDMI-A-4 at 5760,0 only when connected. `~/.config/amane` on the desktop is a git checkout of this repo.
|
||||
- **Desktop daemons the control centre uses:** OpenRC services `iwd` (Wi-Fi), `bluetooth` (bluez), `openvpn.<name>` (VPN); doas rule `permit nopass fabian` for `rc-service`, `sh` and `cat`; `wl-copy`, `ip`, `lspci`. iwd and bluetoothd are often stopped; the shell then greys their tiles.
|
||||
- **Desktop:** `ssh -o BatchMode=yes fabian@192.168.2.169`. Hyprland 0.56.2 with a **Lua** config (`~/.config/hypr/*.lua`; API stubs in `/usr/share/hypr/stubs/hl.meta.lua`). Intel Xe iGPU (Raptor Lake) plus an eGPU (RX 6600). Monitors: eDP-1 1920×1200 at 1920,620; HDMI-A-1 1920×1080 at 0,620; DP-2 1920×1080 at 3840,0; HDMI-A-4 at 5760,0 only when connected. `~/.config/amane` on the desktop is a git checkout of this repo.
|
||||
- **Desktop daemons the shell uses:** OpenRC services `iwd`, `bluetooth`, `openvpn.<name>`, `ollama` (stopped by default; `gemma4:e4b`, listens on 0.0.0.0:11434, runs on the eGPU); doas rule `permit nopass fabian` for `rc-service`, `sh` and `cat`; gnome-keyring as the Secret Service (`secret-tool`); `wl-copy`, `ip`, `lspci`, `ghostty`, `btm`. iwd and bluetoothd are often stopped; the shell then greys their tiles.
|
||||
|
||||
## Ground rules from the user
|
||||
|
||||
- **Branch per big change, ask before merging.** New branch for each big change; merge into `master` only after explicit approval. "In main branch" or "merge directly to main" means commit straight to master. Small follow-ups in the same request can share a branch. **Push only when the user asks.**
|
||||
- **Branch per big change, ask before merging.** Merge into `master` only after explicit approval. "In main branch" or "merge directly to main" means commit straight to master. Small follow-ups in the same request can share a branch. **Push only when the user asks.**
|
||||
- **Never touch the user's real players' sound** (Cider, Firefox). Before any `island preset|output` call or volume change, check `amane ipc call island status` names a fake test player. Test audio goes to the inaudible null sinks.
|
||||
- **The user's own amane:** they sometimes run it themselves. Don't kill it without asking; `tools/amane-remote.sh start` refuses when it finds a shell it didn't start.
|
||||
- **Desktop access is SSH only.** Don't probe the network, SSH keys or other hosts. If SSH fails, report it and wait.
|
||||
- **The user's own amane:** their session starts one at login. Don't kill it without asking; `tools/amane-remote.sh start` refuses when it finds a shell it didn't start.
|
||||
- **Desktop access is SSH only.** Don't probe the network, SSH keys or other hosts. If SSH fails, report it and wait. The user may be at the desktop while you test: if workspaces change or cards close by themselves, stop the on-screen tests.
|
||||
- **Leave the desktop clean** after testing: fake players and null sinks stopped, shell running without the frame log (see "After testing").
|
||||
- **Edit files with the patch/write tools, not scripts that rewrite files.** The user installs missing tools themselves. Scripts take instance values (hosts, names) as arguments.
|
||||
- **No hardcoded tuning values:** every size, timing, text size, colour or interval is a setting in `config.rs` (see Settings). The user asked for this explicitly.
|
||||
- **Blur strength is Hyprland's only** (`decoration.blur.*`); the shell never sets it. A shell blur setting was added once and reverted at the user's request.
|
||||
- **D-Bus only for reading status.** Actions go through the CLIs: `iwctl` to join, scan and disconnect, `bluetoothctl` to pair and connect (the user had a D-Bus agent version reverted, `afe963d`). The one exception, chosen by the user: the shell serves `org.kde.StatusNotifierWatcher` and calls tray apps' dbusmenus. Ask before serving anything else on the bus.
|
||||
- **Never wake iwd by accident:** any D-Bus call to `net.connman.iwd` starts it (it then joins Wi-Fi and adds a route). Check `org.freedesktop.DBus.NameHasOwner` first; only talk to it while it has an owner.
|
||||
- **Root only through `doas -n` with fixed command lines** (`rc-service <svc> start|stop`, `cat /var/lib/iwd/<profile>.psk`). Strings from other programs (SSIDs, device names, D-Bus metadata) go to `Command` args, never into `amane::spawn`/`output` (`sh -c`). Passwords go to `wl-copy` on stdin, never as an argument; iwctl's `--passphrase` is visible in `ps` for about a second, which the user accepted.
|
||||
- Edits to AGENTS.md prompt for approval (`security.protected_instruction_files`); the user approves them at the screen, and a timed-out prompt is a no: don't retry by another path. Keep this file near 20k characters, Hermes's load limit.
|
||||
- **No hardcoded tuning values:** every size, timing, text size, colour or interval is a setting in `config.rs` (see Settings).
|
||||
- **Blur strength is Hyprland's only** (`decoration.blur.*`; the user keeps size 1 and the layer rule's `ignore_alpha = 0.5`). The shell never sets it.
|
||||
- **D-Bus mostly for reading status.** Actions go through the CLIs: `iwctl`, `bluetoothctl` (a D-Bus agent version was reverted, `afe963d`). The shell serves only what the user chose: `org.kde.StatusNotifierWatcher` (tray) and `org.freedesktop.Notifications` (Amane's own server; dunst is commented out in `~/.config/hypr/autocmds.lua`, backup `autocmds.lua.before-amane-notifications`). Ask before serving anything else.
|
||||
- **Never wake iwd by accident:** any D-Bus call to `net.connman.iwd` starts it. Check `NameHasOwner` first.
|
||||
- **Root only through `doas -n` with fixed command lines.** Strings from other programs go to `Command` args, never into `amane::spawn`/`output` (`sh -c`). Secrets go on stdin: `wl-copy`, `secret-tool store`, curl's `-K -` for the calendar. Never put a password in a file, an argument or a log.
|
||||
- **Local AI only:** the AI card talks to Ollama on the desktop, never a cloud service. Phone stats are out for now.
|
||||
- Edits to AGENTS.md prompt for approval (`security.protected_instruction_files`); a timed-out prompt is a no: don't retry by another path. Keep this file near 20k characters, Hermes's load limit.
|
||||
- Report honestly what was and wasn't checked on the real desktop.
|
||||
|
||||
## Current state
|
||||
|
||||
**master** (pushed): the island (pill, card, CD ring, EQ, output and player switching, hover animations, instant transport buttons), an idle pill when no player is open, one island per monitor (card on one monitor, live bars only on the focused one), a workspace pill per monitor, wallpaper colours through matugen, and:
|
||||
**master** (pushed): the island (pill, card, CD ring, EQ, output and player switching, hover animations), an idle pill without players, one island per monitor (live bars only on the focused one), a workspace pill per monitor, wallpaper colours through matugen, and:
|
||||
|
||||
- **Control centre** (top right): a status pill (Wi-Fi or hotspot, VPN, Bluetooth, Ethernet, battery) that opens a panel: tiles for Wi-Fi, Bluetooth, VPN, Hotspot, Ethernet; volume with the output, brightness, keyboard light, performance mode, external-GPU programs; a Settings button (gear) at its foot. The panel morphs into its menus as one shape: Wi-Fi and Bluetooth maps (click to join/pair, right-click a saved network for its password, click the centre to search again), VPN list, sound outputs, Ethernet details with copyable addresses. Popups (`magic-prompt`) ask for passwords, PINs and passkeys and say why something failed.
|
||||
- **Control centre** (top right): status pill opening a panel with tiles (Wi-Fi, Bluetooth, VPN, Hotspot, Ethernet), volume, brightness, keyboard light, performance mode, eGPU programs, Settings (gear). The panel morphs into its menus as one shape: Wi-Fi and Bluetooth maps, VPN list, sound outputs, Ethernet details. Popups (`magic-prompt`) ask for passwords, PINs and passkeys.
|
||||
- **System tray** right of the workspace pill.
|
||||
- **Bar hiding:** only on monitors showing a fullscreen window; the pointer on the top edge brings it back. Peeks: the workspace pill alone on a workspace change, the island on a song change or a new player. A peek starts from where the part is (no dip).
|
||||
- **Workspace switch:** Hyprland deletes an empty workspace the moment it's left; its slot stays for the slide, shrinking and fading, so the row closes up with the disc's move instead of jumping.
|
||||
- **Bar hiding** only on monitors showing a fullscreen window; the top edge brings it back. Peeks: the workspace pill on a workspace change; the island on a song change or new player, but not when the focused window's class is the playing player (`parse::same_app`).
|
||||
- **Workspaces:** a removed empty workspace's slot shrinks and fades during the slide; a created one (`createworkspacev2`) grows in.
|
||||
- **Reserved space:** windows that aren't fullscreen stay below the bar (44 px).
|
||||
- **Settings** (branch `config-window`, merged 2026-10-15): `~/.config/amane/config.toml` holds all 184 shell settings with their help; the config window (`Magic Island settings`, a normal window; Esc closes it) edits them plus every Hyprland option. See Settings.
|
||||
- **Settings:** `~/.config/amane/config.toml` holds every shell setting with its help; the config window edits them plus every Hyprland option. Saves are serialised by `config::WRITTEN`, and the file watch skips the shell's own saves (an earlier race reset settings to defaults).
|
||||
- **Glass designs:** `appearance.surface.design` = solid / frosted / liquid, drawn by `surface::shell()` on every surface. The user keeps all three and may want more. The old branches `glass-base`, `design-frosted`, `design-liquid` (also on origin) are superseded; deleting them was asked, no answer.
|
||||
|
||||
On 2026-10-13 the user tried the mouse-only parts (tray clicks, top-edge reveal, Copy buttons, PIN pairing): "All works". The agent itself only drives them over IPC.
|
||||
**Branch `side-pills`** (pushed, not merged; the user tries it first): two one-icon pills left of the island (AI, System) and two right (Home, Notifications); see Architecture and README "Side pills".
|
||||
|
||||
**Glass designs** (`glass-base`, `design-frosted`, `design-liquid`, pushed, not merged): `glass-base` adds `src/surface.rs` (`shell()` draws the island, idle pill and workspace pill; with glass below 1 the open/close fade uses `.opacity()`, as a veil can't hide content on see-through glass) and a `glass` opacity; `design-frosted` (0.62, thin tint, hairline white rim, top sheen, soft black shadow) and `design-liquid` (0.4, specular gradient, light rim warming to `flame` on hover, accent glow) build on it. They branched from `036b326`, before the control centre and the settings, and read `glass` from `shell.conf`, which master no longer has: port them as settings (`appearance.surface.*`), not as a revived `shell.conf`. Measured 2026-10-09 (pill / open card GPU ms per frame): solid 1.6 / 3.3, liquid 2.2 / 3.3, frosted 2.9 / 3.3. `~/.config/hypr/windowrules.lua` has the glass layer rule (`^magic-.*$`, blur, `ignore_alpha = 0.5`).
|
||||
**On the desktop:** the agent-started shell (pid in `~/.cache/amane-remote-test/shell.pid`, stderr in `shell.err`) runs the `side-pills` build; the user's login shell was stopped with their approval. `config.toml` has `pills.ai.model = gemma4:e4b`, design frosted at 0.7. `~/.config/hypr/hyprland.lua` did not `require("amane")` (checked 2026-10-15), so Hyprland options set in the window are lost on a reload; the user was told.
|
||||
|
||||
**On the desktop:** the agent-started shell runs the `config-window` build (pid in `~/.cache/amane-remote-test/shell.pid`, no frame log). `config.toml` was made from the old `shell.conf` (still there, unread). `~/.config/hypr/hyprland.lua` does **not** `require("amane")` yet (checked 2026-10-15), so Hyprland options set in the window are lost on a Hyprland reload; the user was told to add it. amane is not autostarted: after a reboot the user starts it.
|
||||
|
||||
**Theme pipeline:** Skwd-wall v2's matugen integration `amane` renders `~/.config/amane/matugen/amane.colors` into `~/.config/amane/theme` (scheme `scheme-fidelity`).
|
||||
**Theme pipeline:** Skwd-wall v2's matugen integration renders `~/.config/amane/matugen/amane.colors` into `~/.config/amane/theme`.
|
||||
|
||||
## Constraints of Amane itself
|
||||
|
||||
- Only `amane` and the Rust standard library: `amane compile` generates `Cargo.toml`, so no crates. External tools run as processes (`std::process::Command`, `amane::output`).
|
||||
- Only `amane` and the Rust standard library: `amane compile` generates `Cargo.toml`, so no crates. External tools run as processes (`Command`, `amane::output`); HTTP is curl.
|
||||
- Amane redraws the whole window every frame, so every effect is paid at the frame rate.
|
||||
- Amane can only blur what the shell draws itself; frosting the desktop behind it needs a Hyprland layer rule.
|
||||
- `amane compile` saves `~/.cache/amane/amane-shell`; `amane run` starts it.
|
||||
@@ -56,68 +56,64 @@ On 2026-10-13 the user tried the mouse-only parts (tray clicks, top-edge reveal,
|
||||
|
||||
| Piece | Kind | Job |
|
||||
|---|---|---|
|
||||
| `config.rs` | settings | Every setting declared once in `settings! { part { group { name: type = default, range; "help" } } }`: a typed accessor (`config::island::pill::width()`) plus an `OPTIONS` entry (path, help, kind, range, default) for the window. Values live in a `RwLock`; reading per draw costs nothing measurable. `load()` reads `config.toml` (first start: writes it, moving `shell.conf`'s 4 values over); `save()` rewrites it with every setting and its help. `[hyprland.*]` tables hold Hyprland overrides. |
|
||||
| `settings.rs` | view, normal `Window` | The config window: a page per part and per Hyprland category, search over everything, a row per setting (switch, slider + field, text with colour swatch, choice chips), reset per row, `config.toml` errors at the top. Esc closes. The `config` IPC handler (`command`). |
|
||||
| `hypr.rs` `Hypr` | Service, worker thread | Hyprland's options from Hyprland itself (`hyprctl descriptions -j`, one `--batch` of `j/getoption`), so no option is named in code. `set`/`reset` queue jobs (superseded ones dropped); the worker applies with `hl.config`, rewrites `~/.config/hypr/amane.lua` and `config.toml`; a reset reloads Hyprland's config so the user's value returns. |
|
||||
| `island.rs` `Island` | input-only Service | `open: Animation` (0 pill, 1 card), `expanded`, `selected` player (`None` follows the playing one), open `menu`, time-bar `scrub`, `hover` (per-`Spot`), `opened_on` / `pointer_on` (monitors), `playing_now`. Also the view and the `island` IPC handler. |
|
||||
| `sound.rs` `Sound` | Service, poll | `pactl list sinks` + `sink-inputs` → each player's streams (by process ancestry, then app name), volume, mute, output, equalizer; `presets()` from settings. Curves saved by player identity to `~/.config/amane/island-eq`. |
|
||||
| `spectrum.rs` `Spectrum` | Service | While the shown player plays: `pw-record --target <stream> \| cava -p $XDG_RUNTIME_DIR/magic-island/cava.conf` → levels (`sound.spectrum.bars`). On start it `pkill -KILL`s cava left by an earlier shell (cava ignores SIGTERM). |
|
||||
| `art.rs` `Art` | Service, poll | Cover URL → local file, copied to `palette-cover`, which `Palette` (opened in `main.rs`) watches. Only used for `cover_colors`. |
|
||||
| `theme.rs` `Theme` | Service, `amane::watch_file` | 10 colours from `~/.config/amane/theme`, faded together on one `Animation<Colors>`; also watches `config.toml` (own thread) and reloads it. Views start with `theme::begin()` and draw with `theme::now()`; `theme::touch()` redraws every view. No theme file: `appearance.fallback.*`. `watch_file` watches the folder, so matugen's rename-over saves count. |
|
||||
| `workspaces.rs` | view + input-only `Hover` | Workspace pill per monitor (`magic-workspaces`, top left), slots placed by x in one `Stack` under one sliding accent disc. Click runs `hyprctl dispatch 'hl.dsp.focus({ workspace = N })'` on a thread. |
|
||||
| `parse.rs` | plain functions | pactl/cava/proc parsing, filter-chain config, EQ store, `key = value`, colours, iwctl/bluetoothctl output, Wi-Fi bars, `hyprctl` JSON, `ip`, tray icons; the TOML reader/writer (`Value`, `toml`), a JSON reader (`json`, skips non-JSON lines between values), Hyprland option values (`hypr_option`, `hypr_lua`). Unit-tested on its own. |
|
||||
| `control.rs` `Control` | view + input-only Service | The control centre (`magic-control`) and its menu window (`magic-control-menu`: the panel morphs to the menu's size, then its content fades in). `menu_content(menu, map_w, ..)`, `map_width(monitor_w)`, the `control` IPC handler. |
|
||||
| `system.rs` `Links`, `Power`, `Gpu` | Services, threads | iwd (D-Bus reads, only while it has an owner), openvpn, bluez state, wired cards from sysfs, battery, brightness, keyboard light, power profile, eGPU programs from fdinfo; actions via CLIs. Intervals in `control.polling.*`. |
|
||||
| `prompt.rs` `Prompt` | view + input-only Service | The popup (`magic-prompt`): password, PIN, passkey, confirm, code, saved password, failure reason. Runs the `bluetoothctl` pairing session. `prompt::copy(text, done)` is the one wl-copy. |
|
||||
| `map.rs` | widget functions | The Wi-Fi/Bluetooth maps: nodes on an ellipse round the centre bubble (nearer = stronger), floating, lines, search rings. Sizes in `control.maps.*` (8 nodes at most: a 9th makes labels meet at 822 px; picked by simulating the drift). |
|
||||
| `glyph.rs` | shapes | Every icon as `Shape`s; maps draw all of theirs into ONE canvas (`glyph_shapes`), as each `Canvas` has a large fixed GPU cost. |
|
||||
| `edge.rs` `Edge` | Service, Hyprland sockets | `.socket2.sock` events and `.socket.sock` queries, no polling. Hides the bar on fullscreen monitors, the 1 px `magic-edge` strip, peeks, the island's pop, the workspace slide (`switched`), `hide_h()`, and `spacer` (`magic-reserve`, 44 px). |
|
||||
| `tray.rs` `Tray`, `TrayUi` | Services, bus threads | Owns `org.kde.StatusNotifierWatcher` (only if no other watcher runs) and is its host; icons from themes or `IconPixmap`; dbusmenu menus in `magic-tray-menu`; the `tray` IPC handler. |
|
||||
| `png.rs` | plain functions | Stored-deflate PNG writer for pixmap tray icons. |
|
||||
| `config.rs` | settings | Every setting declared once in `settings! { part { group { name: type = default, range [choices]; "help" } } }`: a typed accessor plus an `OPTIONS` entry for the window. `load()`/`save()` of `config.toml`, `set`/`reset`; `[hyprland.*]` tables hold Hyprland overrides. |
|
||||
| `settings.rs` | view, normal `Window` | The config window: a page per part and per Hyprland category, search, a row per setting (switch, slider + field, text with swatch, chips), reset per row. Esc closes. `config` IPC. |
|
||||
| `hypr.rs` `Hypr` | Service, worker | Hyprland's options from `hyprctl descriptions -j` and one `--batch` of `j/getoption`. `set`/`reset` apply with `hl.config`, rewrite `~/.config/hypr/amane.lua` and `config.toml`. |
|
||||
| `surface.rs` | widget function | `shell(w, h, radius, colors, lift, content)`: the chosen glass design for every surface. |
|
||||
| `island.rs` `Island` | input-only Service | The island: `open: Animation`, player selection, menus, scrub, hover spots, `width_on(monitor)` (for the pods), `island` IPC. `expand()` closes a pod card. |
|
||||
| `pods.rs` `Pods` | view + input-only Service | Side pills: windows `magic-pods-left|right` per monitor beside the island, moving out as it opens; a pill morphs into its card (one timeline, `pills.layout.open_ms`). One card open across the shell. `header`, `chip`, `button`, `rule` helpers for the cards. `pods` IPC (`open|toggle <pod> [monitor]`, `close`, `ask`, `processes`, `notices …`, `status`). |
|
||||
| `ai.rs` `Ai` | Service | Ollama `/api/chat` via curl, streamed line by line (`parse::Chunk`); starts `ollama` via doas when it doesn't answer. Stop and New chat bump `round`, so a late line from an old answer is dropped. |
|
||||
| `stats.rs` `Stats` | Service, thread | CPU (`/proc/stat`), GPU clock (xe/i915 sysfs), memory, network, disk sectors of physical disks, hottest sensor per hwmon. All graphs in one canvas. Processes runs `ghostty --class=amane.monitor -e btm` via `hl.exec_cmd` on `special:monitor`, then toggles it. |
|
||||
| `home.rs` `Clock`, `Forecast`, `Agenda` | Services, threads | Avatar (`~/.face`, else a glyph), clock, open-meteo weather (Munich by default), month grid, CalDAV (iCloud by default): discovery, `REPORT` with `expand`, `parse::ics_events`. Sign-in stores an app-specific password with `secret-tool` (`service amane-calendar account <id>`). |
|
||||
| `notices.rs` `Notices` | Service, poll 250 ms | On top of Amane's `Notifications` server: popups (`magic-notifications`, top right of the focused monitor, timeouts by urgency, paused on hover), pictures found once per notification (`tray::icon_file`), unread count, the history card (cross = `dismiss`, Clear all, Mute = `notifications.popups.enabled`). A popup click without a default action only hides it; the server's `click` would delete it. The server itself never expires notifications. |
|
||||
| `sound.rs` `Sound` | Service, poll | `pactl` sinks and sink-inputs → each player's streams, volume, mute, output, equalizer. Curves in `~/.config/amane/island-eq`. |
|
||||
| `spectrum.rs` `Spectrum` | Service | `pw-record --target <stream> \| cava` → levels while the shown player plays; kills stale cava with `-KILL`. |
|
||||
| `art.rs` `Art` | Service, poll | Cover → `palette-cover` for `cover_colors`. |
|
||||
| `theme.rs` `Theme` | Service, `watch_file` | 10 colours from `~/.config/amane/theme` faded on one `Animation`; also watches `config.toml`. Views start with `theme::begin()`, draw with `theme::now()`; `theme::touch()` redraws all. |
|
||||
| `workspaces.rs` | view + `Hover` | Workspace pill per monitor; slots by x in one `Stack` under one sliding disc; ghost and growing slots. |
|
||||
| `parse.rs` | plain functions | All parsing, unit-tested: pactl/cava/proc, EQ store, colours, iwctl/bluetoothctl, `hyprctl` JSON, TOML (`Value`), JSON, Hyprland values, `same_app`, Ollama chunks, /proc stats, weather, WebDAV XML, iCalendar, civil dates, notification body markup. |
|
||||
| `control.rs`, `map.rs`, `prompt.rs`, `system.rs` | views, Services | Control centre and its menus, the maps (one canvas each), the popups, the network/power/GPU services. |
|
||||
| `glyph.rs` | shapes | Every icon as `Shape`s; draw many into ONE canvas (`glyph_shapes`), as each `Canvas` has a large fixed GPU cost. |
|
||||
| `edge.rs` `Edge` | Service, Hyprland sockets | socket2 events, no polling: bar hiding, `magic-edge`, peeks, workspace slide (`switched`, `created`), `hide_h()`, `magic-reserve`. |
|
||||
| `tray.rs`, `png.rs` | Services | StatusNotifierWatcher and host, icons from themes or pixmaps, dbusmenu menus. |
|
||||
|
||||
The equalizer: one `setsid -f pipewire -c eq-<key>.conf` per player with `libpipewire-module-filter-chain`, 10 biquads at `sound.equalizer.bands`. The player's streams move into sink `magic_island_eq.<key>`; gains change live with `pw-cli set-param`. A player only gets one when its curve or output first changes, or it has a saved non-flat curve.
|
||||
### The island view
|
||||
|
||||
### The view (island.rs `view`)
|
||||
|
||||
- One `LayerWindow` per monitor (`magic-island`, `Layer::Overlay` because the user's windows are fullscreen), sized for the open card; an `InputArea` lets clicks outside through. No player: `idle()`, a click-through pill.
|
||||
- `tap(monitor)` opens the card where it was clicked, `expand()` (IPC) on the focused monitor. Only the focused window and the one with the card read `Spectrum` (reading subscribes), so the rest stay idle.
|
||||
- `phases(open)` splits one timeline (`island.animation.open_ms`): pill fades out, the empty shape grows until `shape_done`, then the card fades in. At most one face per frame; `veiled()` fades with a backdrop-coloured veil instead of `.opacity()`.
|
||||
- Background: one 150° three-stop gradient (`backdrop()`); with `cover_colors` the card blends to the cover's colours as it opens. White-alpha overlays (`appearance.overlays.*`, `CLEAR`) stay unthemed.
|
||||
- Hover: `hovering(spot)` eases a `Spot` over `appearance.motion.hover_ms`. Amane matches hover targets by index in the last frame, so a vanished button never hears the pointer leave: entering a spot fades all others, and `collapse()`/`set_menu()` drop stale highlights.
|
||||
- Transport: play/pause flips at once through `playing_now`; `refresh_soon()` re-reads Media at 150 and 600 ms.
|
||||
- One `LayerWindow` per monitor (`magic-island`, `Layer::Overlay`), sized for the open card; an `InputArea` passes clicks outside through.
|
||||
- `phases(open)` splits one timeline: pill fades out, the empty shape grows, then the card fades in. At most one face per frame; `veiled()` fades with a backdrop-coloured veil.
|
||||
- Hover: Amane matches hover targets by index in the last frame, so a vanished button never hears the pointer leave: entering a spot fades all others, and `collapse()`/`set_menu()` drop stale highlights.
|
||||
|
||||
## Settings
|
||||
|
||||
- **Add a setting** by declaring it in `config.rs`'s `settings!` block in the right part and group, with a range and a one-line help; use the accessor in code. Derived values (`inner_w()` and the like) are functions over settings, not constants. Text sizes are roles (`appearance.text.tiny|caption|small|label|body|heading|section|title|popup`), not numbers. Remaining `const`s are names and identities only (D-Bus names, `CLEAR`, `TAU`).
|
||||
- **Add a setting** in `config.rs`'s `settings!` block with a range and a one-line help; use the accessor in code. Derived values are functions over settings. Text sizes are roles (`appearance.text.*`). Remaining `const`s are names and identities only.
|
||||
- The font and cava's bar count apply on restart; everything else on save.
|
||||
- `amane ipc call config get|set|reset <path> [value]` drives everything without the mouse (`hyprland.<option.path>` for Hyprland, e.g. `hyprland.decoration.blur.size`); `status` says `open=… page=… errors=… search=…`.
|
||||
- `hl.config` value forms: numbers and bools plain, gradients `{ colors = { "rgba(…)" }, angle = 45.0 }`, vec2 `{ x, y }`, css gaps a number or `{ top, right, bottom, left }`. `hyprctl --batch` of all `j/getoption` prints plain "invalid type (internal error)" lines between objects for a few options.
|
||||
- `amane ipc call config get|set|reset <path> [value]` (`hyprland.<option.path>` for Hyprland); `status` says `open=… page=… errors=… search=…`.
|
||||
- `hl.config` value forms: numbers and bools plain, gradients `{ colors = { "rgba(…)" }, angle = 45.0 }`, vec2 `{ x, y }`, css gaps a number or `{ top, right, bottom, left }`.
|
||||
|
||||
## Hyprland 0.56 (Lua config) pitfalls
|
||||
|
||||
- **Never return `visible(false)` from a layer view**: a hidden-at-start layer window stays blank forever ("has no child to draw"). Return `window.click_through().child(Rectangle::new().width(0.0).height(0.0))`.
|
||||
- **`Workspaces::focus` does nothing** (sends `dispatch workspace N`). Use `hl.dsp.focus({ workspace = N })`.
|
||||
- `hyprctl eval` / `repl` share one Lua state; globals (a rule kept in a variable) vanish on a config reload.
|
||||
- Layer-rule `match.namespace` is a full match: `^magic-.*$`. Layer rules can only switch blur on; strength is global.
|
||||
- `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead instances; the live one has `.socket.sock`. A shell started with a dead signature gets an empty `Workspaces` list.
|
||||
- **Never return `visible(false)` from a layer view**: a hidden-at-start layer window stays blank forever. Return `window.click_through().child(Rectangle::new().width(0.0).height(0.0))`.
|
||||
- **`Workspaces::focus` does nothing.** Use `hl.dsp.focus({ workspace = N })`. Special workspaces: `hl.exec_cmd(cmd, { workspace = "special:x silent" })`, `hl.dsp.workspace.toggle_special("x")`.
|
||||
- `hyprctl eval` / `repl` share one Lua state; globals vanish on a config reload.
|
||||
- Layer-rule `match.namespace` is a full match: `^magic-.*$`.
|
||||
- `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead instances; the live one has `.socket.sock`.
|
||||
- `hyprctl layers` lists hidden layer windows too (alpha 0).
|
||||
- **Reserved space:** `Zone::Reserve` reserves the whole window height and only on a window anchored to one edge. Tiled and maximised windows stay out of the strip; fullscreen 2/3 cover it. Bar windows keep `Zone::Ignore`. Check with `hyprctl monitors` (`reserved: 0 44 0 0`).
|
||||
- A peek only shows on the **focused** monitor; test with a workspace hop there, then restore the focus.
|
||||
- `hl.dsp.window.float(...)` acts on the focused window. To screenshot the config window floating, add a temporary rule: `hyprctl eval 'r = hl.window_rule({ name = "amane-settings", match = { title = "^Magic Island settings$" }, float = true, center = true, size = { 1100, 760 } })'`.
|
||||
- **Reserved space:** `Zone::Reserve` reserves the whole window height, only on a window anchored to one edge. Check with `hyprctl monitors` (`reserved: 0 44 0 0`).
|
||||
- A peek only shows on the **focused** monitor. A monitor whose workspace has a fullscreen window hides the bar, pods included (an open pod card stays).
|
||||
|
||||
## Performance rules
|
||||
|
||||
- **Measure before and after every visual change**, baseline first, same player and monitor: Amane's per-frame `gpu` time and `top` CPU (the iGPU busy counter reads 100% even with the shell paused).
|
||||
- Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in. Don't resize a layer window every frame: give it the largest size for the animation (the workspace ghost does).
|
||||
- `.clip()` and `.opacity(<1)` each add a GPU layer (a clip over the whole card once took the iGPU from 47 to 9 fps); a shadow is two. Halos only while hovered.
|
||||
- One backdrop gradient is affordable, many aren't. A veil over a gradient must be the same gradient at the same size.
|
||||
- Draw repeated marks (candles, bars) as one `Path` per look.
|
||||
- Streaming data needs `amane::request_frame()` in the view while levels are live.
|
||||
- **Measure before and after every visual change**, same player and monitor: Amane's per-frame `gpu` time and `top` CPU.
|
||||
- Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in. Don't resize a layer window every frame: size it for the whole animation.
|
||||
- `.clip()` and `.opacity(<1)` each add a GPU layer; a shadow is two. Halos only while hovered.
|
||||
- One backdrop gradient is affordable, many aren't.
|
||||
- Draw repeated marks as one `Path` per look; many icons or graphs in one `Canvas`.
|
||||
- Streaming data needs `amane::request_frame()` while it's live. Slow work (icon lookups, curl, sysfs walks) belongs on Service threads, never in a view.
|
||||
|
||||
## Develop
|
||||
|
||||
1. Type-check in a scratch crate outside the repo (don't commit a `Cargo.toml`), at `/root/.hermes/cache/scratch/amane-check/` (pruned after 24 h idle): `[package] name = "amane-shell"`, edition 2024, `[[bin]] path = '/root/amane-config/src/main.rs'`, `amane = { path = '<copy of the desktop's ~/git/amane>' }`, an empty `[workspace]`. Then `cargo clippy` (first build takes minutes).
|
||||
2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (12 tests).
|
||||
1. Type-check in a scratch crate outside the repo (don't commit a `Cargo.toml`), at `/root/.hermes/cache/scratch/amane-check/` (pruned after 24 h idle): `[package] name = "amane-shell"`, edition 2024, `[[bin]] path = '/root/amane-config/src/main.rs'`, `amane = { path = '<copy of the desktop's ~/git/amane>' }`, an empty `[workspace]`. Then `cargo clippy`. Fetch the copy with `ssh … 'tar cf - --exclude=target --exclude=.git -C ~/git amane'`.
|
||||
2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (21 tests).
|
||||
|
||||
## Testing on the desktop
|
||||
|
||||
@@ -127,23 +123,21 @@ The equalizer: one `setsid -f pipewire -c eq-<key>.conf` per player with `libpip
|
||||
H=fabian@192.168.2.169
|
||||
tools/amane-remote.sh $H deploy src # back up the remote src once, then replace it
|
||||
tools/amane-remote.sh $H build # amane compile
|
||||
tools/amane-remote.sh $H start # stop the shell it started earlier, start one WITH frame logging
|
||||
tools/amane-remote.sh $H start # start a shell WITH frame logging
|
||||
tools/amane-remote.sh $H fps 3 # frames, average gap, gpu wait
|
||||
tools/amane-remote.sh $H anim island # open/close 3x; stutter = 20-500 ms gaps
|
||||
tools/amane-remote.sh $H sinks start # null sinks island_test_a/_b
|
||||
tools/amane-remote.sh $H players start 4 # fake MPRIS players; "Synth Test" plays into island_test_a
|
||||
tools/amane-remote.sh $H players start 4 # fake MPRIS players
|
||||
tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical session's environment
|
||||
```
|
||||
|
||||
- Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound.
|
||||
- `ipc island open` opens on the **focused** monitor. Read geometry from `hyprctl layers -j` right before each `grim` capture. With one window per monitor the frame log mixes windows.
|
||||
- **Animations:** capture a strip in a loop (`grim -g "3840,0 300x60"` takes ~20 ms) while dispatching the change, then tile the frames with ffmpeg (`tile=2x12`) and look at one sheet.
|
||||
- Keys to a focused window: `wtype -k Escape`. Hover can't be faked with `movecursor`; real motion moves the user's mouse, so ask first.
|
||||
- Control centre and tray without a mouse: `ipc control open|close|menu <wifi|bluetooth|vpn|sound>|ethernet|status` (`status` ends with `prompt=…`), `ipc tray list|menu|open N|activate N|choose ID|close`. Fake tray app: `python3 tools/fake_tray.py name|path`; kill it by pid.
|
||||
- Maps with many nodes: deploy a scratch `src/` whose `map::map` injects fake nodes (never commit it), then redeploy the real one.
|
||||
- Measure gaps and alignment from pixels (`ffmpeg … crop=1920:1:0:Y -f rawvideo`), not by asking a vision model.
|
||||
- To tune a visualiser, sample real levels read-only first (`pw-record --target <stream> | cava`, `timeout -s KILL` on each stage); typical music sits at 0.15–0.35.
|
||||
- Check `uptime` before trusting timings. In `pkill -f` patterns over SSH use `[p]ython3 ...`. A fresh shell once died with "lost the gpu"; starting again worked.
|
||||
- `ipc island open` and `ipc pods open X` act on the **focused** monitor (pods take a monitor name too). Read geometry from `hyprctl layers -j` right before each `grim` capture, on a monitor without a fullscreen window.
|
||||
- **Animations:** capture a strip in a loop (`grim -g "3840,0 300x60"`) while dispatching the change, then tile the frames with ffmpeg.
|
||||
- Keys: `wtype -k Escape`. Hover can't be faked with `movecursor`; real motion moves the user's mouse, so ask first.
|
||||
- Without a mouse: `ipc control open|close|menu …|status`, `ipc tray list|menu|open N|…`, `ipc pods …`. Notifications: `notify-send -a App -i icon -u critical -A key=Label …`, then `ipc pods notices list`.
|
||||
- Measure gaps and alignment from pixels (`ffmpeg … crop=… -f rawvideo`), not by asking a vision model.
|
||||
- Check `uptime` before trusting timings. In `pkill -f` patterns over SSH use `[p]ython3 ...`.
|
||||
|
||||
### After testing
|
||||
|
||||
@@ -151,17 +145,18 @@ tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical sessi
|
||||
tools/amane-remote.sh $H players stop
|
||||
tools/amane-remote.sh $H sinks stop
|
||||
tools/amane-remote.sh $H sh 'kill $(cat shell.pid); for i in $(seq 50); do pgrep -F shell.pid > /dev/null || break; sleep 0.1; done
|
||||
rm -f shell.log; setsid -f sh -c "echo \$\$ > shell.pid; exec ~/.cache/amane/amane-shell" > /dev/null 2>&1'
|
||||
rm -f shell.log; setsid -f sh -c "echo \$\$ > shell.pid; exec ~/.cache/amane/amane-shell 2> shell.err" > /dev/null 2>&1'
|
||||
```
|
||||
|
||||
## Open threads
|
||||
|
||||
- The user reviews the glass designs, ported onto master as settings; then merge the chosen one.
|
||||
- **Side pills, untested by the agent:** the calendar (the user signs in later; CalDAV discovery and the iCloud answer have never run), clicks and typing in the cards (only IPC was used), the AI answer in the card (the API answered in 0.8 s; the card capture came after the user closed it), the popups' new right alignment, Processes (ghostty + btm), the avatar (`~/.face` doesn't exist yet).
|
||||
- During the notification test all three test notifications left the history within 6 s and the Firefox one's Open action fired, apparently from the user's clicks at the desk; not confirmed.
|
||||
- The user adds `require("amane")` to `hyprland.lua` (or asks the agent to).
|
||||
- The config window was only driven over IPC and Esc; clicking, dragging and typing in it are untested by the agent.
|
||||
- The config window was only driven over IPC and Esc.
|
||||
- The candle ring is still mirrored (`mirrored()`); the pill's bars aren't.
|
||||
- Upstream Amane fixes for the two Hyprland 0.56 issues (hidden layer at start, `Workspaces::focus`): not started.
|
||||
- `island-eq` is tracked but rewritten live; untracking it was proposed, no answer yet. Leftovers offered for deletion, no answer: `src.before-test/` and old screenshots in `~/.cache/amane-remote-test/` on the desktop, merged local branches on the dev host.
|
||||
- On a monitor narrower than about 1500 px the maps stop at 600 px and run under the island.
|
||||
- The open/close stutter rise (25–40 slow frames per 3 opens and closes on every build) is unexplained.
|
||||
- Upstream Amane fixes for the two Hyprland 0.56 issues: not started.
|
||||
- `island-eq` is tracked but rewritten live; untracking it was proposed, no answer. Leftovers offered for deletion, no answer: `src.before-test/` and old screenshots on the desktop, merged and superseded local branches.
|
||||
- On a monitor narrower than about 1500 px the maps run under the island.
|
||||
- The open/close stutter rise (25–40 slow frames per 3 opens and closes) is unexplained.
|
||||
- See README "Known limits".
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card.
|
||||
|
||||
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing".
|
||||
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing". It opens too, into the last song played (kept across restarts) with Play, which starts its player and asks it to play, and chips that start the players of `island.idle.players` (desktop file names, `gtk-launch`).
|
||||
- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next.
|
||||
- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays.
|
||||
- **Output switcher:** moves only the shown player's audio to another device.
|
||||
@@ -111,15 +111,34 @@ The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`).
|
||||
|
||||
Whether the open card takes the song cover's colours instead (`appearance.colors.cover_colors`), the fade time, and the colours used until there is a theme file are [settings](#settings).
|
||||
|
||||
## 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.
|
||||
|
||||
- **AI** (sparkle): a chat with an Ollama model on your own servers, `gemma4:e4b` at `http://localhost:11434` by default. `pills.ai.servers` takes several, comma-separated, in order: the model chip in the header lists every server's models (each once, with its server when there are several) and switches at once, and a question goes to the first server that has the chosen model. Answers stream in through curl. When Ollama doesn't answer, the shell starts the OpenRC service with `doas rc-service ollama start` (`pills.ai.start_service`). Stop ends an answer and New chat starts over. Nothing goes to a cloud service, only to the servers listed.
|
||||
- **System** (pulse): CPU, the integrated GPU's clock, the external GPU's load (with its video memory and the programs on it, formerly in the control centre), memory, swap, network and disk read/write as graphs over the last minute, plus the hottest sensors with icons, in cool/warm/hot colours (`pills.monitor.warm|hot`). Everything is read from `/proc` and `/sys`, so there is no process list. Processes opens `ghostty --class=amane.monitor -e btm` on the special workspace `monitor`; a second press shows or hides it.
|
||||
- **Home** (house): `~/.face` as the avatar (a person glyph until the file exists), a greeting, the date, a big clock, and Munich's weather now and for four days from open-meteo (no key), over a photo of the current weather (`~/.config/amane/weather/<kind>-<day|night>.jpg`, downloaded once from Wikimedia Commons, credits in `weather/CREDITS.md`; swap any file, `pills.home.photo_opacity` tones it down). A big month grid marks events (dots in their calendars' colours), birthdays from your iCloud contacts (a cake) and the public holidays of `pills.calendar.holidays` (BY by default); click a day for its list. Events can be created, edited and deleted there (a series: title, place and calendar only). For iCloud, set `pills.calendar.account` to your Apple ID and store an [app-specific password](https://account.apple.com) in the keyring: `secret-tool store --label='Magic Island calendar' service amane-calendar account <Apple ID>` (gnome-keyring or another Secret Service has to run). The password never goes into `config.toml`. Other CalDAV/CardDAV servers work through `pills.calendar.server` and `contacts_server`.
|
||||
- **Notifications** (bell, with the unread count): Amane is the notification daemon (`org.freedesktop.Notifications`), so dunst must not run; it is commented out in `~/.config/hypr/autocmds.lua` (the old file is beside it as `autocmds.lua.before-amane-notifications`). Popups appear at the top right of the focused monitor with the app's icon or image, summary, body (simple markup) and action buttons. They time out by urgency (critical ones stay) and wait while the pointer is over them. A click runs the default action. The card lists the history, newest first; each entry's cross deletes it, and Clear all deletes everything. Mute app keeps an app's notifications in the history without popups; Block app drops them (`notifications.apps.muted|blocked`, undone from chips at the top of the card). Do not disturb turns all popups off.
|
||||
|
||||
`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).
|
||||
|
||||
## 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.
|
||||
|
||||
The config window edits the same file: Settings at the foot of the control centre, or `amane ipc call config toggle`. It is a normal window with a page per part of the shell, a page per Hyprland category, and a search over everything. Numbers with a range get a slider and a field, switches toggle, colours show a swatch; a typed value applies on Enter. Reset brings back the default.
|
||||
The config window edits the same file: Settings at the foot of the control centre, or `amane ipc call config toggle`. It is a normal window with a page per part of the shell, a page per Hyprland category, and a search over everything; typing with no field selected (as right after opening it) goes into the search. Numbers with a range get a slider and a field, switches toggle, colours show a swatch; a typed value applies on Enter. Reset brings back the default.
|
||||
|
||||
### Glass
|
||||
|
||||
`appearance.surface.design` picks what the island, the pills, the tray menu, the control centre and its popups are made of: `solid` (the wallpaper's gradient with an accent glow), `frosted` (a thin tint, a hairline white rim, a sheen along the top, a soft shadow) or `liquid` (clear glass, denser toward the bottom right, a light-catching edge, the accent's glow). Each design's opacity, rim and light are settings of their own (`appearance.solid|frosted|liquid.*`); the look itself is `src/surface.rs`. On see-through glass a face fades with `.opacity()` instead of a veil, a GPU layer only while it fades.
|
||||
`appearance.surface.design` picks what the island, the pills, the tray menu, the control centre and its popups are made of: `solid` (the wallpaper's gradient with an accent glow), `frosted` (a thin tint, a hairline white rim, a sheen along the top, a soft shadow) or `liquid` (clear glass, denser toward the bottom right, a light-catching edge, the accent's glow). Each design's opacity, rim and light are settings of their own (`appearance.solid|frosted|liquid.*`); the look itself is `src/surface.rs`, drawn by one shader (`src/surface.wgsl`: the shadow, the fill and the sheen or lit edge in one pass; the rim stays a border). On see-through glass a face fades with `.opacity()` instead of a veil, a GPU layer only while it fades.
|
||||
|
||||
### Performance
|
||||
|
||||
Amane draws rectangles, borders, letters and pictures as cheap quads, and gradients, shadows and `Canvas` paths with vello, which renders over the whole window and then lays the result on top. vello keeps a result between frames only when its shapes didn't change and hold no gradient, so anything drawn with it on every frame costs a pass over the whole window. What redraws every frame is therefore kept off vello: the surfaces are a shader, the pill's bars are rounded rectangles, the candle ring and the equalizer's curve are shaders (`src/ring.wgsl`, `src/curve.wgsl`). A shader's file is written to `$XDG_RUNTIME_DIR/magic-island/` when it is first drawn. A shader must not contain the word `time`, or amane redraws its window on every frame.
|
||||
|
||||
`tools/perf.sh PID [SECONDS]` (on the desktop) prints a process's CPU (and its children's), GPU per engine (DRM fdinfo) and memory.
|
||||
|
||||
Amane can only blur what the shell draws, so the frosting comes from Hyprland: a layer rule for the shell's windows (the namespace match is a full match). `ignore_alpha` leaves pixels at or below that opacity unblurred, which keeps the clear margin around the pills sharp; keep it below the design's thinnest part, or that part shows the desktop unblurred (liquid at its default 0.4 is 0.22 in its top left corner).
|
||||
|
||||
@@ -164,14 +183,15 @@ src/png.rs a stdlib png writer, for tray icons sent as pixels
|
||||
src/system.rs what the control centre shows: iwd, wired cards, openvpn, power, the external GPU
|
||||
src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one
|
||||
src/theme.rs the shell's colours: the matugen theme file, the cross-fade; watches config.toml
|
||||
src/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs
|
||||
src/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs as values for surface.wgsl
|
||||
src/*.wgsl shaders: surface (every surface), ring (the candles), curve (the equalizer's line)
|
||||
src/config.rs every setting, declared once (typed accessors + the window's list), config.toml read and written
|
||||
src/hypr.rs Hyprland's options for the config window: hyprctl descriptions/getoption, hl.config, amane.lua
|
||||
src/settings.rs the config window, `config` IPC
|
||||
src/parse.rs pure text parsing with unit tests, no amane dependency
|
||||
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme
|
||||
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq)
|
||||
tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), see AGENTS.md
|
||||
tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), perf.sh (CPU, GPU, memory of a process), see AGENTS.md
|
||||
```
|
||||
|
||||
## Requirements
|
||||
@@ -225,8 +245,9 @@ bind = SUPER, M, exec, amane ipc call island toggle
|
||||
|
||||
- `~/.config/amane/config.toml`: every setting (see [Settings](#settings)); rewritten by the config window.
|
||||
- `~/.config/hypr/amane.lua`: the Hyprland options set in the config window, as `hl.config`; rewritten by the shell.
|
||||
- `$XDG_STATE_HOME/amane/` (`~/.local/state/amane/`): `last-played` (the song the island last heard, `key = value`) and a copy of its cover, for the card shown while nothing plays.
|
||||
- `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`.
|
||||
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout.
|
||||
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`, the shaders (`*.wgsl`). On tmpfs, gone at logout.
|
||||
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
|
||||
|
||||
## Known limits
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use amane::{Center, Column, End, Full, Rectangle, Row, Scroll, Service, Stack, Start, Text, TextInput, Widget};
|
||||
|
||||
use crate::config::appearance::{overlays, text};
|
||||
use crate::config::pills::ai as settings;
|
||||
use crate::glyph::Glyph;
|
||||
use crate::island::faded;
|
||||
use crate::parse::{self, Chunk};
|
||||
use crate::{pods, theme};
|
||||
|
||||
/*
|
||||
* the AI card: a chat with a model on your own ollama servers (pills.ai.servers) through
|
||||
* their http api (curl, streamed line by line), never a cloud service. a question goes to
|
||||
* the first server that has the chosen model. a local ollama isn't kept running: it's
|
||||
* started (doas, the same fixed rc-service rule the control centre uses) when it doesn't
|
||||
* answer and no other server has the model
|
||||
*/
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Who {
|
||||
You,
|
||||
Model,
|
||||
}
|
||||
|
||||
pub struct Ai {
|
||||
pub said: Vec<(Who, String)>,
|
||||
pub busy: bool,
|
||||
// what's going on or what went wrong, under the chat
|
||||
pub note: Option<String>,
|
||||
// how far the chat is scrolled back from its newest line, in px
|
||||
back: f32,
|
||||
// the question being typed, for the send button
|
||||
typed: String,
|
||||
// bumped by Stop and New chat, so an answer still streaming stops writing
|
||||
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 {
|
||||
fn new() -> Self {
|
||||
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
|
||||
fn listen() {}
|
||||
}
|
||||
|
||||
const INPUT: &str = "ai-input";
|
||||
|
||||
// the curl streaming the answer now, so Stop can end it
|
||||
static ASKING: Mutex<Option<Child>> = Mutex::new(None);
|
||||
|
||||
// a question from outside (ipc), as if typed
|
||||
pub fn ask(words: String) {
|
||||
send(words);
|
||||
}
|
||||
|
||||
fn send(words: String) {
|
||||
let words = words.trim().to_string();
|
||||
|
||||
if words.is_empty() || Ai::read().busy {
|
||||
return;
|
||||
}
|
||||
|
||||
TextInput::set_text(INPUT, "");
|
||||
|
||||
let round = {
|
||||
let mut ai = Ai::write();
|
||||
|
||||
ai.said.push((Who::You, words));
|
||||
ai.said.push((Who::Model, String::new()));
|
||||
ai.busy = true;
|
||||
ai.note = None;
|
||||
ai.back = 0.0;
|
||||
ai.typed.clear();
|
||||
ai.round
|
||||
};
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Err(why) = answer(round) {
|
||||
finish(round, Some(why));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn stop() {
|
||||
if let Some(mut child) = ASKING.lock().unwrap_or_else(PoisonError::into_inner).take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
let mut ai = Ai::write();
|
||||
|
||||
ai.round += 1;
|
||||
ai.busy = false;
|
||||
|
||||
// an answer cut off before its first word leaves nothing to show
|
||||
if ai.said.last().is_some_and(|(who, words)| *who == Who::Model && words.is_empty()) {
|
||||
ai.said.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn new_chat() {
|
||||
stop();
|
||||
|
||||
let mut ai = Ai::write();
|
||||
|
||||
ai.said.clear();
|
||||
ai.note = None;
|
||||
ai.back = 0.0;
|
||||
}
|
||||
|
||||
// on the answering thread: find the model's server (starting a local ollama if needed), then stream the reply into the chat
|
||||
fn answer(round: u32) -> Result<(), String> {
|
||||
let model = settings::model();
|
||||
|
||||
let server = models(Some(&model), |words| note(round, words))?
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == model)
|
||||
.map(|(server, _)| server)
|
||||
.ok_or_else(|| format!("None of pills.ai.servers has {model}"))?;
|
||||
|
||||
let body = request();
|
||||
|
||||
let mut child = Command::new("curl")
|
||||
.args(["-sN", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", "@-", &format!("{server}/api/chat")])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|error| format!("curl: {error}"))?;
|
||||
|
||||
if let Some(mut input) = child.stdin.take() {
|
||||
let _ = input.write_all(body.as_bytes());
|
||||
}
|
||||
|
||||
let output = child.stdout.take().ok_or("curl gave no output")?;
|
||||
|
||||
*ASKING.lock().unwrap_or_else(PoisonError::into_inner) = Some(child);
|
||||
|
||||
for line in BufReader::new(output).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
|
||||
match parse::ollama_chunk(&line) {
|
||||
Some(Chunk::Text(piece)) => {
|
||||
let mut ai = Ai::write();
|
||||
|
||||
if ai.round != round {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some((Who::Model, words)) = ai.said.last_mut() {
|
||||
words.push_str(&piece);
|
||||
}
|
||||
}
|
||||
Some(Chunk::Done) => break,
|
||||
Some(Chunk::Failed(why)) => return Err(why),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mut child) = ASKING.lock().unwrap_or_else(PoisonError::into_inner).take() {
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
let empty = Ai::read().said.last().is_some_and(|(who, words)| *who == Who::Model && words.is_empty());
|
||||
|
||||
finish(round, empty.then(|| String::from("The model gave no answer")));
|
||||
|
||||
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
|
||||
fn answers(server: &str) -> bool {
|
||||
Command::new("curl")
|
||||
.args(["-s", "-m", "1", "-o", "/dev/null", "-w", "%{http_code}", &format!("{server}/api/version")])
|
||||
.output()
|
||||
.is_ok_and(|done| done.stdout == b"200")
|
||||
}
|
||||
|
||||
// the chat so far as ollama's /api/chat request
|
||||
fn request() -> String {
|
||||
let ai = Ai::read();
|
||||
|
||||
let mut messages = vec![format!("{{\"role\":\"system\",\"content\":{}}}", parse::json_string(&settings::system()))];
|
||||
|
||||
for (who, words) in &ai.said {
|
||||
if words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let role = if *who == Who::You { "user" } else { "assistant" };
|
||||
|
||||
messages.push(format!("{{\"role\":\"{role}\",\"content\":{}}}", parse::json_string(words)));
|
||||
}
|
||||
|
||||
format!("{{\"model\":{},\"stream\":true,\"messages\":[{}]}}", parse::json_string(&settings::model()), messages.join(","))
|
||||
}
|
||||
|
||||
fn note(round: u32, words: &str) {
|
||||
let mut ai = Ai::write();
|
||||
|
||||
if ai.round == round {
|
||||
ai.note = (!words.is_empty()).then(|| words.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(round: u32, problem: Option<String>) {
|
||||
let mut ai = Ai::write();
|
||||
|
||||
if ai.round != round {
|
||||
return;
|
||||
}
|
||||
|
||||
ai.busy = false;
|
||||
ai.note = problem;
|
||||
|
||||
if ai.said.last().is_some_and(|(who, words)| *who == Who::Model && words.is_empty()) {
|
||||
ai.said.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn scrolled(step: Scroll) {
|
||||
let mut ai = Ai::write();
|
||||
|
||||
ai.back = (ai.back - step.y * 40.0).max(0.0);
|
||||
}
|
||||
|
||||
/*
|
||||
* the card's inside: the header, the chat (newest at the bottom, the wheel scrolls back),
|
||||
* a note line, the question field
|
||||
*/
|
||||
pub fn card(width: f32, height: f32) -> Rectangle {
|
||||
let ai = Ai::read();
|
||||
let theme = theme::now();
|
||||
|
||||
// the model, as a chip that opens the list of the others
|
||||
// the model, as a chip that opens the list of the others; its arrow points up while the list is open
|
||||
let arrow = if ai.models.is_some() { Glyph::ChevronUp } else { Glyph::Chevron };
|
||||
let mut buttons: Vec<Box<dyn Widget>> = vec![Box::new(pods::chip(&settings::model(), Some(arrow), pick))];
|
||||
|
||||
if ai.busy {
|
||||
buttons.push(Box::new(pods::chip("Stop", None, stop)));
|
||||
} else if !ai.said.is_empty() {
|
||||
buttons.push(Box::new(pods::chip("New chat", None, new_chat)));
|
||||
}
|
||||
|
||||
let header = pods::header("AI", buttons, width);
|
||||
|
||||
let field_h = 40.0;
|
||||
let note_h = text::small() + 8.0;
|
||||
let chat_h = height - pods::header_height() - field_h - note_h - 3.0 * 8.0;
|
||||
|
||||
// the bubbles, measured so the newest sit at the bottom
|
||||
let bubble_w = width * 0.86;
|
||||
let mut bubbles: Vec<Box<dyn Widget>> = Vec::new();
|
||||
let mut total = 0.0;
|
||||
|
||||
for (who, words) in &ai.said {
|
||||
let shown = if words.is_empty() { String::from("…") } else { words.clone() };
|
||||
let words = Text::new(shown).size(text::body()).color(theme.text).wrap();
|
||||
let words_h = words.height_in(bubble_w - 24.0);
|
||||
let mine = *who == Who::You;
|
||||
|
||||
let bubble = Rectangle::new()
|
||||
.width(bubble_w)
|
||||
.height(words_h + 16.0)
|
||||
.radius(14.0)
|
||||
.fill(if mine { faded(theme.accent, 70) } else { overlays::chip() })
|
||||
.padding(8.0)
|
||||
.child(Rectangle::new().width(bubble_w - 24.0).height(words_h).child(words));
|
||||
|
||||
total += words_h + 16.0 + 8.0;
|
||||
// yours on the right, the model's on the left
|
||||
let row = Rectangle::new().width(width).height(words_h + 16.0);
|
||||
let row = if mine { row.align_child(End, Start) } else { row.align_child(Start, Start) };
|
||||
|
||||
bubbles.push(Box::new(row.child(bubble)));
|
||||
}
|
||||
|
||||
let chat: Box<dyn Widget> = if let Some(models) = &ai.models {
|
||||
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 {
|
||||
// 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 column = Column::new(bubbles).gap(8.0);
|
||||
|
||||
Box::new(
|
||||
Rectangle::new()
|
||||
.width(width)
|
||||
.height(chat_h)
|
||||
.clip()
|
||||
.on_scroll(scrolled)
|
||||
.child(Stack::new(vec![Box::new(Rectangle::new().width(width).height(total).translate(0.0, chat_h - total + back).child(column)) as Box<dyn Widget>]).width(width).height(chat_h)),
|
||||
)
|
||||
};
|
||||
|
||||
let note = Text::new(ai.note.clone().unwrap_or_else(|| if ai.busy { String::from("Thinking…") } else { String::new() })).size(text::small()).color(theme.dim).elide();
|
||||
|
||||
let input = TextInput::new(INPUT).width(width - 2.0 * 14.0 - 34.0).size(text::body()).color(theme.text).placeholder(format!("Ask {}…", settings::model())).focused().on_change(|words| Ai::write().typed = words).on_submit(send);
|
||||
|
||||
let field = Rectangle::new()
|
||||
.width(width)
|
||||
.height(field_h)
|
||||
.radius(Full)
|
||||
.fill(overlays::chip())
|
||||
.padding(amane::Padding { top: 0.0, right: 6.0, bottom: 0.0, left: 14.0 })
|
||||
.align_child(Start, Center)
|
||||
.child(
|
||||
Row::new(vec![
|
||||
Box::new(input) as Box<dyn Widget>,
|
||||
Box::new(pods::button(Glyph::Send, || {
|
||||
let typed = Ai::read().typed.clone();
|
||||
|
||||
send(typed);
|
||||
})),
|
||||
])
|
||||
.gap(6.0)
|
||||
.align(Center),
|
||||
);
|
||||
|
||||
Rectangle::new().width(width).height(height).child(
|
||||
Column::new(vec![
|
||||
Box::new(header) as Box<dyn Widget>,
|
||||
chat,
|
||||
Box::new(Rectangle::new().width(width).height(note_h).align_child(Start, Center).child(note)),
|
||||
Box::new(field),
|
||||
])
|
||||
.gap(8.0),
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,15 @@ impl Service for Art {
|
||||
*Self::write() = Self { url, path };
|
||||
}
|
||||
|
||||
// the song playing, kept for the card shown while nothing plays
|
||||
let (url, path) = {
|
||||
let art = Self::read();
|
||||
|
||||
(art.url.clone(), art.path.clone())
|
||||
};
|
||||
|
||||
crate::last::watch(&url, &path);
|
||||
|
||||
// 4 checks a second: after next/previous the new cover follows the title quickly
|
||||
thread::sleep(Duration::from_millis(timing::cover_check_ms()));
|
||||
}
|
||||
|
||||
+107
-3
@@ -329,6 +329,11 @@ settings! {
|
||||
row_letters: usize = 30, 4..=200; "Letters of a device name a menu row shows before cutting the middle"
|
||||
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 {
|
||||
@@ -420,9 +425,6 @@ settings! {
|
||||
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"
|
||||
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 {
|
||||
pad: f32 = 12.0, 0..=100; "Padding inside a menu"
|
||||
@@ -497,6 +499,107 @@ settings! {
|
||||
media_check_ms: u64 = 300, 50..=5000; "How often the song is checked for a change, for the island's peek"
|
||||
}
|
||||
}
|
||||
|
||||
pills {
|
||||
layout {
|
||||
gap: f32 = 10.0, 0..=200; "Space between the island's pill and the small pills beside it"
|
||||
spacing: f32 = 8.0, 0..=100; "Space between the small pills on one side"
|
||||
icon: f32 = 16.0, 6..=40; "Size of the small pills' icons"
|
||||
open_ms: u64 = 420, 0..=5000; "How long a small pill takes to open into its card, and to close"
|
||||
radius: f32 = 22.0, 0..=60; "Corner radius of the opened cards"
|
||||
pad: f32 = 18.0, 0..=60; "Padding inside the opened cards"
|
||||
reveal_at: f32 = 0.2, 0..=1; "How much of a closing card's growth is left when the pills beside it start to show again behind it (0 = only once it's closed)"
|
||||
}
|
||||
ai {
|
||||
width: f32 = 440.0, 280..=1200; "Width of the AI card"
|
||||
height: f32 = 520.0, 200..=1200; "Height of the AI card"
|
||||
servers: String = String::from("http://localhost:11434"); "The Ollama servers the AI card talks to, comma-separated, in order: the model list shows all their models, a question goes to the first that has the chosen one"
|
||||
model: String = String::from("gemma4:e4b"); "The Ollama model to chat with, with its tag (ollama list shows them)"
|
||||
start_service: bool = true; "Start the local ollama service (doas rc-service ollama start) when it isn't answering"
|
||||
system: String = String::from("You are a helpful assistant inside a Linux desktop shell. Answer briefly and plainly."); "What the model is told before every chat"
|
||||
}
|
||||
monitor {
|
||||
width: f32 = 420.0, 280..=1200; "Width of the system monitor card"
|
||||
interval_ms: u64 = 1000, 200..=10000; "How often the system monitor reads the system"
|
||||
history: usize = 60, 10..=600; "How many readings the graphs show"
|
||||
graph_height: f32 = 34.0, 10..=200; "Height of each graph"
|
||||
temperatures: usize = 6, 0..=20; "How many temperature sensors to show, 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"
|
||||
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"
|
||||
}
|
||||
home {
|
||||
width: f32 = 820.0, 300..=1600; "Width of the home card. It opens rightwards from beside the island, so on a 1920 px wide monitor about 820 fits"
|
||||
clock_size: f32 = 64.0, 20..=200; "Size of the big clock"
|
||||
clock_font: String = String::new(); "The big clock's font family, any installed one (fc-list : family lists them); empty takes the shell's font"
|
||||
clock_weight: u64 = 700, 100..=900; "The big clock's weight, 100 (thin) to 900 (black); a font without that weight shows its nearest one"
|
||||
clock_letter_width: f32 = 0.0, 0..=2; "Width of one of the clock's digits as a share of its size, so the weather beside it makes room; 0 takes the shell font's"
|
||||
seconds: bool = false; "Show seconds on the big clock"
|
||||
avatar: String = String::from("~/.face"); "The picture shown as the user's avatar; a drawn person without one"
|
||||
avatar_size: f32 = 84.0, 24..=200; "Size of the avatar; the greeting row is as tall"
|
||||
gap: f32 = 12.0, 0..=60; "Space between the home card's parts"
|
||||
city: String = String::from("Munich"); "The weather's place, as shown"
|
||||
latitude: f32 = 48.137, -90.0..=90.0; "The weather's latitude"
|
||||
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_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 {
|
||||
server: String = String::from("https://caldav.icloud.com/"); "The CalDAV server; iCloud's by default"
|
||||
contacts_server: String = String::from("https://contacts.icloud.com/"); "The CardDAV server the birthdays come from, with the same account; iCloud's by default"
|
||||
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 and the birthdays are synced, in minutes"
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
notifications {
|
||||
popups {
|
||||
enabled: bool = true; "Show notifications as popups; off is do not disturb (they still go to the history)"
|
||||
timeout_ms: u64 = 6000, 1000..=60000; "How long a normal notification's popup stays"
|
||||
low_timeout_ms: u64 = 4000, 1000..=60000; "How long a low-urgency popup stays"
|
||||
critical_stays: bool = true; "Critical notifications stay until dismissed"
|
||||
width: f32 = 380.0, 240..=900; "Width of the popups"
|
||||
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"
|
||||
}
|
||||
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 {
|
||||
width: f32 = 420.0, 280..=1200; "Width of the notification card"
|
||||
height: f32 = 560.0, 200..=1400; "Height of the notification card"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// what config.toml set, by path; a missing or bad setting takes its default
|
||||
@@ -864,6 +967,7 @@ pub fn modules() -> Vec<&'static str> {
|
||||
pub fn title(id: &str) -> String {
|
||||
match id {
|
||||
"control" => String::from("Control centre"),
|
||||
"pills" => String::from("Side pills"),
|
||||
"bar" => String::from("Bar hiding"),
|
||||
"input-capture" => String::from("Input capture"),
|
||||
"opengl" => String::from("OpenGL"),
|
||||
|
||||
+6
-97
@@ -2,7 +2,7 @@
|
||||
* 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
|
||||
* 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
|
||||
*/
|
||||
use std::collections::HashMap;
|
||||
@@ -26,7 +26,7 @@ use crate::parse;
|
||||
use crate::prompt;
|
||||
use crate::settings;
|
||||
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};
|
||||
|
||||
// every size and timing is a setting (config.rs: control.*); these follow from them
|
||||
@@ -266,12 +266,11 @@ struct Layout {
|
||||
keyboard: bool,
|
||||
profiles: bool,
|
||||
battery: bool,
|
||||
gpu_rows: Option<usize>,
|
||||
}
|
||||
|
||||
impl 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 {
|
||||
@@ -292,12 +291,8 @@ impl Layout {
|
||||
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 {
|
||||
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 };
|
||||
|
||||
// the Settings button closes the panel
|
||||
@@ -321,7 +316,6 @@ struct Scene<'a> {
|
||||
bluetooth: &'a Bluetooth,
|
||||
audio: &'a Audio,
|
||||
screen: &'a Brightness,
|
||||
gpu: &'a Gpu,
|
||||
hotspot: &'a str,
|
||||
extra_output: Option<&'a parse::Sink>,
|
||||
layout: Layout,
|
||||
@@ -375,7 +369,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
|
||||
let audio = open.then(Audio::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 sound = open.then(Sound::read);
|
||||
|
||||
@@ -391,7 +384,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
keyboard: power.keyboard.is_some(),
|
||||
profiles: !power.profiles.is_empty(),
|
||||
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();
|
||||
|
||||
// at most one face at a time, each faded by a veil instead of a gpu layer
|
||||
let face = match (&audio, &screen, &gpu, &settings) {
|
||||
(Some(audio), Some(screen), Some(gpu), Some((hotspot, _))) if reveal > 0.0 && fade > 0.0 => {
|
||||
let face = match (&audio, &screen, &settings) {
|
||||
(Some(audio), Some(screen), Some((hotspot, _))) if reveal > 0.0 && fade > 0.0 => {
|
||||
let scene = Scene {
|
||||
control: &control,
|
||||
links: &links,
|
||||
@@ -436,7 +428,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
bluetooth: &bluetooth,
|
||||
audio,
|
||||
screen,
|
||||
gpu,
|
||||
hotspot,
|
||||
extra_output,
|
||||
layout,
|
||||
@@ -880,10 +871,6 @@ fn panel(scene: &Scene) -> Rectangle {
|
||||
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)));
|
||||
|
||||
let height = scene.layout.height();
|
||||
@@ -1480,84 +1467,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
|
||||
* 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);
|
||||
}
|
||||
+186
@@ -12,6 +12,7 @@ pub enum Glyph {
|
||||
Muted,
|
||||
Bluetooth,
|
||||
Chevron,
|
||||
ChevronUp,
|
||||
// pointing left, for a menu's way back
|
||||
Back,
|
||||
Note,
|
||||
@@ -32,6 +33,35 @@ pub enum Glyph {
|
||||
Ethernet,
|
||||
// a cog, for the config window
|
||||
Gear,
|
||||
|
||||
// the side pills: a four-pointed spark for the AI, a pulse line for the system monitor,
|
||||
// a house for home, a bell for notifications
|
||||
Sparkle,
|
||||
Pulse,
|
||||
House,
|
||||
Bell,
|
||||
// a cross to close or delete, an arrow to send, a prompt for a terminal, a head and shoulders
|
||||
Cross,
|
||||
Send,
|
||||
Terminal,
|
||||
Person,
|
||||
// the sky
|
||||
Moon,
|
||||
Cloud,
|
||||
PartCloud,
|
||||
Rain,
|
||||
Snow,
|
||||
Storm,
|
||||
Fog,
|
||||
Thermometer,
|
||||
Drive,
|
||||
Memory,
|
||||
Gpu,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash,
|
||||
Cake,
|
||||
Flag,
|
||||
}
|
||||
|
||||
pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
|
||||
@@ -109,6 +139,7 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
|
||||
.cap(Cap::Round)
|
||||
],
|
||||
Glyph::Chevron => shapes![Path::new().move_to(x(0.12), y(0.32)).line_to(x(0.5), y(0.7)).line_to(x(0.88), y(0.32)).stroke(1.5, color).cap(Cap::Round)],
|
||||
Glyph::ChevronUp => shapes![Path::new().move_to(x(0.12), y(0.68)).line_to(x(0.5), y(0.3)).line_to(x(0.88), y(0.68)).stroke(1.5, color).cap(Cap::Round)],
|
||||
Glyph::Back => shapes![Path::new().move_to(x(0.64), y(0.14)).line_to(x(0.3), y(0.5)).line_to(x(0.64), y(0.86)).stroke(thin.max(1.6), color).cap(Cap::Round)],
|
||||
// an eighth note: round head, stem, a curved flag
|
||||
Glyph::Note => shapes![
|
||||
@@ -234,5 +265,160 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
|
||||
|
||||
parts
|
||||
}
|
||||
Glyph::Sparkle => shapes![
|
||||
Path::new()
|
||||
.move_to(x(0.5), y(0.06))
|
||||
.quad_to(x(0.54), y(0.46), x(0.94), y(0.5))
|
||||
.quad_to(x(0.54), y(0.54), x(0.5), y(0.94))
|
||||
.quad_to(x(0.46), y(0.54), x(0.06), y(0.5))
|
||||
.quad_to(x(0.46), y(0.46), x(0.5), y(0.06))
|
||||
.close()
|
||||
.fill(color)
|
||||
],
|
||||
Glyph::Pulse => shapes![
|
||||
Path::new()
|
||||
.move_to(x(0.04), y(0.54))
|
||||
.line_to(x(0.28), y(0.54))
|
||||
.line_to(x(0.38), y(0.24))
|
||||
.line_to(x(0.54), y(0.8))
|
||||
.line_to(x(0.66), y(0.42))
|
||||
.line_to(x(0.74), y(0.54))
|
||||
.line_to(x(0.96), y(0.54))
|
||||
.stroke(thin, color)
|
||||
.cap(Cap::Round)
|
||||
],
|
||||
Glyph::House => shapes![
|
||||
Path::new()
|
||||
.move_to(x(0.1), y(0.48))
|
||||
.line_to(x(0.5), y(0.12))
|
||||
.line_to(x(0.9), y(0.48))
|
||||
.stroke(thin, color)
|
||||
.cap(Cap::Round),
|
||||
Path::new()
|
||||
.move_to(x(0.22), y(0.4))
|
||||
.line_to(x(0.22), y(0.88))
|
||||
.line_to(x(0.42), y(0.88))
|
||||
.line_to(x(0.42), y(0.64))
|
||||
.line_to(x(0.58), y(0.64))
|
||||
.line_to(x(0.58), y(0.88))
|
||||
.line_to(x(0.78), y(0.88))
|
||||
.line_to(x(0.78), y(0.4))
|
||||
.stroke(thin, color),
|
||||
],
|
||||
Glyph::Bell => shapes![
|
||||
Path::new()
|
||||
.move_to(x(0.16), y(0.74))
|
||||
.quad_to(x(0.26), y(0.62), x(0.26), y(0.46))
|
||||
.quad_to(x(0.26), y(0.16), x(0.5), y(0.16))
|
||||
.quad_to(x(0.74), y(0.16), x(0.74), y(0.46))
|
||||
.quad_to(x(0.74), y(0.62), x(0.84), y(0.74))
|
||||
.close()
|
||||
.fill(color),
|
||||
Circle::new().center(x(0.5), y(0.84)).radius(0.08 * s).fill(color),
|
||||
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)],
|
||||
// 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![
|
||||
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)
|
||||
],
|
||||
Glyph::Terminal => shapes![
|
||||
frame(0.06, 0.14, 0.94, 0.86).stroke(thin, color),
|
||||
Path::new().move_to(x(0.22), y(0.36)).line_to(x(0.38), y(0.5)).line_to(x(0.22), y(0.64)).stroke(thin, color).cap(Cap::Round),
|
||||
line(0.46, 0.66, 0.7, 0.66),
|
||||
],
|
||||
Glyph::Person => shapes![
|
||||
Circle::new().center(x(0.5), y(0.34)).radius(0.18 * s).fill(color),
|
||||
Path::new().move_to(x(0.14), y(0.92)).quad_to(x(0.18), y(0.58), x(0.5), y(0.58)).quad_to(x(0.82), y(0.58), x(0.86), y(0.92)).close().fill(color),
|
||||
],
|
||||
Glyph::Moon => shapes![
|
||||
Path::new()
|
||||
.move_to(x(0.62), y(0.1))
|
||||
.quad_to(x(0.12), y(0.16), x(0.16), y(0.56))
|
||||
.quad_to(x(0.3), y(0.94), x(0.86), y(0.72))
|
||||
.quad_to(x(0.4), y(0.64), x(0.62), y(0.1))
|
||||
.close()
|
||||
.fill(color)
|
||||
],
|
||||
Glyph::Cloud => shapes![cloud(&x, &y, 0.0, color)],
|
||||
Glyph::PartCloud => {
|
||||
let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.34), y(0.36)).radius(0.16 * s).fill(color)];
|
||||
|
||||
for ray in 0..6 {
|
||||
let (sin, cos) = (ray as f32 * std::f32::consts::FRAC_PI_3).sin_cos();
|
||||
|
||||
parts.push(Box::new(line(0.34 + 0.23 * sin, 0.36 - 0.23 * cos, 0.34 + 0.31 * sin, 0.36 - 0.31 * cos)));
|
||||
}
|
||||
|
||||
parts.push(Box::new(cloud(&x, &y, 0.12, color)));
|
||||
|
||||
parts
|
||||
}
|
||||
Glyph::Rain => {
|
||||
let mut parts: Vec<Box<dyn Shape>> = shapes![cloud(&x, &y, -0.14, color)];
|
||||
|
||||
for drop in [0.32, 0.5, 0.68] {
|
||||
parts.push(Box::new(line(drop, 0.7, drop - 0.06, 0.88)));
|
||||
}
|
||||
|
||||
parts
|
||||
}
|
||||
Glyph::Snow => {
|
||||
let mut parts: Vec<Box<dyn Shape>> = shapes![cloud(&x, &y, -0.14, color)];
|
||||
|
||||
for flake in [0.32, 0.5, 0.68] {
|
||||
parts.push(Box::new(Circle::new().center(x(flake), y(0.8)).radius(0.05 * s).fill(color)));
|
||||
}
|
||||
|
||||
parts
|
||||
}
|
||||
Glyph::Storm => shapes![
|
||||
cloud(&x, &y, -0.14, color),
|
||||
Path::new().move_to(x(0.56), y(0.6)).line_to(x(0.42), y(0.78)).line_to(x(0.54), y(0.78)).line_to(x(0.46), y(0.96)).stroke(thin, color).cap(Cap::Round),
|
||||
],
|
||||
Glyph::Fog => shapes![line(0.14, 0.34, 0.86, 0.34), line(0.22, 0.5, 0.78, 0.5), line(0.14, 0.66, 0.86, 0.66)],
|
||||
}
|
||||
}
|
||||
|
||||
// a cloud, `drop` of the box lower (or higher, negative) than its middle
|
||||
fn cloud(x: &impl Fn(f32) -> f32, y: &impl Fn(f32) -> f32, drop: f32, color: Color) -> Path {
|
||||
let y = |k: f32| y(k + drop);
|
||||
|
||||
Path::new()
|
||||
.move_to(x(0.2), y(0.74))
|
||||
.quad_to(x(0.04), y(0.72), x(0.08), y(0.56))
|
||||
.quad_to(x(0.14), y(0.44), x(0.28), y(0.48))
|
||||
.quad_to(x(0.34), y(0.26), x(0.54), y(0.28))
|
||||
.quad_to(x(0.74), y(0.3), x(0.76), y(0.48))
|
||||
.quad_to(x(0.96), y(0.5), x(0.92), y(0.66))
|
||||
.quad_to(x(0.88), y(0.74), x(0.78), y(0.74))
|
||||
.close()
|
||||
.fill(color)
|
||||
}
|
||||
|
||||
+1877
File diff suppressed because it is too large
Load Diff
+99
-111
@@ -1,13 +1,13 @@
|
||||
use std::collections::HashMap;
|
||||
use std::f32::consts::{PI, TAU};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use amane::{
|
||||
Animation, Argument, Blend, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient, Image,
|
||||
InputArea, Layer, LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path, Pointer,
|
||||
Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Start, Text, Vertical,
|
||||
Weight, Widget, Workspaces, Zone, children, shapes,
|
||||
Animation, Argument, Blend, Bus, Center, Color, Column, Easing, End, Full, Gradient, Image, InputArea, Layer,
|
||||
LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Pointer, Rectangle, Row, Scroll, ScrollArea,
|
||||
Service, Shadow, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children,
|
||||
};
|
||||
|
||||
use crate::art::Art;
|
||||
@@ -245,23 +245,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
let spectrum = live.then(Spectrum::read);
|
||||
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
|
||||
|
||||
let Some(player) = shown(&media, island.selected.as_deref()) else {
|
||||
/*
|
||||
* nothing playing: a quiet pill, so the island is always there. never
|
||||
* visible(false): hyprland 0.56 configures a layer surface before its first
|
||||
* commit, so a window that starts hidden gets a size anyway, amane logs "no
|
||||
* child to draw" and waits for frame callbacks an undrawn surface never gets
|
||||
*/
|
||||
return window.click_through().child(
|
||||
Rectangle::new()
|
||||
.width(Parent)
|
||||
.height(Parent)
|
||||
.padding(Padding { top: c::pill::top_gap(), right: 0.0, bottom: 0.0, left: 0.0 })
|
||||
.align_child(Center, Start)
|
||||
.translate(0.0, slide)
|
||||
.child(idle(&theme::now().surfaces())),
|
||||
);
|
||||
};
|
||||
// nothing playing: a quiet pill, which opens into the last song's card (last.rs)
|
||||
let player = shown(&media, island.selected.as_deref());
|
||||
|
||||
let (grow, pill, reveal) = phases(open);
|
||||
|
||||
@@ -280,7 +265,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
}
|
||||
|
||||
let width = mix(c::pill::width() + c::animation::hover_grow() * (lift + 2.0 * pop), c::card::width(), grow);
|
||||
let height = mix(c::pill::height(), wide_h(), grow);
|
||||
let height = mix(c::pill::height(), if player.is_some() { wide_h() } else { crate::last::height() }, grow);
|
||||
let radius = mix(c::pill::height() / 2.0, c::card::radius(), grow);
|
||||
|
||||
/*
|
||||
@@ -290,7 +275,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
*/
|
||||
let wallpaper = theme::now().surfaces();
|
||||
|
||||
let colors = if cover_colors {
|
||||
let colors = if cover_colors && player.is_some() {
|
||||
let cover = backdrop_colors(&Palette::read());
|
||||
|
||||
std::array::from_fn(|stop| Color::blend(wallpaper[stop], cover[stop], reveal))
|
||||
@@ -299,7 +284,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
};
|
||||
|
||||
// at most one face at a time, each faded by a veil instead of a gpu layer
|
||||
let face = if reveal > 0.0 {
|
||||
let face = match player {
|
||||
Some(player) if reveal > 0.0 => {
|
||||
let scene = Scene {
|
||||
player,
|
||||
players: media.players(),
|
||||
@@ -310,11 +296,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
};
|
||||
|
||||
veiled(card(&scene), width, height, radius, reveal, &colors)
|
||||
} else if pill > 0.0 {
|
||||
veiled(compact(player, playing(player, &island), &art.path, levels), width, height, radius, pill, &colors)
|
||||
} else {
|
||||
}
|
||||
Some(player) if pill > 0.0 => veiled(compact(player, playing(player, &island), &art.path, levels), width, height, radius, pill, &colors),
|
||||
None if reveal > 0.0 => veiled(crate::last::card(), width, height, radius, reveal, &colors),
|
||||
None if pill > 0.0 => veiled(idle(), width, height, radius, pill, &colors),
|
||||
// mid-grow: the empty shape alone, the cheapest frames of the animation
|
||||
Rectangle::new().width(0.0).height(0.0)
|
||||
_ => Rectangle::new().width(0.0).height(0.0),
|
||||
};
|
||||
|
||||
// the face centred along the top: while the shape grows it is narrower than the shape
|
||||
@@ -394,6 +381,9 @@ fn open_on(island: &mut Island, monitor: Option<String>) {
|
||||
|
||||
island.expanded = true;
|
||||
island.open.to(1.0);
|
||||
|
||||
// one card at a time: a side pill's card closes
|
||||
crate::pods::close();
|
||||
}
|
||||
|
||||
// the open timeline, at rest on the pill
|
||||
@@ -408,7 +398,7 @@ pub fn focused_monitor() -> Option<String> {
|
||||
workspaces.list().iter().find(|workspace| workspace.focused()).and_then(|workspace| workspace.output()).map(String::from)
|
||||
}
|
||||
|
||||
fn collapse() {
|
||||
pub fn collapse() {
|
||||
let mut island = Island::write();
|
||||
|
||||
island.expanded = false;
|
||||
@@ -514,6 +504,14 @@ fn next_player() {
|
||||
Island::write().selected = next;
|
||||
}
|
||||
|
||||
// how wide this monitor's island is this frame, hover aside: the side pills keep beside it
|
||||
pub fn width_on(monitor: &str) -> f32 {
|
||||
let island = Island::read();
|
||||
let open = if island.opened_on.as_deref().is_none_or(|name| name == monitor) { island.open.value() } else { 0.0 };
|
||||
|
||||
mix(c::pill::width(), c::card::width(), phases(open).0)
|
||||
}
|
||||
|
||||
pub fn mix(from: f32, to: f32, amount: f32) -> f32 {
|
||||
from + (to - from) * amount
|
||||
}
|
||||
@@ -589,15 +587,15 @@ pub fn faded(color: Color, alpha: u8) -> Color {
|
||||
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
||||
}
|
||||
|
||||
// the island while no player is open: a music note and "Nothing playing", on the pill's surface
|
||||
fn idle(colors: &[Color; 3]) -> Rectangle {
|
||||
let content = Rectangle::new()
|
||||
.width(c::pill::width())
|
||||
.height(c::pill::height())
|
||||
.align_child(Center, Center)
|
||||
.child(Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(text::body()).color(theme::now().dim)]).gap(8.0).align(Center));
|
||||
// the pill while no player is open: a music note and "Nothing playing"; the words only
|
||||
// where they fit with half the pill's height to spare at each end, else the note alone, so it never nears the rim
|
||||
fn idle() -> Rectangle {
|
||||
let (width, h, note) = (c::pill::width(), c::pill::height(), 15.0);
|
||||
let words = Text::new("Nothing playing").size(text::body()).color(theme::now().dim);
|
||||
let fits = matches!(words.width(), amane::Size::Fixed(words_w) if note + 8.0 + words_w + h <= width);
|
||||
|
||||
surface::shell(c::pill::width(), c::pill::height(), c::pill::height() / 2.0, colors, 0.0, content)
|
||||
let row = if fits { Row::new(children![icon(Glyph::Note, note, theme::now().accent), words]) } else { Row::new(children![icon(Glyph::Note, note, theme::now().accent)]) };
|
||||
Rectangle::new().width(width).height(h).align_child(Center, Center).child(row.gap(8.0).align(Center))
|
||||
}
|
||||
|
||||
// the pill: cover, title, the player's live levels
|
||||
@@ -672,46 +670,35 @@ fn stepped(volume: u8, scroll: Scroll) -> u8 {
|
||||
(i32::from(volume) + step).clamp(0, 100) as u8
|
||||
}
|
||||
|
||||
static RING: LazyLock<PathBuf> = LazyLock::new(|| surface::shader_file("ring.wgsl", include_str!("ring.wgsl")));
|
||||
static CURVE: LazyLock<PathBuf> = LazyLock::new(|| surface::shader_file("curve.wgsl", include_str!("curve.wgsl")));
|
||||
|
||||
/*
|
||||
* every candle goes into one path per look (glow, body, flame) instead of a
|
||||
* shape each: 120 shapes cost this iGPU a third of its frame rate, 3 cost nothing
|
||||
* the candles, drawn by ring.wgsl: as stroked paths they cost a vello pass over the whole
|
||||
* island window on every frame of the music. the shader gets half the ring's levels (the
|
||||
* other half mirrors them), at most 44 of them, eased the way the paths were
|
||||
*/
|
||||
fn ring(levels: &[f32]) -> Canvas {
|
||||
let center = c::disc::size() / 2.0;
|
||||
let inner = c::disc::cover() / 2.0 + 7.0;
|
||||
fn ring(levels: &[f32]) -> Rectangle {
|
||||
let candles = c::disc::candles();
|
||||
let half = (candles / 2).max(1);
|
||||
let given = half.min(44);
|
||||
|
||||
let mut glow = Path::new();
|
||||
let mut body = Path::new();
|
||||
let mut flames = Path::new();
|
||||
let theme = theme::now();
|
||||
let size = c::disc::size();
|
||||
|
||||
for index in 0..c::disc::candles() {
|
||||
// a gentle curve lifts the quiet bands, so the ring isn't all bass
|
||||
let level = mirrored(levels, index).powf(0.75);
|
||||
let eased: Vec<f32> = (0..given).map(|slot| mirrored(levels, slot * half / given).powf(0.75)).collect();
|
||||
|
||||
let (sin, cos) = (index as f32 / c::disc::candles() as f32 * TAU).sin_cos();
|
||||
let mut values = vec![
|
||||
[size / 2.0, c::disc::cover() / 2.0 + 7.0, candles as f32, given as f32],
|
||||
[c::disc::candle_reach(), 9.0, 4.5, 4.0],
|
||||
surface::plain(faded(theme.accent, 50)),
|
||||
surface::plain(faded(theme.accent, 215)),
|
||||
surface::plain(theme.flame),
|
||||
];
|
||||
|
||||
let reach = inner + 3.0 + level * c::disc::candle_reach();
|
||||
values.extend(surface::rows(&eased));
|
||||
|
||||
let (x0, y0) = (center + sin * inner, center - cos * inner);
|
||||
let (x1, y1) = (center + sin * reach, center - cos * reach);
|
||||
|
||||
body = body.move_to(x0, y0).line_to(x1, y1);
|
||||
|
||||
// the loud ones glow, and burn at the tip
|
||||
if level > 0.25 {
|
||||
glow = glow.move_to(x0, y0).line_to(x1, y1);
|
||||
}
|
||||
|
||||
if level > 0.45 {
|
||||
flames = flames.move_to(x1 - sin * 0.5, y1 + cos * 0.5).line_to(x1, y1);
|
||||
}
|
||||
}
|
||||
|
||||
Canvas::new().width(c::disc::size()).height(c::disc::size()).shapes(shapes![
|
||||
glow.stroke(9.0, faded(theme::now().accent, 50)).cap(Cap::Round),
|
||||
body.stroke(4.5, faded(theme::now().accent, 215)).cap(Cap::Round),
|
||||
flames.stroke(4.0, theme::now().flame).cap(Cap::Round),
|
||||
])
|
||||
Rectangle::new().width(size).height(size).shader(RING.as_path()).shader_values(values)
|
||||
}
|
||||
|
||||
// low frequencies at the top, rising down both sides, so the ring is symmetric
|
||||
@@ -1159,40 +1146,34 @@ fn equalizer(scene: &Scene) -> Column {
|
||||
}
|
||||
|
||||
// the curve through the knobs, shivering with the player's sound
|
||||
fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas {
|
||||
let column = inner_w() / crate::config::sound::equalizer::bands().len() as f32;
|
||||
/*
|
||||
* drawn by curve.wgsl, for the same reason as the ring: the points are worked out there,
|
||||
* from the knobs' heights and at most 36 live levels
|
||||
*/
|
||||
fn curve(gains: &[f32; 10], levels: &[f32]) -> Rectangle {
|
||||
let bands = crate::config::sound::equalizer::bands().len().clamp(2, gains.len());
|
||||
let column = inner_w() / bands as f32;
|
||||
|
||||
let points: Vec<(f32, f32)> = (0..=c::equalizer::curve_steps())
|
||||
.map(|step| {
|
||||
let along = step as f32 / c::equalizer::curve_steps() as f32;
|
||||
let given = levels.len().min(36);
|
||||
let sampled: Vec<f32> = (0..given).map(|slot| levels[if given > 1 { slot * (levels.len() - 1) / (given - 1) } else { 0 }]).collect();
|
||||
|
||||
// cosine blend between neighbouring knobs keeps the curve round
|
||||
let place = along * (crate::config::sound::equalizer::bands().len() - 1) as f32;
|
||||
let band = (place.floor() as usize).min(crate::config::sound::equalizer::bands().len() - 2);
|
||||
let eased = (1.0 - ((place - band as f32) * PI).cos()) / 2.0;
|
||||
let knobs: Vec<f32> = gains[..bands].iter().map(|&gain| knob_y(gain)).collect();
|
||||
|
||||
let y = mix(knob_y(gains[band]), knob_y(gains[band + 1]), eased);
|
||||
let mut values = vec![
|
||||
[column / 2.0, inner_w() - column, c::equalizer::curve_steps() as f32, bands as f32],
|
||||
[given as f32, 7.0, 2.0, 0.0],
|
||||
surface::plain(faded(theme::now().accent, 45)),
|
||||
surface::plain(faded(theme::now().accent, 235)),
|
||||
];
|
||||
|
||||
let level = if levels.is_empty() { 0.0 } else { levels[((along * (levels.len() - 1) as f32).round() as usize).min(levels.len() - 1)] };
|
||||
// three rows of knobs whatever their number, so the levels always start at row 7
|
||||
let mut knob_rows = surface::rows(&knobs);
|
||||
|
||||
let shiver = level * 9.0 * if step % 2 == 0 { -1.0 } else { 0.6 };
|
||||
knob_rows.resize(3, [0.0; 4]);
|
||||
values.extend(knob_rows);
|
||||
values.extend(surface::rows(&sampled));
|
||||
|
||||
(column / 2.0 + along * (inner_w() - column), y + shiver)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let stroke = |width: f32, alpha: u8| {
|
||||
let mut path = Path::new().move_to(points[0].0, points[0].1);
|
||||
|
||||
for (x, y) in &points[1..] {
|
||||
path = path.line_to(*x, *y);
|
||||
}
|
||||
|
||||
path.stroke(width, faded(theme::now().accent, alpha)).cap(Cap::Round)
|
||||
};
|
||||
|
||||
// a wide faint stroke under a thin bright one reads as a glow
|
||||
Canvas::new().width(inner_w()).height(c::equalizer::slider_height()).shapes(shapes![stroke(7.0, 45), stroke(2.0, 235)])
|
||||
Rectangle::new().width(inner_w()).height(c::equalizer::slider_height()).shader(CURVE.as_path()).shader_values(values)
|
||||
}
|
||||
|
||||
fn presets(scene: &Scene) -> Column {
|
||||
@@ -1326,7 +1307,7 @@ fn title(player: &MediaPlayer) -> &str {
|
||||
}
|
||||
|
||||
// a round cover with a pink rim, optionally with a cd hole; a soft gradient while there is none or it decodes
|
||||
fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
|
||||
pub fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
|
||||
let frame = Rectangle::new()
|
||||
.width(size)
|
||||
.height(size)
|
||||
@@ -1353,20 +1334,27 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
|
||||
/*
|
||||
* the pill's visualiser: seven bars from the shown player's own sound, glowing when
|
||||
* loud; a pause sign while paused, and still bars while the capture starts or the
|
||||
* player has no stream to listen to
|
||||
* player has no stream to listen to. the bars are rounded rectangles, which amane draws
|
||||
* as quads: as stroked paths on a canvas they cost a vello pass over the whole island
|
||||
* window on every frame of the music
|
||||
*/
|
||||
fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
|
||||
fn pill_levels(playing: bool, levels: &[f32]) -> Rectangle {
|
||||
let (bars, width, height) = (c::pill::bars(), c::pill::bars_width(), c::pill::bars_height());
|
||||
|
||||
if !playing {
|
||||
return icon(Glyph::Pause, 13.0, theme::now().dim);
|
||||
return Rectangle::new().width(13.0).height(13.0).child(icon(Glyph::Pause, 13.0, theme::now().dim));
|
||||
}
|
||||
|
||||
// lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line
|
||||
let middle = height / 2.0;
|
||||
|
||||
let mut glow = Path::new();
|
||||
let mut body = Path::new();
|
||||
// a round-capped line `thick` wide from y0 to y1 at x: a pill shape reaching past both ends by half its width
|
||||
let line = |x: f32, y0: f32, y1: f32, thick: f32, color: Color| {
|
||||
Rectangle::new().width(thick).height(y1 - y0 + thick).radius(Full).fill(color).translate(x - thick / 2.0, y0 - thick / 2.0)
|
||||
};
|
||||
|
||||
let mut glow: Vec<Box<dyn Widget>> = Vec::new();
|
||||
let mut body: Vec<Box<dyn Widget>> = Vec::new();
|
||||
|
||||
for index in 0..bars {
|
||||
let level = if levels.is_empty() {
|
||||
@@ -1383,17 +1371,17 @@ fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
|
||||
let half = 1.5 + level * (middle - 3.0);
|
||||
let x = 3.0 + index as f32 * (width - 6.0) / bars.saturating_sub(1).max(1) as f32;
|
||||
|
||||
body = body.move_to(x, middle - half).line_to(x, middle + half);
|
||||
body.push(Box::new(line(x, middle - half, middle + half, 3.2, theme::now().accent)));
|
||||
|
||||
if level > 0.3 {
|
||||
glow = glow.move_to(x, middle - half).line_to(x, middle + half);
|
||||
glow.push(Box::new(line(x, middle - half, middle + half, 7.0, faded(theme::now().accent, 55))));
|
||||
}
|
||||
}
|
||||
|
||||
Canvas::new().width(width).height(height).shapes(shapes![
|
||||
glow.stroke(7.0, faded(theme::now().accent, 55)).cap(Cap::Round),
|
||||
body.stroke(3.2, theme::now().accent).cap(Cap::Round),
|
||||
])
|
||||
// every glow under every bar, as the two strokes were
|
||||
glow.extend(body);
|
||||
|
||||
Rectangle::new().width(width).height(height).child(Stack::new(glow).width(width).height(height))
|
||||
}
|
||||
|
||||
// the loudest level in one of `groups` equal slices of the spectrum, low to high
|
||||
|
||||
+314
@@ -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))
|
||||
}
|
||||
+12
-1
@@ -1,16 +1,22 @@
|
||||
mod ai;
|
||||
mod art;
|
||||
mod config;
|
||||
mod control;
|
||||
mod edge;
|
||||
mod glyph;
|
||||
mod home;
|
||||
mod hypr;
|
||||
mod island;
|
||||
mod last;
|
||||
mod map;
|
||||
mod notices;
|
||||
mod parse;
|
||||
mod pods;
|
||||
mod prompt;
|
||||
mod settings;
|
||||
mod sound;
|
||||
mod spectrum;
|
||||
mod stats;
|
||||
mod surface;
|
||||
mod system;
|
||||
mod png;
|
||||
@@ -37,14 +43,19 @@ fn main() {
|
||||
.ipc("control", control::command)
|
||||
.ipc("tray", tray::command)
|
||||
.ipc("config", settings::command)
|
||||
.ipc("pods", pods::command)
|
||||
.window_per_monitor(edge::spacer)
|
||||
.window_per_monitor(edge::view)
|
||||
.window_per_monitor(island::view)
|
||||
// the small pills beside the island, and their cards
|
||||
.window_per_monitor(pods::left)
|
||||
.window_per_monitor(pods::right)
|
||||
.window_per_monitor(workspaces::view)
|
||||
.window_per_monitor(control::view)
|
||||
.window_per_monitor(control::menu_view)
|
||||
.window_per_monitor(tray::menu_view)
|
||||
// last, so the popup lies over the control centre
|
||||
// last, so the popups lie over the control centre
|
||||
.window_per_monitor(notices::popups)
|
||||
.window_per_monitor(prompt::view)
|
||||
.run();
|
||||
}
|
||||
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use amane::{
|
||||
Center, Column, Full, Horizontal, Image, LayerWindow, Layer, Margin, Monitor, Notification, Notifications, Pointer, Rectangle, Row, ScrollArea, Service, SpaceBetween,
|
||||
Start, Text, Urgency, Vertical, Weight, Widget, Zone,
|
||||
};
|
||||
|
||||
use crate::config::appearance::{overlays, text};
|
||||
use crate::config::control::pill as control_pill;
|
||||
use crate::config::island::pill as island_pill;
|
||||
use crate::config::notifications::{apps, popups as settings};
|
||||
use crate::glyph::Glyph;
|
||||
use crate::island::{self, faded};
|
||||
use crate::parse::{self, Value};
|
||||
use crate::{config, pods, surface, theme, tray};
|
||||
|
||||
/*
|
||||
* notifications: amane's own server (org.freedesktop.Notifications, which dunst held
|
||||
* before) keeps the list; this adds the popups, the unread count on the bell and the
|
||||
* history card. a watcher reads the list four times a second (memory, not d-bus), finds
|
||||
* each new one's picture once and times the popups out by urgency
|
||||
*/
|
||||
#[derive(Default)]
|
||||
pub struct Notices {
|
||||
// shown as popups, newest first, with when each (re)arrived
|
||||
popups: Vec<(u32, Instant)>,
|
||||
// each notification's arrival time as last seen, to tell new and replaced ones apart
|
||||
known: HashMap<u32, SystemTime>,
|
||||
// a picture file per notification: its image, else its icon found in the themes
|
||||
pictures: HashMap<u32, String>,
|
||||
unread: usize,
|
||||
// the pointer over the popups: they don't time out meanwhile
|
||||
hovered: bool,
|
||||
}
|
||||
|
||||
impl Service for Notices {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn listen() {
|
||||
loop {
|
||||
watch();
|
||||
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unread() -> usize {
|
||||
Notices::read().unread
|
||||
}
|
||||
|
||||
// the history was looked at
|
||||
pub fn mark_read() {
|
||||
let mut notices = Notices::write();
|
||||
|
||||
if notices.unread > 0 {
|
||||
notices.unread = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout(urgency: Urgency) -> Option<Duration> {
|
||||
match urgency {
|
||||
Urgency::Low => Some(Duration::from_millis(settings::low_timeout_ms())),
|
||||
Urgency::Critical if settings::critical_stays() => None,
|
||||
_ => Some(Duration::from_millis(settings::timeout_ms())),
|
||||
}
|
||||
}
|
||||
|
||||
fn watch() {
|
||||
let current: Vec<(u32, SystemTime, Urgency, String, String, String)> = Notifications::read()
|
||||
.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 notices = Notices::read();
|
||||
|
||||
let arrived: Vec<_> = current.iter().filter(|(id, received, ..)| notices.known.get(id) != Some(received)).cloned().collect();
|
||||
let gone: Vec<u32> = notices.known.keys().filter(|id| !current.iter().any(|(known, ..)| known == *id)).copied().collect();
|
||||
|
||||
let expired: Vec<u32> = if notices.hovered {
|
||||
Vec::new()
|
||||
} else {
|
||||
notices
|
||||
.popups
|
||||
.iter()
|
||||
.filter(|(id, since)| current.iter().find(|(known, ..)| known == id).and_then(|(_, _, urgency, ..)| timeout(*urgency)).is_some_and(|stay| since.elapsed() >= stay))
|
||||
.map(|(id, _)| *id)
|
||||
.collect()
|
||||
};
|
||||
|
||||
(arrived, gone, expired)
|
||||
};
|
||||
|
||||
if arrived.is_empty() && gone.is_empty() && expired.is_empty() {
|
||||
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
|
||||
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 popping = settings::enabled();
|
||||
|
||||
let mut notices = Notices::write();
|
||||
|
||||
for (id, received, .., app) in &arrived {
|
||||
let fresh = !notices.known.contains_key(id);
|
||||
let quiet = listed(&muted, app);
|
||||
|
||||
notices.known.insert(*id, *received);
|
||||
notices.popups.retain(|(shown, _)| shown != id);
|
||||
|
||||
if popping && !quiet {
|
||||
notices.popups.insert(0, (*id, Instant::now()));
|
||||
}
|
||||
|
||||
if fresh && !looking && !quiet {
|
||||
notices.unread += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (id, file) in pictures {
|
||||
match file {
|
||||
Some(file) => notices.pictures.insert(id, file),
|
||||
None => notices.pictures.remove(&id),
|
||||
};
|
||||
}
|
||||
|
||||
for id in gone {
|
||||
notices.known.remove(&id);
|
||||
notices.pictures.remove(&id);
|
||||
notices.popups.retain(|(shown, _)| *shown != id);
|
||||
}
|
||||
|
||||
notices.popups.retain(|(shown, _)| !expired.contains(shown));
|
||||
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)
|
||||
fn picture(icon: &str, image: &str) -> Option<String> {
|
||||
for wanted in [image, icon] {
|
||||
let path = wanted.strip_prefix("file://").unwrap_or(wanted);
|
||||
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.starts_with('/') {
|
||||
if Path::new(path).is_file() {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(file) = tray::icon_file(path, "") {
|
||||
return Some(file.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn hide(id: u32) {
|
||||
Notices::write().popups.retain(|(shown, _)| *shown != id);
|
||||
}
|
||||
|
||||
fn hovering(inside: bool) {
|
||||
let mut notices = Notices::write();
|
||||
|
||||
notices.hovered = inside;
|
||||
|
||||
// leaving starts every popup's time anew
|
||||
if !inside {
|
||||
for (_, since) in &mut notices.popups {
|
||||
*since = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ago(received: SystemTime) -> String {
|
||||
let seconds = received.elapsed().map_or(0, |since| since.as_secs());
|
||||
|
||||
match seconds {
|
||||
0..60 => String::from("now"),
|
||||
60..3600 => format!("{} min", seconds / 60),
|
||||
3600..86400 => format!("{} h", seconds / 3600),
|
||||
_ => format!("{} d", seconds / 86400),
|
||||
}
|
||||
}
|
||||
|
||||
// the picture, or the app's first letter on a disc
|
||||
fn picture_widget(file: Option<&String>, app: &str, size: f32) -> Box<dyn Widget> {
|
||||
let theme = theme::now();
|
||||
|
||||
match file {
|
||||
Some(file) => Box::new(Rectangle::new().width(size).height(size).radius(size * 0.22).fill(Image::contain(file.clone()))),
|
||||
None => {
|
||||
let letter: String = app.chars().next().map(|letter| letter.to_uppercase().collect()).unwrap_or_else(|| String::from("•"));
|
||||
|
||||
Box::new(Rectangle::new().width(size).height(size).radius(Full).fill(faded(theme.accent, 70)).align_child(Center, Center).child(Text::new(letter).size(size * 0.42).weight(Weight::Bold).color(theme.accent)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the buttons a notification asks for
|
||||
fn actions(notice: &Notification) -> Option<Box<dyn Widget>> {
|
||||
if notice.actions().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = notice.id();
|
||||
let chips: Vec<Box<dyn Widget>> = notice
|
||||
.actions()
|
||||
.iter()
|
||||
.map(|action| {
|
||||
let key = action.key().to_string();
|
||||
|
||||
Box::new(pods::chip(action.label(), None, move || Notifications::invoke(id, &key))) as Box<dyn Widget>
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(Box::new(Row::new(chips).gap(6.0)))
|
||||
}
|
||||
|
||||
/*
|
||||
* one notification's face, for a popup and for the history: picture, app and time,
|
||||
* summary, body; `lines` caps the body; `close` is the cross at its top right
|
||||
*/
|
||||
fn face(notice: &Notification, file: Option<&String>, width: f32, lines: usize, close: Box<dyn Fn()>) -> (Rectangle, f32) {
|
||||
let theme = theme::now();
|
||||
let picture_size = settings::icon();
|
||||
let text_w = width - picture_size - 12.0 - 30.0;
|
||||
|
||||
let body = parse::plain_body(notice.body());
|
||||
let body = Text::new(body).size(text::small()).color(theme.dim).wrap().max_lines(lines);
|
||||
let body_h = if notice.body().is_empty() { 0.0 } else { body.height_in(text_w) };
|
||||
|
||||
let line_h = text::tiny() + 6.0;
|
||||
let summary_h = text::body() + 6.0;
|
||||
let words_h = line_h + summary_h + body_h;
|
||||
|
||||
let actions = actions(notice);
|
||||
let actions_h = if actions.is_some() { 26.0 + 8.0 } else { 0.0 };
|
||||
|
||||
let height = words_h.max(picture_size) + actions_h;
|
||||
|
||||
let app = if notice.app_name().is_empty() { String::from("Notification") } else { notice.app_name().to_string() };
|
||||
|
||||
let words = Column::new(vec![
|
||||
Box::new(Rectangle::new().width(text_w).height(line_h).align_child(Start, Center).child(Text::new(format!("{app} · {}", ago(notice.received()))).size(text::tiny()).color(theme.dim).elide())) as Box<dyn Widget>,
|
||||
Box::new(Rectangle::new().width(text_w).height(summary_h).align_child(Start, Center).child(Text::new(notice.summary()).size(text::body()).weight(Weight::Bold).color(theme.text).elide())),
|
||||
Box::new(Rectangle::new().width(text_w).height(body_h).child(body)),
|
||||
]);
|
||||
|
||||
let top = Row::new(vec![
|
||||
Box::new(Row::new(vec![picture_widget(file, &app, picture_size), Box::new(words)]).gap(12.0)) as Box<dyn Widget>,
|
||||
Box::new(pods::button(Glyph::Cross, close)),
|
||||
])
|
||||
.width(width)
|
||||
.justify(SpaceBetween);
|
||||
|
||||
let mut parts: Vec<Box<dyn Widget>> = vec![Box::new(Rectangle::new().width(width).height(words_h.max(picture_size)).child(top))];
|
||||
|
||||
if let Some(actions) = actions {
|
||||
parts.push(Box::new(Rectangle::new().width(width).height(26.0).padding(amane::Padding { top: 0.0, right: 0.0, bottom: 0.0, left: picture_size + 12.0 }).child(Column::new(vec![actions]))));
|
||||
}
|
||||
|
||||
(Rectangle::new().width(width).height(height).child(Column::new(parts).gap(8.0)), height)
|
||||
}
|
||||
|
||||
/*
|
||||
* the popups, top right under the bar on the focused monitor, newest on top; a click
|
||||
* runs the notification's default action (or just hides the popup), the cross hides
|
||||
* it, both leave it in the history
|
||||
*/
|
||||
pub fn popups(monitor: &Monitor) -> LayerWindow {
|
||||
theme::begin();
|
||||
|
||||
let notices = Notices::read();
|
||||
let list = Notifications::read();
|
||||
let shadow = island_pill::shadow();
|
||||
let width = settings::width();
|
||||
let pad = 14.0;
|
||||
|
||||
// the right edge lines up with the control centre's pill above; the shadow there runs off the screen
|
||||
let right = control_pill::right();
|
||||
|
||||
let window = |height: f32| {
|
||||
LayerWindow::new()
|
||||
.width(width + shadow + right)
|
||||
.height(height)
|
||||
.anchor_vertical(Vertical::Top)
|
||||
.anchor_horizontal(Horizontal::Right)
|
||||
.margin(Margin { top: (island_pill::top_gap() + island_pill::height() + 8.0 - shadow).max(0.0) as i32, right: 0, bottom: 0, left: 0 })
|
||||
.layer(Layer::Overlay)
|
||||
.space(Zone::Ignore)
|
||||
.namespace("magic-notifications")
|
||||
};
|
||||
|
||||
let focused = island::focused_monitor().is_none_or(|name| name == monitor.name);
|
||||
let shown: Vec<(&Notification, Instant)> =
|
||||
notices.popups.iter().filter_map(|(id, since)| list.list().iter().find(|notice| notice.id() == *id).map(|notice| (notice, *since))).take(settings::count()).collect();
|
||||
|
||||
if !focused || shown.is_empty() {
|
||||
return window(1.0).click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||
}
|
||||
|
||||
let colors = theme::now().surfaces();
|
||||
let mut cards: Vec<Box<dyn Widget>> = Vec::new();
|
||||
let mut total = 0.0;
|
||||
let mut arriving = false;
|
||||
|
||||
for (notice, since) in shown {
|
||||
let id = notice.id();
|
||||
let (content, height) = face(notice, notices.pictures.get(&id), width - 2.0 * pad, 3, Box::new(move || hide(id)));
|
||||
let height = height + 2.0 * pad;
|
||||
|
||||
// slides in from the right over its first quarter second
|
||||
let t = (since.elapsed().as_secs_f32() / 0.25).min(1.0);
|
||||
arriving |= t < 1.0;
|
||||
|
||||
let content = Rectangle::new().width(width).height(height).padding(pad).child(content);
|
||||
let card = surface::shell(width, height, 18.0, &colors, if notice.urgency() == Urgency::Critical { 1.0 } else { 0.0 }, content);
|
||||
let card = if notice.urgency() == Urgency::Critical { card.border(1.5, crate::config::appearance::overlays::low_battery()) } else { card };
|
||||
|
||||
let has_default = notice.has_default_action();
|
||||
|
||||
cards.push(Box::new(card.cursor(Pointer).on_click(move |_| if has_default { Notifications::click(id) } else { hide(id) }).translate((1.0 - t * t * (3.0 - 2.0 * t)) * 40.0, 0.0)));
|
||||
|
||||
total += height + 10.0;
|
||||
}
|
||||
|
||||
if arriving {
|
||||
amane::request_frame();
|
||||
}
|
||||
|
||||
window(total + 2.0 * shadow).child(
|
||||
Rectangle::new()
|
||||
.width(width + shadow + right)
|
||||
.height(total + 2.0 * shadow)
|
||||
.padding(amane::Padding { top: shadow, right, bottom: shadow, left: shadow })
|
||||
.on_hover(hovering)
|
||||
.child(Column::new(cards).gap(10.0)),
|
||||
)
|
||||
}
|
||||
|
||||
// `pods notices list|dismiss <id>|clear`: the history without the mouse
|
||||
pub fn command(words: &[&str]) -> String {
|
||||
match words {
|
||||
["dismiss", id] => match id.parse() {
|
||||
Ok(id) => {
|
||||
Notifications::dismiss(id);
|
||||
|
||||
String::from("ok")
|
||||
}
|
||||
Err(_) => String::from("dismiss takes a notification's number"),
|
||||
},
|
||||
["clear"] => {
|
||||
Notifications::clear();
|
||||
|
||||
String::from("ok")
|
||||
}
|
||||
_ => {
|
||||
let notifications = Notifications::read();
|
||||
let notices = Notices::read();
|
||||
let lines: Vec<String> = notifications
|
||||
.list()
|
||||
.iter()
|
||||
.map(|notice| {
|
||||
let popup = if notices.popups.iter().any(|(id, _)| *id == notice.id()) { " popup" } else { "" };
|
||||
let picture = notices.pictures.get(¬ice.id()).map_or(String::from("-"), Clone::clone);
|
||||
|
||||
format!("{} [{:?}{popup}] {}: {} | {} | picture {picture} | actions {}", notice.id(), notice.urgency(), notice.app_name(), notice.summary(), parse::plain_body(notice.body()), notice.actions().len())
|
||||
})
|
||||
.collect();
|
||||
|
||||
format!("running={} unread={}\n{}", notifications.running(), notices.unread, lines.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// do not disturb: the popups off (or on again), kept in config.toml
|
||||
fn toggle_quiet() {
|
||||
let _ = config::set("notifications.popups.enabled", Value::Bool(!settings::enabled()));
|
||||
}
|
||||
|
||||
// the history card: every notification still open, newest first, each with its cross to delete it
|
||||
pub fn card(width: f32, height: f32) -> Rectangle {
|
||||
let notices = Notices::read();
|
||||
let list = Notifications::read();
|
||||
let theme = theme::now();
|
||||
|
||||
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() {
|
||||
buttons.push(Box::new(pods::chip("Clear all", None, Notifications::clear)));
|
||||
}
|
||||
|
||||
let header = pods::header("Notifications", buttons, width);
|
||||
|
||||
// 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() {
|
||||
Box::new(
|
||||
Rectangle::new()
|
||||
.width(width)
|
||||
.height(list_h)
|
||||
.align_child(Center, Center)
|
||||
.child(Text::new("Another notification daemon (dunst?) holds the bus name: stop it and restart the shell").size(text::small()).color(theme.dim).wrap()),
|
||||
)
|
||||
} else if list.list().is_empty() {
|
||||
Box::new(
|
||||
Rectangle::new().width(width).height(list_h).align_child(Center, Center).child(
|
||||
Column::new(vec![
|
||||
Box::new(Rectangle::new().width(width).height(40.0).align_child(Center, Center).child(crate::glyph::icon(Glyph::Bell, 32.0, faded(theme.dim, 120)))) as Box<dyn Widget>,
|
||||
Box::new(Rectangle::new().width(width).height(text::small() + 8.0).align_child(Center, Center).child(Text::new("No notifications").size(text::small()).color(theme.dim))),
|
||||
])
|
||||
.gap(8.0),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
let item_w = width - 8.0;
|
||||
let mut items: Vec<Box<dyn Widget>> = Vec::new();
|
||||
|
||||
for notice in list.list().iter().rev() {
|
||||
let id = notice.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();
|
||||
|
||||
// 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 };
|
||||
|
||||
items.push(Box::new(item));
|
||||
}
|
||||
|
||||
Box::new(ScrollArea::new("notices-history", Column::new(items).gap(8.0)).width(width).height(list_h))
|
||||
};
|
||||
|
||||
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))
|
||||
}
|
||||
+1638
File diff suppressed because it is too large
Load Diff
+507
@@ -0,0 +1,507 @@
|
||||
use std::collections::HashMap;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use amane::{
|
||||
Animation, Blend, Center, Color, Easing, Full, Horizontal, InputArea, Key, Keyboard, Layer, LayerWindow, Margin, Monitor, Parent, Pointer, Rectangle, Row,
|
||||
Service, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Zone,
|
||||
};
|
||||
|
||||
use crate::config::appearance::{motion, overlays, text};
|
||||
use crate::config::island::{card as island_card, pill as island_pill};
|
||||
use crate::config::notifications::history;
|
||||
use crate::config::pills::{self as settings, layout};
|
||||
use crate::edge::{self, Edge};
|
||||
use crate::glyph::{Glyph, icon};
|
||||
use crate::island::{self, CLEAR, mix, phases, veiled};
|
||||
use crate::{ai, home, notices, stats, surface, theme};
|
||||
|
||||
/*
|
||||
* four small one-icon pills beside the island, each opening into a card the way the
|
||||
* island does: left of it the AI chat and the system monitor, right of it home and
|
||||
* notifications. one window per side and monitor (magic-pods-left|right) placed beside
|
||||
* the island's pill; while the island opens they move out of its way. one card at a
|
||||
* time across the shell: opening one collapses the island and the other way round
|
||||
*/
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Pod {
|
||||
Ai,
|
||||
Monitor,
|
||||
Home,
|
||||
Notifications,
|
||||
}
|
||||
|
||||
// left of the island, outermost first; right of it, innermost first
|
||||
const LEFT: [Pod; 2] = [Pod::Ai, Pod::Monitor];
|
||||
const RIGHT: [Pod; 2] = [Pod::Home, Pod::Notifications];
|
||||
|
||||
impl Pod {
|
||||
fn left(self) -> bool {
|
||||
matches!(self, Self::Ai | Self::Monitor)
|
||||
}
|
||||
|
||||
fn glyph(self) -> Glyph {
|
||||
match self {
|
||||
Self::Ai => Glyph::Sparkle,
|
||||
Self::Monitor => Glyph::Pulse,
|
||||
Self::Home => Glyph::House,
|
||||
Self::Notifications => Glyph::Bell,
|
||||
}
|
||||
}
|
||||
|
||||
// the open card's size
|
||||
fn size(self) -> (f32, f32) {
|
||||
match self {
|
||||
Self::Ai => (settings::ai::width(), settings::ai::height()),
|
||||
Self::Monitor => (settings::monitor::width(), stats::height()),
|
||||
Self::Home => (settings::home::width(), home::height()),
|
||||
Self::Notifications => (history::width(), history::height()),
|
||||
}
|
||||
}
|
||||
|
||||
// whether its card takes typing: then it has the keyboard and stays open when the pointer leaves
|
||||
fn types(self) -> bool {
|
||||
self == Self::Ai || (self == Self::Home && home::typing())
|
||||
}
|
||||
|
||||
fn word(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ai => "ai",
|
||||
Self::Monitor => "monitor",
|
||||
Self::Home => "home",
|
||||
Self::Notifications => "notifications",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_word(word: &str) -> Option<Self> {
|
||||
[Self::Ai, Self::Monitor, Self::Home, Self::Notifications].into_iter().find(|pod| pod.word() == word)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Pods {
|
||||
// the card open or closing, and the monitor it's on; `shown` whether it's opening or open
|
||||
card: Option<(Pod, String)>,
|
||||
shown: bool,
|
||||
timeline: Animation,
|
||||
|
||||
hover: HashMap<Pod, Animation>,
|
||||
|
||||
// the pointer over the card, and a count every arrival and departure bumps, so a late close does nothing
|
||||
inside: bool,
|
||||
moves: u32,
|
||||
}
|
||||
|
||||
impl Service for Pods {
|
||||
fn new() -> Self {
|
||||
Self { card: None, shown: false, timeline: timeline(), hover: HashMap::new(), inside: false, moves: 0 }
|
||||
}
|
||||
|
||||
// changes only through input
|
||||
fn listen() {}
|
||||
}
|
||||
|
||||
fn timeline() -> Animation {
|
||||
Animation::new(0.0).duration(Duration::from_millis(layout::open_ms())).easing(Easing::Linear)
|
||||
}
|
||||
|
||||
pub fn open(pod: Pod, monitor: &str) {
|
||||
island::collapse();
|
||||
|
||||
let mut pods = Pods::write();
|
||||
|
||||
// another card, or this one on another monitor: it grows from its own pill
|
||||
if pods.card.as_ref().is_none_or(|(open, on)| *open != pod || on != monitor) {
|
||||
pods.card = Some((pod, monitor.to_string()));
|
||||
pods.timeline = timeline();
|
||||
}
|
||||
|
||||
pods.shown = true;
|
||||
pods.inside = true;
|
||||
pods.timeline.to(1.0);
|
||||
|
||||
drop(pods);
|
||||
|
||||
if pod == Pod::Notifications {
|
||||
notices::mark_read();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close() {
|
||||
let mut pods = Pods::write();
|
||||
|
||||
if pods.shown {
|
||||
pods.shown = false;
|
||||
pods.timeline.to(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle(pod: Pod, monitor: &str) {
|
||||
let open_here = {
|
||||
let pods = Pods::read();
|
||||
|
||||
pods.shown && pods.card.as_ref().is_some_and(|(open, on)| *open == pod && on == monitor)
|
||||
};
|
||||
|
||||
if open_here { close() } else { open(pod, monitor) }
|
||||
}
|
||||
|
||||
// the open card's pod, if any
|
||||
pub fn open_card() -> Option<Pod> {
|
||||
let pods = Pods::read();
|
||||
|
||||
pods.card.as_ref().filter(|_| pods.shown).map(|(pod, _)| *pod)
|
||||
}
|
||||
|
||||
fn hovering(pod: Pod) -> impl Fn(bool) + 'static {
|
||||
move |inside| {
|
||||
edge::over_bar(inside);
|
||||
|
||||
Pods::write()
|
||||
.hover
|
||||
.entry(pod)
|
||||
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms())))
|
||||
.to(if inside { 1.0 } else { 0.0 });
|
||||
}
|
||||
}
|
||||
|
||||
// the pointer on or off a card; leaving one that takes no typing closes it a moment later
|
||||
fn over_card(inside: bool) {
|
||||
edge::over_bar(inside);
|
||||
|
||||
let moves = {
|
||||
let mut pods = Pods::write();
|
||||
|
||||
pods.inside = inside;
|
||||
pods.moves += 1;
|
||||
pods.moves
|
||||
};
|
||||
|
||||
if inside {
|
||||
return;
|
||||
}
|
||||
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
|
||||
let leave = {
|
||||
let pods = Pods::read();
|
||||
|
||||
pods.moves == moves && !pods.inside && pods.card.as_ref().is_some_and(|(pod, _)| !pod.types())
|
||||
};
|
||||
|
||||
if leave {
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn left(monitor: &Monitor) -> LayerWindow {
|
||||
side(monitor, true)
|
||||
}
|
||||
|
||||
pub fn right(monitor: &Monitor) -> LayerWindow {
|
||||
side(monitor, false)
|
||||
}
|
||||
|
||||
fn side(monitor: &Monitor, left: bool) -> LayerWindow {
|
||||
theme::begin();
|
||||
|
||||
let here = monitor.name.as_str();
|
||||
let pods = if left { LEFT } else { RIGHT };
|
||||
let colors = theme::now().surfaces();
|
||||
|
||||
let state = Pods::read();
|
||||
|
||||
// this side's card on this monitor, while it's at all open
|
||||
let card = state.card.as_ref().filter(|(pod, on)| pod.left() == left && on == here).map(|(pod, _)| (*pod, state.timeline.value())).filter(|(_, t)| *t > 0.0);
|
||||
|
||||
// with the bar on a fullscreen monitor, unless its card is open; out of the island's way while it opens
|
||||
let slide = if card.is_some() { 0.0 } else { edge::hidden_by(Edge::read().shown(here), 0.0, edge::hide_h()) };
|
||||
let shift = (island::width_on(here) - island_pill::width()) / 2.0;
|
||||
|
||||
let (h, top, shadow, spacing) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow(), layout::spacing());
|
||||
let count = pods.len() as f32;
|
||||
let group_w = count * h + (count - 1.0) * spacing;
|
||||
|
||||
let (card_w, card_h) = card.map_or((0.0, 0.0), |(pod, _)| pod.size());
|
||||
|
||||
/*
|
||||
* room for the pills to move away from the open island, or for this side's widest
|
||||
* card. the width never follows the open card: a narrower window would move with
|
||||
* its margin, and Hyprland applies size and position on different frames, so the
|
||||
* pills jumped when a card closed
|
||||
*/
|
||||
let room = (island_card::width() - island_pill::width()) / 2.0;
|
||||
let widest = pods.iter().map(|pod| pod.size().0).fold(0.0, f32::max);
|
||||
let inner_w = (group_w + room).max(widest);
|
||||
let window_h = top + h.max(card_h) + shadow;
|
||||
|
||||
// the group's edge nearest the island, in the window, and where the window starts on the monitor
|
||||
let near = if left { shadow + inner_w } else { shadow };
|
||||
let middle = monitor.width as f32 / 2.0;
|
||||
let beside = island_pill::width() / 2.0 + layout::gap();
|
||||
let start = if left { middle - beside - near } else { middle + beside - near };
|
||||
|
||||
let window = LayerWindow::new()
|
||||
.width(inner_w + 2.0 * shadow)
|
||||
.height(window_h)
|
||||
.anchor_vertical(Vertical::Top)
|
||||
.anchor_horizontal(Horizontal::Left)
|
||||
.margin(Margin { top: 0, right: 0, bottom: 0, left: start.round() as i32 })
|
||||
.layer(Layer::Overlay)
|
||||
.space(Zone::Ignore)
|
||||
.namespace(if left { "magic-pods-left" } else { "magic-pods-right" });
|
||||
|
||||
// empty and click-through rather than hidden: see the island's view for why
|
||||
if slide <= -edge::hide_h() {
|
||||
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||
}
|
||||
|
||||
// a pill's left edge in the window; `k` counts from the island outwards
|
||||
let pill_x = |pod: Pod| {
|
||||
let at = pods.iter().position(|other| *other == pod).unwrap_or(0) as f32;
|
||||
let k = if left { count - 1.0 - at } else { at };
|
||||
|
||||
if left { near - shift - (k + 1.0) * h - k * spacing } else { near + shift + k * (h + spacing) }
|
||||
};
|
||||
|
||||
let (grow, pill_shown, reveal) = card.map_or((0.0, 1.0, 0.0), |(_, t)| phases(t));
|
||||
|
||||
/*
|
||||
* the pills beside an open card are gone; a closing card shrinks back into its own
|
||||
* pill's place, and they come back behind it only over the last `reveal_at` of its
|
||||
* shrinking, so they don't pop up around a big card
|
||||
*/
|
||||
let reveal_at = layout::reveal_at();
|
||||
let others = if grow <= 0.0 {
|
||||
1.0
|
||||
} else if reveal_at <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let k = (1.0 - grow / reveal_at).clamp(0.0, 1.0);
|
||||
|
||||
k * k * (3.0 - 2.0 * k)
|
||||
};
|
||||
|
||||
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
||||
let mut regions: Vec<InputArea> = Vec::new();
|
||||
|
||||
for pod in pods {
|
||||
// the open one is the card, drawn last, over the others
|
||||
if card.is_some_and(|(open, _)| open == pod) || others <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let x = pill_x(pod);
|
||||
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
||||
let pill = pill(pod, lit, &colors, here);
|
||||
let pill = if others < 1.0 { pill.opacity(others) } else { pill };
|
||||
|
||||
regions.push(InputArea { x: x as i32, y: (top + slide) as i32, width: h.ceil() as i32, height: h.ceil() as i32 });
|
||||
layers.push(Box::new(pill.translate(x, top + slide)));
|
||||
}
|
||||
|
||||
let typing = card.is_some_and(|(pod, _)| pod.types() && state.shown);
|
||||
|
||||
if let Some((pod, _)) = card {
|
||||
// from the pill to the card, its edge nearest the island staying put
|
||||
let from_x = pill_x(pod);
|
||||
let to_x = if left { near - shift - card_w } else { near + shift };
|
||||
|
||||
let width = mix(h, card_w, grow);
|
||||
let height = mix(h, card_h, grow);
|
||||
let x = mix(from_x, to_x, grow);
|
||||
let radius = mix(h / 2.0, layout::radius(), grow);
|
||||
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
||||
|
||||
let face: Rectangle = if reveal > 0.0 {
|
||||
veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors)
|
||||
} else {
|
||||
// the pill's icon fading as the shape grows
|
||||
let tint = Color::blend(CLEAR, theme::now().text, pill_shown);
|
||||
|
||||
Rectangle::new().width(h).height(h).align_child(Center, Center).child(icon(pod.glyph(), layout::icon(), tint))
|
||||
};
|
||||
|
||||
let face = Rectangle::new().width(width).height(height).align_child(Start, Start).child(face);
|
||||
let shape = surface::shell(width, height, radius, &colors, lit * pill_shown, face).on_hover(over_card).translate(x, top + slide);
|
||||
|
||||
regions.push(InputArea { x: x as i32, y: (top + slide) as i32, width: width.ceil() as i32, height: height.ceil() as i32 });
|
||||
layers.push(Box::new(shape));
|
||||
}
|
||||
|
||||
let window = window.input_region(regions);
|
||||
|
||||
let window = if typing {
|
||||
window.keyboard(Keyboard::OnDemand).on_key(|key| {
|
||||
// Esc closes the home card's event form first, then the card
|
||||
if key == Key::Escape && !home::escape() {
|
||||
close();
|
||||
}
|
||||
})
|
||||
} else {
|
||||
window
|
||||
};
|
||||
|
||||
window.child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent)))
|
||||
}
|
||||
|
||||
// a small pill: the surface with one icon; the bell carries the unread count
|
||||
fn pill(pod: Pod, lit: f32, colors: &[Color; 3], monitor: &str) -> Rectangle {
|
||||
let h = island_pill::height();
|
||||
let theme = theme::now();
|
||||
|
||||
let glyph = icon(pod.glyph(), layout::icon() * (1.0 + 0.08 * lit), Color::blend(theme.dim, theme.text, 0.6 + 0.4 * lit));
|
||||
let mut inside: Vec<Box<dyn Widget>> = vec![Box::new(Rectangle::new().width(h).height(h).align_child(Center, Center).child(glyph))];
|
||||
|
||||
let unread = if pod == Pod::Notifications { notices::unread() } else { 0 };
|
||||
|
||||
if unread > 0 {
|
||||
let badge = text::tiny() + 6.0;
|
||||
let words = if unread > 9 { String::from("9+") } else { unread.to_string() };
|
||||
|
||||
inside.push(Box::new(
|
||||
Rectangle::new()
|
||||
.width(badge)
|
||||
.height(badge)
|
||||
.radius(Full)
|
||||
.fill(theme.accent)
|
||||
.align_child(Center, Center)
|
||||
.translate(h - badge - 3.0, 3.0)
|
||||
.child(Text::new(words).size(text::tiny() - 1.0).weight(Weight::Bold).color(theme.on_accent)),
|
||||
));
|
||||
}
|
||||
|
||||
let content = Rectangle::new().width(h).height(h).child(Stack::new(inside).width(h).height(h));
|
||||
let on = monitor.to_string();
|
||||
|
||||
surface::shell(h, h, h / 2.0, colors, lit, content).cursor(Pointer).on_hover(hovering(pod)).on_click(move |_| toggle(pod, &on))
|
||||
}
|
||||
|
||||
fn content(pod: Pod, width: f32, height: f32) -> Rectangle {
|
||||
let pad = layout::pad();
|
||||
let (inner_w, inner_h) = (width - 2.0 * pad, height - 2.0 * pad);
|
||||
|
||||
let body = match pod {
|
||||
Pod::Ai => ai::card(inner_w, inner_h),
|
||||
Pod::Monitor => stats::card(inner_w, inner_h),
|
||||
Pod::Home => home::card(inner_w, inner_h),
|
||||
Pod::Notifications => notices::card(inner_w, inner_h),
|
||||
};
|
||||
|
||||
Rectangle::new().width(width).height(height).padding(pad).child(body)
|
||||
}
|
||||
|
||||
/*
|
||||
* a card's first row: its title, the card's own buttons, then a cross that closes it.
|
||||
* the cards share it so they read as one family
|
||||
*/
|
||||
pub fn header(title: &str, buttons: Vec<Box<dyn Widget>>, width: f32) -> Rectangle {
|
||||
let theme = theme::now();
|
||||
|
||||
let mut right: Vec<Box<dyn Widget>> = buttons;
|
||||
right.push(Box::new(button(Glyph::Cross, close)));
|
||||
|
||||
Rectangle::new().width(width).height(header_height()).child(
|
||||
Row::new(vec![
|
||||
Box::new(Text::new(title).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
|
||||
Box::new(Row::new(right).gap(6.0).align(Center)),
|
||||
])
|
||||
.width(width)
|
||||
.justify(SpaceBetween)
|
||||
.align(Center),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn header_height() -> f32 {
|
||||
30.0
|
||||
}
|
||||
|
||||
// a small round icon button
|
||||
pub fn button(glyph: Glyph, pressed: impl Fn() + 'static) -> Rectangle {
|
||||
let theme = theme::now();
|
||||
|
||||
Rectangle::new()
|
||||
.width(26.0)
|
||||
.height(26.0)
|
||||
.radius(Full)
|
||||
.fill(overlays::chip())
|
||||
.align_child(Center, Center)
|
||||
.cursor(Pointer)
|
||||
.on_click(move |_| pressed())
|
||||
.child(icon(glyph, 14.0, theme.text))
|
||||
}
|
||||
|
||||
// a chip with words on it, for actions like "Clear all" or "Processes"
|
||||
pub fn chip(words: &str, glyph: Option<Glyph>, pressed: impl Fn() + 'static) -> Rectangle {
|
||||
let theme = theme::now();
|
||||
let letters = words.chars().count() as f32;
|
||||
let width = letters * text::small() * crate::config::appearance::font::letter_width() + if glyph.is_some() { 40.0 } else { 22.0 };
|
||||
|
||||
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
|
||||
|
||||
if let Some(glyph) = glyph {
|
||||
parts.push(Box::new(icon(glyph, 13.0, theme.text)));
|
||||
}
|
||||
|
||||
parts.push(Box::new(Text::new(words).size(text::small()).color(theme.text)));
|
||||
|
||||
Rectangle::new()
|
||||
.width(width)
|
||||
.height(26.0)
|
||||
.radius(Full)
|
||||
.fill(overlays::chip())
|
||||
.align_child(Center, Center)
|
||||
.cursor(Pointer)
|
||||
.on_click(move |_| pressed())
|
||||
.child(Row::new(parts).gap(6.0).align(Center))
|
||||
}
|
||||
|
||||
// `amane ipc call pods open|toggle ai|monitor|home|notifications [monitor]`, `close`, `status`; the focused monitor by default
|
||||
pub fn command(arguments: &[String]) -> String {
|
||||
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
|
||||
let focused = island::focused_monitor().unwrap_or_default();
|
||||
|
||||
match words.as_slice() {
|
||||
[verb @ ("open" | "toggle"), name, rest @ ..] => match Pod::from_word(name) {
|
||||
Some(pod) => {
|
||||
let on = rest.first().map_or(focused, |monitor| monitor.to_string());
|
||||
|
||||
if *verb == "open" { open(pod, &on) } else { toggle(pod, &on) }
|
||||
|
||||
String::from("ok")
|
||||
}
|
||||
None => String::from("pods: ai, monitor, home or notifications"),
|
||||
},
|
||||
["close"] => {
|
||||
close();
|
||||
|
||||
String::from("ok")
|
||||
}
|
||||
["ask", question @ ..] if !question.is_empty() => {
|
||||
ai::ask(question.join(" "));
|
||||
|
||||
String::from("ok")
|
||||
}
|
||||
["notices", rest @ ..] => notices::command(rest),
|
||||
["processes"] => {
|
||||
stats::processes();
|
||||
|
||||
String::from("ok")
|
||||
}
|
||||
["status"] | [] => {
|
||||
let pods = Pods::read();
|
||||
let card = pods.card.as_ref().filter(|_| pods.shown).map_or("none", |(pod, _)| pod.word());
|
||||
let on = pods.card.as_ref().map_or("", |(_, on)| on.as_str());
|
||||
|
||||
format!("card={card} monitor={on} timeline={:.2} unread={}", pods.timeline.value(), notices::unread())
|
||||
}
|
||||
_ => String::from("usage: pods open|toggle <ai|monitor|home|notifications> [monitor] | close | ask <question> | notices list|dismiss <id>|clear | status"),
|
||||
}
|
||||
}
|
||||
|
||||
// a thin divider across a card
|
||||
pub fn rule(width: f32) -> Rectangle {
|
||||
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,
|
||||
message: Option<(String, bool)>,
|
||||
hover: Option<String>,
|
||||
// just opened, or a key went to the search: it takes the keys on the next frame
|
||||
focus_search: bool,
|
||||
}
|
||||
|
||||
impl Service for Ui {
|
||||
fn new() -> Self {
|
||||
Self { page: String::from("appearance"), query: String::new(), message: None, hover: None }
|
||||
Self { page: String::from("appearance"), query: String::new(), message: None, hover: None, focus_search: false }
|
||||
}
|
||||
|
||||
fn listen() {}
|
||||
}
|
||||
|
||||
pub fn open() {
|
||||
Ui::write().focus_search = true;
|
||||
|
||||
open_window(NAME, view);
|
||||
|
||||
hypr::load();
|
||||
@@ -158,6 +162,16 @@ fn say(result: Result<(), String>, done: String) {
|
||||
pub fn view() -> Window {
|
||||
theme::begin();
|
||||
|
||||
/*
|
||||
* only on this frame: a TextInput that's .focused() takes the keys every frame it's
|
||||
* drawn, so always focused, the search would take them back from a value being typed
|
||||
*/
|
||||
let focus_search = Ui::read().focus_search;
|
||||
|
||||
if focus_search {
|
||||
Ui::write().focus_search = false;
|
||||
}
|
||||
|
||||
let colors = theme::now();
|
||||
let ui = Ui::read();
|
||||
let hypr = Hypr::read();
|
||||
@@ -283,22 +297,43 @@ pub fn view() -> Window {
|
||||
.title(TITLE)
|
||||
.size(1100.0, 760.0)
|
||||
.resizable(true)
|
||||
// Escape closes it, also from a field (a typed value only applies on Enter)
|
||||
.on_key(|key| {
|
||||
if key == Key::Escape {
|
||||
close_window(NAME);
|
||||
}
|
||||
/*
|
||||
* Escape closes it, also from a field (a typed value only applies on Enter). keys no
|
||||
* field takes, like right after opening, are typed into the search, which then takes
|
||||
* the keys itself: amane drops a field's focus whenever one of the shell's windows
|
||||
* loses the keyboard, as one does when this window opens
|
||||
*/
|
||||
.on_key(|key| match key {
|
||||
Key::Escape => close_window(NAME),
|
||||
Key::Character(letter) => search_key(Some(letter)),
|
||||
Key::Space => search_key(Some(' ')),
|
||||
Key::Backspace => search_key(None),
|
||||
_ => {}
|
||||
})
|
||||
.child(
|
||||
Rectangle::new()
|
||||
.width(width)
|
||||
.height(height)
|
||||
.fill(colors.surface_low)
|
||||
.child(Row::new(children![sidebar(&ui, &hypr, height, &colors), main])),
|
||||
.child(Row::new(children![sidebar(&ui, &hypr, height, &colors, focus_search), main])),
|
||||
)
|
||||
}
|
||||
|
||||
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> Rectangle {
|
||||
// a key typed with no field focused: into the search (none: Backspace), which takes the next keys
|
||||
fn search_key(letter: Option<char>) {
|
||||
let mut ui = Ui::write();
|
||||
|
||||
match letter {
|
||||
Some(letter) => ui.query.push(letter),
|
||||
None => {
|
||||
ui.query.pop();
|
||||
}
|
||||
}
|
||||
|
||||
ui.focus_search = true;
|
||||
}
|
||||
|
||||
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors, focus_search: bool) -> Rectangle {
|
||||
let mut entries: Vec<Box<dyn Widget>> = vec![Box::new(caption("SHELL", colors))];
|
||||
|
||||
for module in config::modules() {
|
||||
@@ -333,11 +368,15 @@ fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> Rectangle {
|
||||
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
|
||||
.align_child(Start, Center)
|
||||
.cursor(Cursor::Text)
|
||||
.child(TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| {
|
||||
.child({
|
||||
let input = TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| {
|
||||
SYNCED.with_borrow_mut(|synced| synced.insert(SEARCH, text.clone()));
|
||||
|
||||
Ui::write().query = text;
|
||||
}));
|
||||
});
|
||||
|
||||
if focus_search { input.focused() } else { input }
|
||||
});
|
||||
|
||||
Rectangle::new()
|
||||
.width(SIDEBAR_W)
|
||||
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use amane::{Blend, Canvas, Center, Color, Column, Full, Path, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, Weight, Widget};
|
||||
|
||||
use crate::config::appearance::{overlays, text};
|
||||
use crate::config::pills::{layout, monitor as settings};
|
||||
use crate::glyph::{Glyph, glyph_shapes};
|
||||
use crate::island::faded;
|
||||
use crate::parse;
|
||||
use crate::system::Gpu;
|
||||
use crate::{pods, theme};
|
||||
|
||||
/*
|
||||
* the system monitor card: cpu, the integrated gpu, the external gpu, memory, swap,
|
||||
* network, disk reads and writes as graphs over the last readings; the external gpu's
|
||||
* memory and the programs on it; the hottest temperature sensors in their colours.
|
||||
* 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)]
|
||||
pub struct Stats {
|
||||
// 0 to 1
|
||||
cpu: Vec<f32>,
|
||||
// the integrated gpu's clock as a share of its highest, and in words
|
||||
gpu: Vec<f32>,
|
||||
gpu_words: String,
|
||||
// the external gpu's busy share
|
||||
egpu: Vec<f32>,
|
||||
ram: Vec<f32>,
|
||||
ram_words: String,
|
||||
swap: Vec<f32>,
|
||||
swap_words: String,
|
||||
// bytes a second
|
||||
down: Vec<f32>,
|
||||
up: Vec<f32>,
|
||||
read: Vec<f32>,
|
||||
write: Vec<f32>,
|
||||
// (sensor, its icon, °C), hottest first
|
||||
temps: Vec<(String, Glyph, f32)>,
|
||||
}
|
||||
|
||||
struct Sample {
|
||||
at: Instant,
|
||||
cpu: (u64, u64),
|
||||
net: (u64, u64),
|
||||
disk: (u64, u64),
|
||||
}
|
||||
|
||||
impl Service for Stats {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn listen() {
|
||||
let mut before = sample();
|
||||
|
||||
loop {
|
||||
thread::sleep(Duration::from_millis(settings::interval_ms()));
|
||||
|
||||
let now = sample();
|
||||
let seconds = now.at.duration_since(before.at).as_secs_f32().max(0.001);
|
||||
let rate = |new: u64, old: u64| new.saturating_sub(old) as f32 / seconds;
|
||||
|
||||
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 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 egpu = Gpu::read().busy as f32 / 100.0;
|
||||
let temps = temperatures();
|
||||
|
||||
{
|
||||
let mut stats = Self::write();
|
||||
let keep = settings::history();
|
||||
|
||||
push(&mut stats.cpu, busy, 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.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.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.write, rate(now.disk.1, before.disk.1) * 512.0, keep);
|
||||
|
||||
stats.gpu_words = clock_words;
|
||||
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;
|
||||
}
|
||||
|
||||
before = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gigabytes(kb: u64) -> String {
|
||||
format!("{:.1} GB", kb as f32 / 1048576.0)
|
||||
}
|
||||
|
||||
fn push(history: &mut Vec<f32>, value: f32, keep: usize) {
|
||||
history.push(value);
|
||||
|
||||
let extra = history.len().saturating_sub(keep);
|
||||
|
||||
history.drain(..extra);
|
||||
}
|
||||
|
||||
fn read(path: impl Into<PathBuf>) -> String {
|
||||
fs::read_to_string(path.into()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn sample() -> Sample {
|
||||
Sample { at: Instant::now(), cpu: parse::cpu_jiffies(&read("/proc/stat")).unwrap_or((0, 0)), net: parse::network_bytes(&read("/proc/net/dev")), disk: disk_sectors() }
|
||||
}
|
||||
|
||||
// sectors read and written by the whole physical disks: ones with a device behind them, not loop, ram or zram
|
||||
fn disk_sectors() -> (u64, u64) {
|
||||
let mut sum = (0, 0);
|
||||
|
||||
for entry in fs::read_dir("/sys/block").into_iter().flatten().flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
if ["loop", "ram", "zram"].iter().any(|virtual_disk| name.starts_with(virtual_disk)) || !entry.path().join("device").exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((reads, writes)) = parse::block_sectors(&read(entry.path().join("stat"))) {
|
||||
sum.0 += reads;
|
||||
sum.1 += writes;
|
||||
}
|
||||
}
|
||||
|
||||
sum
|
||||
}
|
||||
|
||||
// the first gpu that tells its clock (xe or i915): its share of the highest clock, and the clock in words
|
||||
fn gpu_clock() -> (f32, String) {
|
||||
for entry in fs::read_dir("/sys/class/drm").into_iter().flatten().flatten() {
|
||||
let card = entry.path();
|
||||
|
||||
for (now, top) in [("device/tile0/gt0/freq0/act_freq", "device/tile0/gt0/freq0/max_freq"), ("gt_act_freq_mhz", "gt_max_freq_mhz")] {
|
||||
let mhz = |file: &str| read(card.join(file)).trim().parse::<f32>().ok();
|
||||
|
||||
if let (Some(now), Some(top)) = (mhz(now), mhz(top)) {
|
||||
return ((now / top.max(1.0)).clamp(0.0, 1.0), format!("{now:.0} of {top:.0} MHz"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(0.0, String::from("no clock to read"))
|
||||
}
|
||||
|
||||
// what the temperatures are shown in, always: a sensor keeps its place whatever its reading
|
||||
const ORDER: [&str; 8] = ["CPU", "iGPU", "eGPU", "NVMe", "Memory", "Board", "ThinkPad", "Wi-Fi"];
|
||||
|
||||
// every hwmon device's hottest sensor, named for what it is, in ORDER (then by name, then by device)
|
||||
fn temperatures() -> Vec<(String, Glyph, f32)> {
|
||||
let mut found: Vec<(String, Glyph, f32)> = Vec::new();
|
||||
|
||||
// the devices in their numbers' order (hwmon2 before hwmon10), so two of a kind keep theirs too
|
||||
let mut devices: Vec<PathBuf> = fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten().map(|entry| entry.path()).collect();
|
||||
|
||||
devices.sort_by_key(|path| path.file_name().and_then(|name| name.to_str()).and_then(|name| name.trim_start_matches("hwmon").parse::<u32>().ok()).unwrap_or(u32::MAX));
|
||||
|
||||
for device in devices {
|
||||
let kind = read(device.join("name")).trim().to_string();
|
||||
|
||||
let hottest = fs::read_dir(&device)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|file| file.file_name().to_string_lossy().starts_with("temp") && file.file_name().to_string_lossy().ends_with("_input"))
|
||||
.filter_map(|file| read(file.path()).trim().parse::<f32>().ok())
|
||||
.map(|milli| milli / 1000.0)
|
||||
.filter(|celsius| *celsius > 0.0 && *celsius < 150.0)
|
||||
.fold(None, |hottest: Option<f32>, celsius| Some(hottest.map_or(celsius, |hot| hot.max(celsius))));
|
||||
|
||||
let Some(celsius) = hottest else { continue };
|
||||
|
||||
let (name, glyph) = match kind.as_str() {
|
||||
"coretemp" | "k10temp" | "zenpower" => ("CPU", Glyph::Chip),
|
||||
"nvme" => ("NVMe", Glyph::Drive),
|
||||
"amdgpu" | "nouveau" => ("eGPU", Glyph::Gpu),
|
||||
"xe" | "i915" => ("iGPU", Glyph::Gpu),
|
||||
"acpitz" => ("Board", Glyph::Thermometer),
|
||||
"thinkpad" => ("ThinkPad", Glyph::Laptop),
|
||||
"spd5118" | "jc42" => ("Memory", Glyph::Memory),
|
||||
other if other.starts_with("iwlwifi") => ("Wi-Fi", Glyph::Thermometer),
|
||||
other => (other, Glyph::Thermometer),
|
||||
};
|
||||
|
||||
found.push((name.to_string(), glyph, celsius));
|
||||
}
|
||||
|
||||
// a stable sort: same kinds keep the devices' order
|
||||
found.sort_by_key(|(name, ..)| (ORDER.iter().position(|kind| kind == name).unwrap_or(ORDER.len()), name.clone()));
|
||||
found.truncate(settings::temperatures());
|
||||
|
||||
// a kind shown more than once is numbered, in the order shown
|
||||
let names: Vec<String> = found.iter().map(|(name, ..)| name.clone()).collect();
|
||||
|
||||
for (index, (name, ..)) in found.iter_mut().enumerate() {
|
||||
if names.iter().filter(|other| *other == name).count() > 1 {
|
||||
let number = names[..index].iter().filter(|other| *other == name).count() + 1;
|
||||
|
||||
*name = format!("{name} {number}");
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* workspace when it runs already. the window is told apart by its class
|
||||
*/
|
||||
pub fn processes() {
|
||||
pods::close();
|
||||
|
||||
let (terminal, command, space) = (settings::terminal(), settings::processes(), settings::workspace());
|
||||
|
||||
thread::spawn(move || {
|
||||
let class = format!("amane.{space}");
|
||||
let running = Command::new("pgrep").args(["-f", "--", &format!("--class={class}")]).output().is_ok_and(|done| done.status.success());
|
||||
|
||||
if !running {
|
||||
let line = format!("{terminal} --class={class} -e {command}");
|
||||
let lua = format!("hl.exec_cmd({}, {{ workspace = {} }})", parse::quoted(&line), parse::quoted(&format!("special:{space} silent")));
|
||||
|
||||
let _ = Command::new("hyprctl").args(["eval", &lua]).output();
|
||||
}
|
||||
|
||||
let _ = Command::new("hyprctl").args(["dispatch", &format!("hl.dsp.workspace.toggle_special({})", parse::quoted(&space))]).output();
|
||||
});
|
||||
}
|
||||
|
||||
fn label_height() -> f32 {
|
||||
text::small() + 8.0
|
||||
}
|
||||
|
||||
fn temp_row_height() -> f32 {
|
||||
text::small() + 16.0
|
||||
}
|
||||
|
||||
fn temp_rows() -> usize {
|
||||
settings::temperatures().div_ceil(2)
|
||||
}
|
||||
|
||||
fn program_row_height() -> f32 {
|
||||
text::small() + 8.0
|
||||
}
|
||||
|
||||
// the external gpu's rows under its graph: its memory, then the programs on it (at least one line)
|
||||
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
|
||||
}
|
||||
|
||||
// bytes a second in words
|
||||
fn rate(bytes: f32) -> String {
|
||||
match bytes {
|
||||
b if b >= 1e9 => format!("{:.1} GB/s", b / 1e9),
|
||||
b if b >= 1e6 => format!("{:.1} MB/s", b / 1e6),
|
||||
b if b >= 1e3 => format!("{:.0} kB/s", b / 1e3),
|
||||
b => format!("{b:.0} B/s"),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
type Series<'a> = (&'a [f32], f32, Color);
|
||||
|
||||
// a graph's line and the faint area under it, scaled so `top` reaches the top
|
||||
fn graph(history: &[f32], top: f32, origin: (f32, f32), size: (f32, f32), color: Color) -> Vec<Box<dyn Shape>> {
|
||||
let (x0, y0) = origin;
|
||||
let (width, height) = size;
|
||||
let keep = settings::history().max(2) as f32;
|
||||
|
||||
if history.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// newest on the right; older readings fill in leftwards
|
||||
let point = |index: usize, value: f32| {
|
||||
let age = (history.len() - 1 - index) as f32;
|
||||
|
||||
(x0 + width - age / (keep - 1.0) * width, y0 + height - (value / top.max(f32::EPSILON)).clamp(0.0, 1.0) * height)
|
||||
};
|
||||
|
||||
let mut line = Path::new();
|
||||
let mut area = Path::new();
|
||||
|
||||
for (index, value) in history.iter().enumerate() {
|
||||
let (x, y) = point(index, *value);
|
||||
|
||||
if index == 0 {
|
||||
line = line.move_to(x, y);
|
||||
area = area.move_to(x, y0 + height).line_to(x, y);
|
||||
} else {
|
||||
line = line.line_to(x, y);
|
||||
area = area.line_to(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
let (last_x, _) = point(history.len() - 1, 0.0);
|
||||
|
||||
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 {
|
||||
let stats = Stats::read();
|
||||
let gpu = Gpu::read();
|
||||
let theme = theme::now();
|
||||
|
||||
let header = pods::header("System", vec![Box::new(pods::chip("Processes", Some(Glyph::Terminal), processes))], width);
|
||||
|
||||
let last = |history: &[f32]| history.last().copied().unwrap_or(0.0);
|
||||
let peak = |a: &[f32], b: &[f32]| a.iter().chain(b).copied().fold(1024.0_f32, f32::max);
|
||||
|
||||
let net_top = peak(&stats.down, &stats.up);
|
||||
let disk_top = peak(&stats.read, &stats.write);
|
||||
|
||||
// (name, value in words, graphs as (history, top, colour))
|
||||
let mut blocks: Vec<(String, String, Vec<Series>)> = vec![
|
||||
(String::from("CPU"), format!("{:.0} %", last(&stats.cpu) * 100.0), vec![(&stats.cpu, 1.0, theme.accent)]),
|
||||
(String::from("iGPU"), stats.gpu_words.clone(), vec![(&stats.gpu, 1.0, theme.accent)]),
|
||||
];
|
||||
|
||||
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());
|
||||
|
||||
// every graph into one canvas under the labels: each canvas costs the gpu on its own
|
||||
let mut shapes: Vec<Box<dyn Shape>> = Vec::new();
|
||||
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
|
||||
|
||||
for (index, (name, words, series)) in blocks.into_iter().enumerate() {
|
||||
let top = index as f32 * (label_h + graph_h + 10.0);
|
||||
|
||||
for (history, peak, color) in series {
|
||||
shapes.extend(graph(history, peak, (0.0, top + label_h), (width, graph_h), color));
|
||||
}
|
||||
|
||||
rows.push(Box::new(
|
||||
Rectangle::new().width(width).height(label_h + graph_h).child(
|
||||
Row::new(vec![
|
||||
Box::new(Text::new(name).size(text::small()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
|
||||
Box::new(Text::new(words).size(text::small()).color(theme.dim)),
|
||||
])
|
||||
.width(width)
|
||||
.justify(SpaceBetween),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
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 mut parts: Vec<Box<dyn Widget>> = vec![Box::new(header), Box::new(graphs)];
|
||||
|
||||
if !gpu.name.is_empty() {
|
||||
parts.push(Box::new(egpu_detail(&gpu, width)));
|
||||
}
|
||||
|
||||
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() {
|
||||
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(Column::new(parts).gap(10.0))
|
||||
}
|
||||
+127
-27
@@ -1,7 +1,10 @@
|
||||
use amane::{Blend, Color, Gradient, Rectangle, Shadow, Stack, children};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use amane::{Blend, Color, Rectangle, Stack, children};
|
||||
|
||||
use crate::config::appearance::{frosted, liquid, solid, surface};
|
||||
use crate::island::{backdrop, faded};
|
||||
use crate::island::faded;
|
||||
use crate::theme;
|
||||
|
||||
/*
|
||||
@@ -10,58 +13,155 @@ use crate::theme;
|
||||
* `content` (already width x height, with its own padding and alignment) in the surface.
|
||||
* `lift` (0 to 1) is how raised it is: hovered, or a popup over the rest.
|
||||
* see-through designs need Hyprland's blur behind the shell (README: Glass)
|
||||
*
|
||||
* every design is a shadow, a gradient fill and maybe a second gradient just inside the
|
||||
* rim, which surface.wgsl draws in one pass. drawn as amane's shadow and gradients they
|
||||
* took two vello passes over the whole window on every frame (vello keeps no gradient
|
||||
* between frames), so a playing pill, whose bars redraw it 60 times a second, cost the
|
||||
* igpu about 3 ms a frame; the shader costs a few hundred thousand pixels of arithmetic.
|
||||
* the rim stays a border, which amane draws as a quad
|
||||
*/
|
||||
pub fn shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
|
||||
let theme = theme::now();
|
||||
let base = Rectangle::new().width(width).height(height).radius(radius);
|
||||
|
||||
match surface::design().as_str() {
|
||||
let look = match surface::design().as_str() {
|
||||
// after Apple's materials: a thin tint, a hairline light rim (the accent when hovered),
|
||||
// a soft highlight along the top as if lit from above, one soft low shadow instead of a glow
|
||||
"frosted" => {
|
||||
let sheen = inner(width, height, radius).fill(Gradient::linear(180.0, [(0.0, white(frosted::sheen())), (0.3, white(frosted::sheen() * 0.26)), (0.65, white(0.0))]));
|
||||
let a = alpha(frosted::opacity());
|
||||
let sheen = frosted::sheen();
|
||||
|
||||
base.fill(backdrop(colors, alpha(frosted::opacity())))
|
||||
.border(1.0, Color::blend(white(frosted::rim()), faded(theme.accent, 150), lift))
|
||||
.shadow(Shadow::drop(Color::BLACK).opacity(frosted::shadow() + 0.1 * lift).blur(24.0).offset(0.0, 6.0))
|
||||
.child(Stack::new(children![sheen, content]).width(width).height(height))
|
||||
Look {
|
||||
shadow: (Color::BLACK, frosted::shadow() + 0.1 * lift, 24.0, (0.0, 6.0)),
|
||||
fill: (150.0, vec![(0.0, faded(colors[0], a)), (0.55, faded(colors[1], a)), (1.0, faded(colors[2], a))]),
|
||||
inner: (180.0, vec![(0.0, white(sheen)), (0.3, white(sheen * 0.26)), (0.65, white(0.0))]),
|
||||
rim: (1.0, Color::blend(white(frosted::rim()), faded(theme.accent, 150), lift)),
|
||||
}
|
||||
}
|
||||
|
||||
// clear glass, denser toward the bottom right where the equalizer's labels sit; a bright
|
||||
// edge where light enters at the top left and leaves at the bottom right; the accent's glow
|
||||
"liquid" => {
|
||||
let glass = liquid::opacity();
|
||||
let fill = Gradient::linear(150.0, [(0.0, faded(colors[0], alpha(glass * 0.55))), (0.55, faded(colors[1], alpha(glass))), (1.0, faded(colors[2], alpha(glass * 1.6)))]);
|
||||
|
||||
let light = liquid::light();
|
||||
let edge = inner(width, height, radius).fill(Gradient::linear(
|
||||
135.0,
|
||||
[(0.0, white(light)), (0.16, white(light * 0.23)), (0.4, white(0.0)), (0.82, white(0.0)), (1.0, white(light * 0.43))],
|
||||
));
|
||||
|
||||
base.fill(fill)
|
||||
.border(liquid::rim_width(), Color::blend(white(liquid::rim()), faded(theme.flame, 200), lift))
|
||||
.shadow(Shadow::drop(theme.accent).opacity(liquid::glow() + 0.3 * lift).blur(24.0))
|
||||
.child(Stack::new(children![edge, content]).width(width).height(height))
|
||||
Look {
|
||||
shadow: (theme.accent, liquid::glow() + 0.3 * lift, 24.0, (0.0, 0.0)),
|
||||
fill: (150.0, vec![(0.0, faded(colors[0], alpha(glass * 0.55))), (0.55, faded(colors[1], alpha(glass))), (1.0, faded(colors[2], alpha(glass * 1.6)))]),
|
||||
inner: (135.0, vec![(0.0, white(light)), (0.16, white(light * 0.23)), (0.4, white(0.0)), (0.82, white(0.0)), (1.0, white(light * 0.43))]),
|
||||
rim: (liquid::rim_width(), Color::blend(white(liquid::rim()), faded(theme.flame, 200), lift)),
|
||||
}
|
||||
}
|
||||
|
||||
// the original: the wallpaper's gradient, a 1 px rim, a soft glow in the accent
|
||||
_ => base
|
||||
.fill(backdrop(colors, alpha(solid::opacity())))
|
||||
.border(1.0, Color::blend(theme.outline, faded(theme.accent, 110), lift))
|
||||
.shadow(Shadow::drop(theme.accent).opacity(solid::glow() + (solid::hover_glow() - solid::glow()) * lift).blur(22.0))
|
||||
.child(content),
|
||||
_ => {
|
||||
let a = alpha(solid::opacity());
|
||||
|
||||
Look {
|
||||
shadow: (theme.accent, solid::glow() + (solid::hover_glow() - solid::glow()) * lift, 22.0, (0.0, 0.0)),
|
||||
fill: (150.0, vec![(0.0, faded(colors[0], a)), (0.55, faded(colors[1], a)), (1.0, faded(colors[2], a))]),
|
||||
inner: (0.0, Vec::new()),
|
||||
rim: (1.0, Color::blend(theme.outline, faded(theme.accent, 110), lift)),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// room for the shadow: vello draws a blurred rectangle 2.5 deviations (half the blur each) past its edge
|
||||
let (_, _, blur, (dx, dy)) = look.shadow;
|
||||
let margin = (1.25 * blur + dx.abs().max(dy.abs())).ceil();
|
||||
|
||||
let paint = Rectangle::new()
|
||||
.width(width + 2.0 * margin)
|
||||
.height(height + 2.0 * margin)
|
||||
.translate(-margin, -margin)
|
||||
.shader(SHADER.as_path())
|
||||
.shader_values(look.values(width, height, radius, margin));
|
||||
|
||||
let (thickness, rim_color) = look.rim;
|
||||
let rim = Rectangle::new().width(width).height(height).radius(radius).border(thickness, rim_color);
|
||||
|
||||
Rectangle::new().width(width).height(height).child(Stack::new(children![paint, rim, content]).width(width).height(height))
|
||||
}
|
||||
|
||||
// whether the desktop shows through: then a veil can't hide a face, so faces fade with .opacity()
|
||||
pub fn clear() -> bool {
|
||||
surface::design() != "solid" || solid::opacity() < 1.0
|
||||
}
|
||||
|
||||
// a layer just inside the rim, so the rim stays crisp over it
|
||||
fn inner(width: f32, height: f32, radius: f32) -> Rectangle {
|
||||
Rectangle::new().width(width - 2.0).height(height - 2.0).radius((radius - 1.0).max(0.0)).translate(1.0, 1.0)
|
||||
static SHADER: LazyLock<PathBuf> = LazyLock::new(|| shader_file("surface.wgsl", include_str!("surface.wgsl")));
|
||||
|
||||
// a shader's source is part of the shell; amane wants a file, so it goes beside cava's config
|
||||
pub fn shader_file(name: &str, source: &str) -> PathBuf {
|
||||
let path = crate::art::folder().join(name);
|
||||
|
||||
let _ = std::fs::write(&path, source);
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
// numbers as a shader's rows of four, the last one padded with 0
|
||||
pub fn rows(numbers: &[f32]) -> Vec<[f32; 4]> {
|
||||
numbers.chunks(4).map(|chunk| std::array::from_fn(|index| chunk.get(index).copied().unwrap_or(0.0))).collect()
|
||||
}
|
||||
|
||||
type Stops = Vec<(f32, Color)>;
|
||||
|
||||
struct Look {
|
||||
// colour, strength, blur, offset
|
||||
shadow: (Color, f32, f32, (f32, f32)),
|
||||
// angle and stops: up to 3 for the fill, 5 for the gradient inside the rim (none: no stops)
|
||||
fill: (f32, Stops),
|
||||
inner: (f32, Stops),
|
||||
// width and colour
|
||||
rim: (f32, Color),
|
||||
}
|
||||
|
||||
impl Look {
|
||||
// the rows surface.wgsl reads, see its top
|
||||
fn values(&self, width: f32, height: f32, radius: f32, margin: f32) -> Vec<[f32; 4]> {
|
||||
let (color, strength, blur, (dx, dy)) = self.shadow;
|
||||
let mut shadow = plain(color);
|
||||
|
||||
shadow[3] *= strength.clamp(0.0, 1.0);
|
||||
|
||||
let mut rows = vec![
|
||||
[width, height, radius, margin],
|
||||
shadow,
|
||||
[blur / 2.0, dx, dy, 1.0],
|
||||
[self.fill.0, self.inner.0, self.fill.1.len().min(3) as f32, self.inner.1.len().min(5) as f32],
|
||||
];
|
||||
|
||||
let (colors, offsets) = stops::<3>(&self.fill.1);
|
||||
|
||||
rows.extend(colors);
|
||||
rows.push([offsets[0], offsets[1], offsets[2], 0.0]);
|
||||
|
||||
let (colors, offsets) = stops::<5>(&self.inner.1);
|
||||
|
||||
rows.extend(colors);
|
||||
rows.push([offsets[0], offsets[1], offsets[2], offsets[3]]);
|
||||
rows.push([offsets[4], 0.0, 0.0, 0.0]);
|
||||
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
// N (at most 5) stops as colours and their offsets, missing ones clear
|
||||
fn stops<const N: usize>(stops: &[(f32, Color)]) -> ([[f32; 4]; N], [f32; 5]) {
|
||||
let mut colors = [[0.0; 4]; N];
|
||||
let mut offsets = [0.0; 5];
|
||||
|
||||
for (index, (offset, color)) in stops.iter().take(N).enumerate() {
|
||||
colors[index] = plain(*color);
|
||||
offsets[index] = *offset;
|
||||
}
|
||||
|
||||
(colors, offsets)
|
||||
}
|
||||
|
||||
// a colour as a shader reads it: plain, not premultiplied, 0 to 1
|
||||
pub fn plain(color: Color) -> [f32; 4] {
|
||||
[color.red(), color.green(), color.blue(), color.alpha()].map(|part| f32::from(part) / 255.0)
|
||||
}
|
||||
|
||||
// an opacity, 0 to 1, as a colour's alpha
|
||||
|
||||
@@ -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 crate::control::Control;
|
||||
use crate::parse;
|
||||
use crate::config::control::{polling, services};
|
||||
|
||||
@@ -800,13 +799,13 @@ impl Service for Gpu {
|
||||
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() {
|
||||
let mut names: HashMap<String, String> = HashMap::new();
|
||||
let mut engines: HashMap<u32, (u64, Instant)> = HashMap::new();
|
||||
|
||||
loop {
|
||||
if !Control::read().expanded() {
|
||||
if crate::pods::open_card() != Some(crate::pods::Pod::Monitor) {
|
||||
thread::sleep(Duration::from_millis(polling::gpu_wait_ms()));
|
||||
|
||||
continue;
|
||||
|
||||
+1
-1
@@ -306,7 +306,7 @@ fn forget(name: &str) {
|
||||
* (gtk's choice, hicolor, Adwaita), then /usr/share/pixmaps. themes are walked once
|
||||
* per name, the answer kept
|
||||
*/
|
||||
fn icon_file(name: &str, theme_path: &str) -> Option<PathBuf> {
|
||||
pub fn icon_file(name: &str, theme_path: &str) -> Option<PathBuf> {
|
||||
static FOUND: Mutex<Option<HashMap<String, Option<PathBuf>>>> = Mutex::new(None);
|
||||
|
||||
if name.is_empty() {
|
||||
|
||||
@@ -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