Compare commits
44
Commits
dfc9a19860
...
side-pills
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9407ee3506 | ||
|
|
84b01f7c56 | ||
|
|
9b4334835a | ||
|
|
49a0bae039 | ||
|
|
bccdb86ebb | ||
|
|
ed0eb532db | ||
|
|
d59a5bc81c | ||
|
|
e8741b1cb3 | ||
|
|
bbe184ab97 | ||
|
|
6399844731 | ||
|
|
5d322b940d | ||
|
|
7d8525e95c | ||
|
|
3b9929aff4 | ||
|
|
4fbfad3ce3 | ||
|
|
dfe3867529 | ||
|
|
200aff97ae | ||
|
|
11646cafcf | ||
|
|
35386e84f1 | ||
|
|
a7c49a16c0 | ||
|
|
1e99d7264c | ||
|
|
b14df3a435 | ||
|
|
afe963dfea | ||
|
|
4d4166dadc | ||
|
|
8fa951fe15 | ||
|
|
7bbb23d9ca | ||
|
|
318b2a8443 | ||
|
|
1b571abef0 | ||
|
|
54f51c5671 | ||
|
|
826401e28b | ||
|
|
036b326b04 | ||
|
|
d04cbac8d4 | ||
|
|
eabda748f5 | ||
|
|
2870ebbd13 | ||
|
|
ecd9a8d3b0 | ||
|
|
66bf25f7dd | ||
|
|
c749ca9d9d | ||
|
|
9329d250f6 | ||
|
|
ddaef1f805 | ||
|
|
9d6a13f876 | ||
|
|
e847cbde73 | ||
|
|
7844a98828 | ||
|
|
967ff37b78 | ||
|
|
85416b501e | ||
|
|
0f6fb88e58 |
@@ -2,3 +2,6 @@
|
|||||||
Cargo.toml
|
Cargo.toml
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# written by matugen (through skwd-wall) from the wallpaper, see matugen/amane.colors
|
||||||
|
theme
|
||||||
|
|||||||
@@ -1,117 +1,162 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Notes for agents continuing work on the Magic Island shell. Read README.md first for what it does and how it's laid out.
|
Handoff for agents continuing work on the Magic Island shell (Hermes, Claude Code, Codex, a new session). Read README.md first for what the shell does and how to use it; this file is how it's built, how to work on it, and where things stand. Last updated 2026-10-15.
|
||||||
|
|
||||||
|
## Where things are
|
||||||
|
|
||||||
|
- **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) 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
|
## Ground rules from the user
|
||||||
|
|
||||||
- **Branch per big change, and ask before merging.** Create a new branch for each big change, show the result, and merge into `master` only after the user explicitly approves. Small follow-ups the user asks for in the same breath can go on the same branch.
|
- **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 and Firefox are real; their volume, equalizer and output are the user's. Before any `island preset|output` call or volume change, run `amane ipc call island status` and make sure `player=` is a fake test player. Test audio goes to inaudible null sinks (see Testing).
|
- **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 desktop is reached only by `ssh -o BatchMode=yes fabian@192.168.2.169`** (Hyprland, Intel iGPU, monitors DP-2 and eDP-1). 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.
|
||||||
- **Leave the desktop clean:** stop fake players and null sinks after testing, and restart the shell without frame logging (see "After testing").
|
- **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.
|
||||||
- Be honest in summaries about what was and wasn't checked on the real desktop.
|
- **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).
|
||||||
|
- **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), 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): 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 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:** `~/.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.
|
||||||
|
|
||||||
|
**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".
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Theme pipeline:** Skwd-wall v2's matugen integration renders `~/.config/amane/matugen/amane.colors` into `~/.config/amane/theme`.
|
||||||
|
|
||||||
## Constraints of Amane itself
|
## Constraints of Amane itself
|
||||||
|
|
||||||
- The shell can only depend on `amane` and the Rust standard library: `amane compile` generates `Cargo.toml` and no other crates can be added. 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 docs: `/root/amane/docs/src/*.md` on the dev host; library source in `/root/amane/src/`.
|
- Amane redraws the whole window every frame, so every effect is paid at the frame rate.
|
||||||
- Amane redraws the whole window every frame, so every effect in the view 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.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
| Piece | Kind | Job |
|
| Piece | Kind | Job |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `island.rs` `Island` | input-only Service | `open: Animation` (0 pill, 1 card), `expanded`, `selected` player (bus name, `None` follows the playing one), open dropdown, time-bar scrub. Also the view and the `island` IPC handler (`command`). |
|
| `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. |
|
||||||
| `sound.rs` `Sound` | Service, 1 s poll | `pactl list sinks` + `sink-inputs` → each player's streams (matched by process ancestry, then app name), volume, mute, output, equalizer. Curves saved by player identity to `~/.config/amane/island-eq`. |
|
| `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. |
|
||||||
| `spectrum.rs` `Spectrum` | Service, 200 ms check | While the shown player plays: `pw-record --target <its stream> \| cava -p $XDG_RUNTIME_DIR/magic-island/cava.conf` → 24 levels. Stops when it pauses. |
|
| `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`. |
|
||||||
| `art.rs` `Art` | Service, 1 s poll | Cover URL → local file (curl for https), copied to `palette-cover`, which `Palette` (opened in `main.rs`, 8 colours) watches. |
|
| `surface.rs` | widget function | `shell(w, h, radius, colors, lift, content)`: the chosen glass design for every surface. |
|
||||||
| `parse.rs` | plain functions, no amane | pactl/cava/proc parsing, filter-chain config, `pw-cli` params, EQ store, middle ellipsis. Unit-tested on its own. |
|
| `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` process per player with `libpipewire-module-filter-chain`, 10 biquads (low shelf 31 Hz, peaking 63 Hz–8 kHz, high shelf 16 kHz). The player's streams are moved into sink `magic_island_eq.<key>`; gains change live with `pw-cli set-param magic_island_eq.<key> Props '{ params = [ "b1:Gain" 6.0 ... ] }'`. A player only gets one when its curve or output is first changed, or it has a saved non-flat curve.
|
### The island view
|
||||||
|
|
||||||
### The view (island.rs `view`)
|
- 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.
|
||||||
|
|
||||||
- One `LayerWindow` (namespace `magic-island`, `Layer::Overlay` because the user's windows are fullscreen) sized for the open card; an `InputArea` lets clicks outside the island through.
|
## Settings
|
||||||
- `phases(open)` splits the one linear 560 ms timeline: pill fades out over 0–0.18, the empty shape grows over 0–0.6 (cubic ease-out), the card fades in over 0.6–1 (smoothstep). Closing runs it backwards. At most one face (pill or card) is built per frame.
|
|
||||||
- `veiled()` fades a face by laying the backdrop gradient over it with alpha `1 - shown` instead of `.opacity()`: same picture, no GPU layer.
|
|
||||||
- `backdrop_colors()` / `backdrop()`: one 150° three-stop gradient from the palette (dominant, `colors()[1]`, `background()`), each darkened by `deepen()` to a max brightness of 0.22 / 0.15 / 0.07.
|
|
||||||
- `pill_levels()`: 7 bars, bar *i* = loudest of spectrum slice *i* of 7, × 1.4, linear, drawn up and down from the middle line.
|
|
||||||
- `ring()`: 40 candles around the CD, levels from `mirrored()` (bass at the top, highs down both sides), `powf(0.75)`. Drawn as 3 paths (glow, body, flame tips).
|
|
||||||
- Moving the pointer off the island collapses it (`on_hover`).
|
|
||||||
|
|
||||||
## Performance rules (measured on the Intel iGPU)
|
- **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]` (`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 }`.
|
||||||
|
|
||||||
Current numbers: card open with music 60 fps, about 3.1 ms GPU per frame; pill with music 60 fps, about 1.7 ms GPU, about 9% of one core for shell + cava; paused, about 1 frame a second. Opening has 1 frame over 20 ms (where the card first appears), closing 0.
|
## Hyprland 0.56 (Lua config) pitfalls
|
||||||
|
|
||||||
- **Measure before and after every visual change** with `tools/amane-remote.sh HOST fps` and `anim island`, baseline first.
|
- **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))`.
|
||||||
- Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in.
|
- **`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")`.
|
||||||
- `.clip()` and `.opacity(<1)` each add a GPU layer. A clip over the whole card once took the iGPU from 47 to 9 fps.
|
- `hyprctl eval` / `repl` share one Lua state; globals vanish on a config reload.
|
||||||
- Gradients are expensive in numbers: a gradient background plus 10 gradient slider fills halved the frame rate; the single backdrop gradient is affordable.
|
- Layer-rule `match.namespace` is a full match: `^magic-.*$`.
|
||||||
- A veil over a gradient must be the same gradient at the same size as the shape (inset 1 px for the rim): Amane places a gradient by each rectangle's own bounds, so a smaller veil shows patches.
|
- `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead instances; the live one has `.socket.sock`.
|
||||||
- Blurred shadows cost every frame: the drop shadow only exists on the pill and during the animation.
|
- `hyprctl layers` lists hidden layer windows too (alpha 0).
|
||||||
- Many shapes cost: draw repeated marks (candles, bars) as one `Path` per look.
|
- **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`).
|
||||||
- Streaming data needs `amane::request_frame()` in the view while levels are live; a Service write alone only got about 11 redraws a second from Hyprland.
|
- 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).
|
||||||
- `Rectangle::child` holds one child; stack layers with `Stack`.
|
|
||||||
|
## Performance rules
|
||||||
|
|
||||||
|
- **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
|
## Develop
|
||||||
|
|
||||||
1. Type-check on the dev host without the desktop. A scratch crate outside the repo (don't commit a `Cargo.toml`, Amane generates its own):
|
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).
|
||||||
```toml
|
|
||||||
[package]
|
|
||||||
name = "amane-shell"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "amane-shell"
|
|
||||||
path = '/root/amane-config/src/main.rs'
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
amane = { path = '/root/amane' }
|
|
||||||
|
|
||||||
[workspace]
|
|
||||||
```
|
|
||||||
|
|
||||||
Then `cargo clippy` in that crate. (It is at `/root/.hermes/cache/scratch/amane-check/` on the dev host.)
|
|
||||||
|
|
||||||
2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (5 tests).
|
|
||||||
|
|
||||||
## Testing on the desktop
|
## Testing on the desktop
|
||||||
|
|
||||||
`tools/amane-remote.sh HOST COMMAND`; the full command list is in the comment at the top of the script. The main ones:
|
`tools/amane-remote.sh HOST COMMAND`; the full list is at the top of the script. Its output carries "bash -c uid" noise: `grep -v` it.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
H=fabian@192.168.2.169
|
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 deploy src # back up the remote src once, then replace it
|
||||||
tools/amane-remote.sh $H build # amane compile
|
tools/amane-remote.sh $H build # amane compile
|
||||||
tools/amane-remote.sh $H start # runs the shell WITH frame logging (AMANE_FRAMES=1)
|
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 fps 3 # frames, average gap, gpu wait
|
||||||
tools/amane-remote.sh $H anim island # open/close 3x, every frame gap
|
tools/amane-remote.sh $H anim island # open/close 3x; stutter = 20-500 ms gaps
|
||||||
tools/amane-remote.sh $H shot magic-island out.png
|
|
||||||
tools/amane-remote.sh $H sinks start # null sinks island_test_a/_b
|
tools/amane-remote.sh $H sinks start # null sinks island_test_a/_b
|
||||||
tools/amane-remote.sh $H players start 4 # fake MPRIS players (tools/fake_mpris.py), "Synth Test" makes real sound into island_test_a
|
tools/amane-remote.sh $H players start 4 # fake MPRIS players
|
||||||
tools/amane-remote.sh $H ipc island status
|
|
||||||
tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical session's environment
|
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.
|
- Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound.
|
||||||
- The island follows the focused monitor: read its geometry from `hyprctl layers -j` (namespace `magic-island`) right before each `grim` capture.
|
- `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.
|
||||||
- The user's pointer may be over the island and open or collapse it during a test; check `status` and capture right after `open`.
|
- **Animations:** capture a strip in a loop (`grim -g "3840,0 300x60"`) while dispatching the change, then tile the frames with ffmpeg.
|
||||||
- To tune a visualiser, sample the real levels read-only (`pw-record --target <stream> | cava`, with `timeout -s KILL` on each stage; a plain `timeout` on a pipeline orphans cava) and look at the distribution first. Typical music sits at 0.15–0.35.
|
- Keys: `wtype -k Escape`. Hover can't be faked with `movecursor`; real motion moves the user's mouse, so ask first.
|
||||||
- In `pkill -f` patterns over SSH, use `[p]ython3 ...` so the pattern doesn't match the remote shell itself.
|
- 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
|
### After testing
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
tools/amane-remote.sh $H players stop
|
tools/amane-remote.sh $H players stop
|
||||||
tools/amane-remote.sh $H sinks stop
|
tools/amane-remote.sh $H sinks stop
|
||||||
tools/amane-remote.sh $H sh 'kill $(cat shell.pid); sleep 0.5; rm -f shell.log
|
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
|
||||||
setsid -f sh -c "echo \$\$ > shell.pid; exec ~/.cache/amane/project/target/debug/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'
|
||||||
```
|
```
|
||||||
|
|
||||||
The desktop's `amane` CLI is older than the docs: no `~/.cache/amane/amane-shell` is saved and `amane run` doesn't apply, so the shell runs from `~/.cache/amane/project/target/debug/amane-shell`.
|
|
||||||
|
|
||||||
## Open threads
|
## Open threads
|
||||||
|
|
||||||
- The candle ring is still mirrored (`mirrored()`); the user had the pill bars unmirrored and may want the same for the ring.
|
- **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.
|
||||||
|
- The candle ring is still mirrored (`mirrored()`); the pill's bars aren't.
|
||||||
|
- 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".
|
- See README "Known limits".
|
||||||
|
|||||||
@@ -2,14 +2,168 @@
|
|||||||
|
|
||||||
A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card.
|
A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card.
|
||||||
|
|
||||||
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign.
|
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing". It opens too, into the last song played (kept across restarts) with Play, which starts its player and asks it to play, and chips that start the players of `island.idle.players` (desktop file names, `gtk-launch`).
|
||||||
- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next.
|
- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next.
|
||||||
- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays.
|
- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays.
|
||||||
- **Output switcher:** moves only the shown player's audio to another device.
|
- **Output switcher:** moves only the shown player's audio to another device.
|
||||||
- **Per-player volume** (scroll the volume chip, click to mute), set on that player's own PulseAudio/PipeWire streams, so it also works for browsers that ignore MPRIS volume.
|
- **Per-player volume** (scroll the volume chip, click to mute), set on that player's own PulseAudio/PipeWire streams, so it also works for browsers that ignore MPRIS volume.
|
||||||
- **Real 10-band equalizer per player** (31 Hz to 16 kHz, ±12 dB): a PipeWire filter-chain per player, with 8 presets (Flat, Bass, Treble, Vocal, Pop, Rock, Jazz, Classic). Curves are saved per player.
|
- **Real 10-band equalizer per player** (31 Hz to 16 kHz, ±12 dB): a PipeWire filter-chain per player, with 8 presets (Flat, Bass, Treble, Vocal, Pop, Rock, Jazz, Classic). Curves are saved per player.
|
||||||
- **Background:** a diagonal gradient from the cover's own colours, darkened so white text stays readable.
|
- **Background:** a diagonal gradient in the wallpaper's colours (see Colours); optionally the open card takes the song cover's colours.
|
||||||
- **Opening animation:** the pill fades out, the empty shape grows, then the card fades in. Closing runs backwards. Moving the pointer off the open card closes it.
|
- **Opening animation:** the pill fades out, the empty shape grows, then the card fades in. Closing runs backwards. Moving the pointer off the open card closes it.
|
||||||
|
- **Every monitor:** each monitor shows the pill. The card opens only on the monitor you click (a keybind opens it on the focused one), and only the focused monitor's pill shows live bars; the others show still bars, which keeps the cost at about one island's.
|
||||||
|
- **Workspace pill:** top left on every monitor, that monitor's Hyprland workspaces as round numbered slots in the island's look. The focused workspace sits on an accent disc, which slides over (stretching a little on the way) when the workspace changes; the one shown on another monitor has a softer accent, empty ones are dim. Click one to switch to it.
|
||||||
|
- **System tray:** right of the workspace pill, a pill with every tray app's icon (StatusNotifierItem). Left click activates the app (or opens its menu if that's all it offers), right click opens its menu, drawn in the island's look (checkmarks, separators, submenus with a way back, disabled entries dim), middle click is the app's secondary action, scrolling its scroll. Nothing on this desktop ran a tray watcher, so the shell owns `org.kde.StatusNotifierWatcher` on the session bus and serves it (if another bar already does, the shell shows no tray). Icons come from the app's icon name (its own theme folder, gtk's icon theme, hicolor, AdwaitaLegacy, Adwaita, `/usr/share/pixmaps`) or from the pixels it sends, written as PNGs to `$XDG_RUNTIME_DIR/amane-tray/`. An app that quits takes its icon with it (`src/tray.rs`).
|
||||||
|
- **Control centre:** top right on every monitor, a pill with Ethernet, Wi-Fi (or the hotspot), VPN, Bluetooth and the battery. Click it and it opens, in the island's way, into a panel like macOS's Control Center. See [Control centre](#control-centre).
|
||||||
|
- **Out of the way of fullscreen windows:** on a monitor whose workspace shows a fullscreen window (Hyprland fullscreen 2 or 3; only maximised keeps the bar), the island, the workspace pill (with the tray) and the control centre's pill slide out of sight. Resting the pointer on that monitor's top edge for 0.3 s brings them back until it has been off them and the edge for 0.7 s; an open card or panel stays. Everywhere else the bar is always there. Hyprland's event socket says when windows go fullscreen or workspaces change, and its command socket answers `monitors` and `clients`; nothing polls. The edge strip is a one-pixel Overlay layer, there only on fullscreen monitors (it takes their top pixel row). While hidden the bar draws nothing at all, a playing player included (`src/edge.rs`).
|
||||||
|
- **Room for the bar:** windows that aren't fullscreen stay below the bar. An empty, click-through strip as tall as the pills reach (44 px) reserves the top of every monitor (a layer-shell exclusive zone, namespace `magic-reserve`); Hyprland keeps tiled and maximised windows out of it, plus `gaps_out`, while fullscreen windows cover it, just where the bar hides. The reservation goes away with the shell. The bar's own windows ignore it, so they stay at the top edge.
|
||||||
|
- **Peeks:** some events bring one part in for a moment over a fullscreen window: the workspace pill alone (without the tray) for 1.6 s when the workspace changes (with the disc sliding over), the island for 3.5 s when the song changes or a player appears, on the focused monitor. The island also pops (it widens and glows, then lets go) on those events when it's visible anyway. The control centre never peeks.
|
||||||
|
|
||||||
|
## Control centre
|
||||||
|
|
||||||
|
The pill shows only what runs: the Ethernet socket while a cable is connected, Wi-Fi signal while iwd runs (dim while its radio is off; the hotspot sign instead while it's on), the VPN lock only while a VPN connects or is connected (accent once connected), Bluetooth while bluetoothd runs (dim while powered off, accent with a device connected), and the battery with its percentage (a bolt while charging, red below 15 %). The pill is as wide as what it shows.
|
||||||
|
|
||||||
|
The panel has:
|
||||||
|
|
||||||
|
- **Tiles:** Wi-Fi, Bluetooth, VPN, Hotspot, and Ethernet across the panel when the machine has a wired card. Accent = on; grey with a grey strike through the icon = its service isn't running (iwd, bluetoothd, the openvpn service); grey without the strike = nothing to work with (no device, hotspot not set up). Wi-Fi, Bluetooth and VPN open their menu when clicked anywhere; Hotspot switches at once.
|
||||||
|
- *Wi-Fi* (iwd): iwd stopped → grey; the switch in its menu runs `rc-service iwd start`. iwd running → the switch turns the device (station) on or off. The menu is a map: this desktop as a bubble in the middle (its hostname under it), every network in range floating around it, nearer the stronger its signal, rings rippling out while iwd scans. Each is tied to the bubble by a line: grey and wobbling while not connected; after a click the line runs out from the bubble to it until it's connected; then solid, with a pulse travelling along it. Click a saved or open network to join it (`iwctl station <dev> connect`), the connected one to disconnect; a new network asks for its password in a popup first (802.1x networks still need `iwctl`). If joining fails, the popup says why, in iwctl's words. Right-click a saved network for its saved password (read with `doas -n cat /var/lib/iwd/<profile>.psk`), which Copy (or a click on it) puts on the clipboard through `wl-copy`. Click the bubble in the middle to search again: rings run out from it while iwd scans. The tile and the connected network show the receive link rate (iwd's diagnostics, asked at most once a minute). "Stop the Wi-Fi service" at the bottom runs `rc-service iwd stop`. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it.
|
||||||
|
- *Bluetooth* (BlueZ, through Amane's `Bluetooth`): the switch powers the adapter; with bluez installed but stopped it runs `rc-service bluetooth start`. The menu is the same map with the devices bluez knows or finds (it scans while open): connected ones closest, then paired, then the rest. Click to connect, disconnect, or pair (then trust and connect). Pairing and connecting go through a `bluetoothctl` session with its own agent: a PIN or passkey it needs, a code to confirm or to type on the device are asked in a popup, and a failure shows why. Click the bubble to search for devices (`bluetoothctl --timeout 15 scan on`).
|
||||||
|
- *VPN*: every openrc service `/etc/init.d/openvpn.*` (here `openvpn.swiss`), each with a switch. Connecting while openrc has it *inactive*, connected once its `up.sh` marks it *started*. A switch runs `rc-service openvpn.<name> start|stop`.
|
||||||
|
- *Hotspot* (iwd access point): see [Hotspot setup](#hotspot-setup).
|
||||||
|
- *Ethernet*: every real wired card in `/sys/class/net` (a device behind it, not wireless): connected or no cable, and the link speed it negotiated (`speed`, as 1 Gb/s). A click turns the panel into its details (it morphs like into any menu; the back button returns): the interface and driver, status (speed, duplex), every IPv4 and IPv6 address, gateway, DNS servers, MAC, MTU and traffic since boot (`ip -o addr`, `ip -o route`, `/etc/resolv.conf`, sysfs). Addresses, gateway, DNS and MAC have a Copy chip; a click anywhere on such a row copies it (addresses without their prefix).
|
||||||
|
- **Popups:** questions (a password, PIN or passkey), a code to confirm, a saved password or why something failed, in a small card over the menu. Only a popup with a field takes the keyboard (Enter answers, Escape cancels); the others leave keys to other windows until clicked.
|
||||||
|
- **Menus:** one shape the whole way: the panel fades its content out and takes the menu's size (the Wi-Fi and Bluetooth maps reach from the monitor's right edge to the media island's pill, leaving a 16 px gap, room for the pill's pop: 822 × 480 px on a 1920 px monitor; at most 8 networks or devices, the strongest first, so their labels never meet), then the menu's content fades in over it from a window of its own (so the maps' animation redraws only that window). The back button returns to the panel. Moving the pointer off both closes the menu where it is: its content fades out and it shrinks straight into the pill, without the panel growing back first.
|
||||||
|
- **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. With `control.services.extra_output` set (see [Settings](#settings)) a second slider follows for the output whose name contains it (here `NR515`: the `[TX-NR515]` receiver on HDMI 1). Clicking the block elsewhere opens the Sound menu: every output with its own slider and mute; click a name to make it the default output.
|
||||||
|
- **Display:** the laptop screen's brightness and the keyboard light (`tpacpi::kbd_backlight`: Off, Low, High), written straight to their sysfs files (a udev rule makes them writable by everyone on this laptop), else through logind, which refuses a shell without a seat (one started over ssh). For external monitors see [External monitor brightness](#external-monitor-brightness).
|
||||||
|
- **Performance:** the firmware profiles in `/sys/firmware/acpi/platform_profile_choices` (Low power, Balanced, Performance), and the battery's state and time left. Picking one sets the platform profile and the CPU governor and energy preference with it: Low power = `powersave` + `power`, Balanced = `powersave` + `balance_performance`, Performance = the `performance` governor.
|
||||||
|
- **External GPU:** the GPU the machine didn't boot with (`boot_vga = 0`, here the Radeon RX 6600 in the Thunderbolt enclosure): load, temperature, power draw, video memory, and every program that has it open with its video memory (and its share of the GPU while the driver reports engine time). Found through `/proc/<pid>/fdinfo` (`drm-pdev`), only while the panel is open. Root's processes can't be read and don't show; the shell itself shows up, because Vulkan opens every GPU.
|
||||||
|
|
||||||
|
Changes that need root (starting services, the performance mode) run as `doas -n <fixed command>`, so they need a doas rule without a password for these commands (here `permit nopass fabian`). Nothing from other programs is passed to a shell.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
amane ipc call control toggle # also: open, close
|
||||||
|
amane ipc call control menu wifi # also: bluetooth, vpn, sound, close
|
||||||
|
amane ipc call control status # expanded=<bool> menu=<name|none> wifi=<state> vpn=<state> profile=<p> battery=<n%> prompt=<what the popup shows|none>
|
||||||
|
amane ipc call control wifi join <SSID> # as a click on it in the map; also: wifi password <SSID>, wifi search
|
||||||
|
amane ipc call control bluetooth pair <ADDRESS> # also: bluetooth connect <ADDRESS>, bluetooth search
|
||||||
|
amane ipc call control prompt answer <text> # answers the popup; also: prompt cancel
|
||||||
|
amane ipc call control ethernet # as a click on the Ethernet tile
|
||||||
|
amane ipc call tray list # every tray item: key, title, icon file
|
||||||
|
amane ipc call tray open <n> # the n-th icon's menu, as a right click; also: activate <n>
|
||||||
|
amane ipc call tray menu # the open menu's entries; then: choose <id>, enter <id>, back, close
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hotspot setup
|
||||||
|
|
||||||
|
iwd turns the Wi-Fi device into an access point, so the laptop isn't a Wi-Fi client while the hotspot is on (the internet then comes from `eth0`). Once, as root:
|
||||||
|
|
||||||
|
1. Let iwd hand out addresses, in `/etc/iwd/main.conf`. This also makes iwd configure IPv4 for Wi-Fi as a client:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[General]
|
||||||
|
EnableNetworkConfiguration=true
|
||||||
|
```
|
||||||
|
|
||||||
|
2. A profile `/var/lib/iwd/ap/<Name>.ap` (`man iwd.ap`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Security]
|
||||||
|
Passphrase=at-least-eight-characters
|
||||||
|
|
||||||
|
[IPv4]
|
||||||
|
Address=192.168.80.1
|
||||||
|
DNSList=<your router, or a public resolver>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. `hotspot = "<Name>"` under `[control.services]` in `config.toml`, or Control centre › Services in the config window (applies live).
|
||||||
|
4. To share `eth0`'s internet with the hotspot's devices, forwarding and NAT (iwd doesn't do this), e.g. with nftables:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sysctl -w net.ipv4.ip_forward=1
|
||||||
|
nft add table ip hotspot
|
||||||
|
nft 'add chain ip hotspot post { type nat hook postrouting priority 100 ; }'
|
||||||
|
nft add rule ip hotspot post ip saddr 192.168.80.0/24 oifname eth0 masquerade
|
||||||
|
```
|
||||||
|
|
||||||
|
Clicking the tile switches the device to access-point mode and calls `StartProfile(<Name>)`; if that fails (no such profile) it switches back to a client. The tile shows the profile's name, then how many devices are connected.
|
||||||
|
|
||||||
|
### External monitor brightness
|
||||||
|
|
||||||
|
External monitors take brightness over DDC/CI (VCP code `10`), which `ddcutil` speaks. Not wired in yet; to try it:
|
||||||
|
|
||||||
|
1. `emerge --ask app-misc/ddcutil`
|
||||||
|
2. Load `i2c-dev`: `modprobe i2c-dev`, and to keep it, `modules="i2c-dev"` in `/etc/conf.d/modules`.
|
||||||
|
3. Access without root: ddcutil's udev rule gives the `/dev/i2c-*` buses to group `i2c`; `gpasswd -a fabian i2c`, then log in again.
|
||||||
|
4. Turn on DDC/CI in each monitor's on-screen menu, if it has the option.
|
||||||
|
5. `ddcutil detect` lists the monitors; `ddcutil --display 1 getvcp 10` reads one's brightness, `ddcutil --display 1 setvcp 10 60` sets it.
|
||||||
|
|
||||||
|
Each call takes 50–300 ms, so a slider has to send only where the drag ends. Monitors behind docks or MST hubs often don't pass DDC; the eGPU's own DP and HDMI ports usually do. Once `ddcutil detect` works, the Display block can get one slider per external monitor.
|
||||||
|
|
||||||
|
## Colours
|
||||||
|
|
||||||
|
The shell takes its colours from the wallpaper through matugen. Skwd-wall v2 runs matugen on every new wallpaper and renders `matugen/amane.colors` into `~/.config/amane/theme`; the shell picks the file up the moment it changes (inotify through `amane::watch_file`) and cross-fades to the new colours. Without the file it keeps its original pink on purple; deleting it takes effect at the next start.
|
||||||
|
|
||||||
|
Register the template in Skwd-wall as a matugen integration:
|
||||||
|
|
||||||
|
- template: `~/.config/amane/matugen/amane.colors`
|
||||||
|
- output: `~/.config/amane/theme`
|
||||||
|
|
||||||
|
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; 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`, 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).
|
||||||
|
|
||||||
|
```lua
|
||||||
|
hl.layer_rule({
|
||||||
|
name = "amane-glass",
|
||||||
|
match = { namespace = "^magic-.*$" },
|
||||||
|
blur = true,
|
||||||
|
ignore_alpha = 0.2,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The blur's strength is Hyprland's own (`decoration.blur.size`, `passes`; also on the config window's Decoration page).
|
||||||
|
|
||||||
|
### Hyprland options
|
||||||
|
|
||||||
|
The Hyprland pages list every option Hyprland itself describes (`hyprctl descriptions`), with its current value and default, and whether your Hyprland config sets it. A change applies at once (`hl.config`) and is kept in `config.toml` under `[hyprland.*]`; the shell writes those to `~/.config/hypr/amane.lua`. Load that file last in `hyprland.lua`, so the window's values survive a Hyprland reload and win over the rest of your config:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
require("amane")
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset on a Hyprland option removes it from both files and reloads Hyprland's config, so your own value comes back.
|
||||||
|
|
||||||
|
Gradients are typed `rgba(80d9ffff) rgba(b18cffff) 45deg` (or `#80d9ff`), points `0 0`, gaps `6` or `6 10` (css-style).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -19,9 +173,25 @@ src/island.rs the view: pill, card, ring, equalizer, dropdowns, animation,
|
|||||||
src/sound.rs per-player volume, mute, output and equalizer (pactl, pw-cli, pipewire)
|
src/sound.rs per-player volume, mute, output and equalizer (pactl, pw-cli, pipewire)
|
||||||
src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava)
|
src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava)
|
||||||
src/art.rs cover download (curl) and the palette file
|
src/art.rs cover download (curl) and the palette file
|
||||||
|
src/workspaces.rs the workspace pill, top left on every monitor
|
||||||
|
src/control.rs the control centre: status pill top right, its panel and menus, `control` IPC
|
||||||
|
src/map.rs the Wi-Fi and Bluetooth menus' map: this desktop in the middle, what it can connect to around it
|
||||||
|
src/prompt.rs the control centre's popup: passwords, PINs, codes, saved passwords, why a connection failed; joins through iwctl, pairs through bluetoothctl
|
||||||
|
src/edge.rs hides the bar over fullscreen windows (Hyprland's sockets), the edge strip, peeks, the workspace slide, the space reserved for the bar
|
||||||
|
src/tray.rs the system tray: StatusNotifierWatcher and host, icon lookup, dbusmenu menus
|
||||||
|
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 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
|
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)
|
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq)
|
||||||
tools/ remote test driver and fake MPRIS players, see AGENTS.md
|
tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), perf.sh (CPU, GPU, memory of a process), see AGENTS.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -32,27 +202,37 @@ On the desktop that runs the shell:
|
|||||||
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
|
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
|
||||||
- `cava`, `curl`
|
- `cava`, `curl`
|
||||||
- the Fira Code font
|
- the Fira Code font
|
||||||
|
- for the control centre: `iwd`, openrc `openvpn.<name>` services, `pciutils` (`lspci`), `wl-clipboard` (`wl-copy`, for copying a saved password or an address), `iproute2` (`ip`, for the Ethernet details), optionally `bluez` (with `bluetoothctl`), and a doas rule without a password for `rc-service`, `sh` and `cat` (see [Control centre](#control-centre))
|
||||||
|
|
||||||
## Install and run
|
## Install and run
|
||||||
|
|
||||||
The shell lives in `~/.config/amane/`. Copy (or clone) this repo's `src/` there, then build and start:
|
The shell lives in `~/.config/amane/`. Clone this repo there (or copy its `src/` and `matugen/`), then build and start:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
amane compile
|
amane compile
|
||||||
~/.cache/amane/project/target/debug/amane-shell
|
amane run
|
||||||
```
|
```
|
||||||
|
|
||||||
The installed CLI on the current desktop is older than the Amane docs: it doesn't save `~/.cache/amane/amane-shell`, so `amane run` has nothing to start. Run the binary above directly.
|
`amane compile` saves the shell as `~/.cache/amane/amane-shell`, which `amane run` starts.
|
||||||
|
|
||||||
## Control it
|
## Control it
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
amane ipc call island toggle # also: open, close
|
amane ipc call island toggle # also: open, close
|
||||||
amane ipc call island next # show the next player
|
amane ipc call island next # show the next player
|
||||||
|
amane ipc call island play-pause # play/pause the shown player; the icon flips at once
|
||||||
amane ipc call island menu output # open a dropdown: output | player | close
|
amane ipc call island menu output # open a dropdown: output | player | close
|
||||||
amane ipc call island preset Rock # apply an equalizer preset to the shown player
|
amane ipc call island preset Rock # apply an equalizer preset to the shown player
|
||||||
amane ipc call island output <sink> # move the shown player to a sink (pactl sink name)
|
amane ipc call island output <sink> # move the shown player to a sink (pactl sink name)
|
||||||
amane ipc call island status # player=<identity> expanded=<bool> menu=<bool>
|
amane ipc call island status # player=<identity> expanded=<bool> menu=<bool>
|
||||||
|
amane ipc call config toggle # the config window; also: open, close
|
||||||
|
amane ipc call config page island # open it on a page: appearance, island, ..., hyprland:decoration
|
||||||
|
amane ipc call config search blur # open it searching
|
||||||
|
amane ipc call config get island.pill.width
|
||||||
|
amane ipc call config set island.pill.width 260 # any setting by its config.toml path
|
||||||
|
amane ipc call config set hyprland.decoration.blur.size 3
|
||||||
|
amane ipc call config reset island.pill.width
|
||||||
|
amane ipc call config status # open=<bool> page=<id> errors=<n> search=<words>
|
||||||
```
|
```
|
||||||
|
|
||||||
Example Hyprland keybind:
|
Example Hyprland keybind:
|
||||||
@@ -63,8 +243,11 @@ bind = SUPER, M, exec, amane ipc call island toggle
|
|||||||
|
|
||||||
## Runtime files
|
## Runtime files
|
||||||
|
|
||||||
|
- `~/.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>`.
|
- `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`.
|
||||||
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout.
|
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`, the shaders (`*.wgsl`). On tmpfs, gone at logout.
|
||||||
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
|
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
|
||||||
|
|
||||||
## Known limits
|
## Known limits
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# matugen template for amane: skwd-wall renders it on every new wallpaper into ~/.config/amane/theme,
|
||||||
|
# which the shell picks up the moment it changes. key=value, one colour per line; a missing line keeps the default
|
||||||
|
accent={{colors.primary.default.hex}}
|
||||||
|
on_accent={{colors.on_primary.default.hex}}
|
||||||
|
accent_soft={{colors.primary_container.default.hex}}
|
||||||
|
flame={{colors.primary_fixed.default.hex}}
|
||||||
|
text={{colors.on_surface.default.hex}}
|
||||||
|
dim={{colors.on_surface_variant.default.hex}}
|
||||||
|
outline={{colors.outline_variant.default.hex}}
|
||||||
|
surface={{colors.surface_container.default.hex}}
|
||||||
|
surface_high={{colors.surface_container_high.default.hex}}
|
||||||
|
surface_low={{colors.surface_container_lowest.default.hex}}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
+12
-1
@@ -7,6 +7,7 @@ use std::{env, fs, thread};
|
|||||||
use amane::{Media, Service};
|
use amane::{Media, Service};
|
||||||
|
|
||||||
use crate::island::{self, Island};
|
use crate::island::{self, Island};
|
||||||
|
use crate::config::island::timing;
|
||||||
use crate::parse;
|
use crate::parse;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -41,7 +42,17 @@ impl Service for Art {
|
|||||||
*Self::write() = Self { url, path };
|
*Self::write() = Self { url, path };
|
||||||
}
|
}
|
||||||
|
|
||||||
thread::sleep(Duration::from_secs(1));
|
// 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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+993
@@ -0,0 +1,993 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{LazyLock, Mutex, PoisonError, RwLock};
|
||||||
|
|
||||||
|
use amane::Color;
|
||||||
|
|
||||||
|
use crate::parse::{self, Value};
|
||||||
|
use crate::{hypr, theme};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* every setting the shell has, in ~/.config/amane/config.toml. each is declared once
|
||||||
|
* below: the macro makes a typed function for the code (config::island::pill::width())
|
||||||
|
* and an entry in OPTIONS for the config window and the file. the file holds every
|
||||||
|
* setting with its help above it; saving it applies at once (theme.rs watches it).
|
||||||
|
* [hyprland.*] tables are Hyprland options changed in the window, see hypr.rs
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||||
|
pub enum Kind {
|
||||||
|
Bool,
|
||||||
|
Int,
|
||||||
|
Float,
|
||||||
|
Text,
|
||||||
|
Color,
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Opt {
|
||||||
|
// "module.group.name", also its place in config.toml
|
||||||
|
pub path: &'static str,
|
||||||
|
pub help: &'static str,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub range: Option<(f64, f64)>,
|
||||||
|
// the only words a text setting takes, shown as chips; empty: any text
|
||||||
|
pub choices: &'static [&'static str],
|
||||||
|
pub default: fn() -> Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Opt {
|
||||||
|
pub fn module(&self) -> &'static str {
|
||||||
|
self.path.split('.').next().unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn group(&self) -> &'static str {
|
||||||
|
self.path.split('.').nth(1).unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
self.path.rsplit('.').next().unwrap_or("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a type a setting can have: how it reads from and writes to the file
|
||||||
|
pub trait Setting: Sized {
|
||||||
|
const KIND: Kind;
|
||||||
|
|
||||||
|
fn read(value: &Value) -> Option<Self>;
|
||||||
|
fn value(&self) -> Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Setting for bool {
|
||||||
|
const KIND: Kind = Kind::Bool;
|
||||||
|
|
||||||
|
fn read(value: &Value) -> Option<Self> {
|
||||||
|
if let Value::Bool(on) = value { Some(*on) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(&self) -> Value {
|
||||||
|
Value::Bool(*self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Setting for f32 {
|
||||||
|
const KIND: Kind = Kind::Float;
|
||||||
|
|
||||||
|
fn read(value: &Value) -> Option<Self> {
|
||||||
|
value.number().map(|number| number as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// through its own shortest text, so 0.66 is written 0.66 and not 0.6600000262260437
|
||||||
|
fn value(&self) -> Value {
|
||||||
|
Value::Float(self.to_string().parse().unwrap_or(f64::from(*self)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! whole_setting {
|
||||||
|
($($ty:ty),*) => {$(
|
||||||
|
impl Setting for $ty {
|
||||||
|
const KIND: Kind = Kind::Int;
|
||||||
|
|
||||||
|
fn read(value: &Value) -> Option<Self> {
|
||||||
|
if let Value::Int(number) = value { <$ty>::try_from(*number).ok() } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(&self) -> Value {
|
||||||
|
Value::Int(i64::try_from(*self).unwrap_or(i64::MAX))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)*};
|
||||||
|
}
|
||||||
|
|
||||||
|
whole_setting!(u32, u64, usize);
|
||||||
|
|
||||||
|
impl Setting for String {
|
||||||
|
const KIND: Kind = Kind::Text;
|
||||||
|
|
||||||
|
fn read(value: &Value) -> Option<Self> {
|
||||||
|
value.text().map(String::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(&self) -> Value {
|
||||||
|
Value::Text(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Setting for Color {
|
||||||
|
const KIND: Kind = Kind::Color;
|
||||||
|
|
||||||
|
fn read(value: &Value) -> Option<Self> {
|
||||||
|
color(value.text()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(&self) -> Value {
|
||||||
|
Value::Text(self.hex())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Setting for [f32; 10] {
|
||||||
|
const KIND: Kind = Kind::List;
|
||||||
|
|
||||||
|
fn read(value: &Value) -> Option<Self> {
|
||||||
|
let Value::List(items) = value else { return None };
|
||||||
|
|
||||||
|
let numbers: Vec<f32> = items.iter().map(|item| item.number().map(|number| number as f32)).collect::<Option<_>>()?;
|
||||||
|
|
||||||
|
numbers.try_into().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(&self) -> Value {
|
||||||
|
Value::List(self.iter().map(Setting::value).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Setting for [u32; 10] {
|
||||||
|
const KIND: Kind = Kind::List;
|
||||||
|
|
||||||
|
fn read(value: &Value) -> Option<Self> {
|
||||||
|
let Value::List(items) = value else { return None };
|
||||||
|
|
||||||
|
let numbers: Vec<u32> = items.iter().map(u32::read).collect::<Option<_>>()?;
|
||||||
|
|
||||||
|
numbers.try_into().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(&self) -> Value {
|
||||||
|
Value::List(self.iter().map(Setting::value).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "#rrggbb" or "#rrggbbaa", the # optional
|
||||||
|
pub fn color(text: &str) -> Option<Color> {
|
||||||
|
let digits = text.trim().trim_start_matches('#');
|
||||||
|
|
||||||
|
if !(digits.len() == 6 || digits.len() == 8) || !digits.chars().all(|digit| digit.is_ascii_hexdigit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = |at: usize| u8::from_str_radix(&digits[at..at + 2], 16).ok();
|
||||||
|
|
||||||
|
Some(Color::rgba(channel(0)?, channel(2)?, channel(4)?, if digits.len() == 8 { channel(6)? } else { 255 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! settings {
|
||||||
|
($( $module:ident { $( $group:ident { $( $name:ident : $ty:ty $([ $($choice:literal),+ ])? = $default:expr $(, $min:literal ..= $max:literal)? ; $help:literal )* } )* } )*) => {
|
||||||
|
$( pub mod $module {
|
||||||
|
$( pub mod $group {
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use super::super::*;
|
||||||
|
|
||||||
|
$(
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn $name() -> $ty {
|
||||||
|
super::super::get(concat!(stringify!($module), ".", stringify!($group), ".", stringify!($name)), || $default)
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
} )*
|
||||||
|
} )*
|
||||||
|
|
||||||
|
pub static OPTIONS: &[Opt] = &[ $( $( $( Opt {
|
||||||
|
path: concat!(stringify!($module), ".", stringify!($group), ".", stringify!($name)),
|
||||||
|
help: $help,
|
||||||
|
kind: <$ty as Setting>::KIND,
|
||||||
|
range: settings!(@range $($min, $max)?),
|
||||||
|
choices: &[ $( $($choice),+ )? ],
|
||||||
|
default: || <$ty as Setting>::value(&$default),
|
||||||
|
}, )* )* )* ];
|
||||||
|
};
|
||||||
|
(@range) => { None };
|
||||||
|
(@range $min:literal, $max:literal) => { Some(($min as f64, $max as f64)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
settings! {
|
||||||
|
appearance {
|
||||||
|
font {
|
||||||
|
family: String = String::from("Fira Code"); "The font for all text. Takes effect when the shell restarts"
|
||||||
|
letter_width: f32 = 0.66, 0.3..=1.2; "How wide one letter is, as a share of the text size: chips are sized to their labels with it. Match it to the font"
|
||||||
|
}
|
||||||
|
text {
|
||||||
|
tiny: f32 = 10.0, 6..=40; "The smallest text: badges, the equalizer's band labels, details under map nodes"
|
||||||
|
caption: f32 = 10.5, 6..=40; "Subtitles under the control centre's tiles"
|
||||||
|
small: f32 = 11.0, 6..=40; "Secondary text: chips, notes, percentages, the artist line"
|
||||||
|
label: f32 = 11.5, 6..=40; "Labels in menus and popups"
|
||||||
|
body: f32 = 12.0, 6..=40; "Most text"
|
||||||
|
popup: f32 = 13.0, 6..=40; "A popup's title and its main line"
|
||||||
|
heading: f32 = 14.0, 6..=40; "A menu's title"
|
||||||
|
section: f32 = 15.0, 6..=40; "The equalizer's heading"
|
||||||
|
title: f32 = 19.0, 6..=60; "The song title on the open media card"
|
||||||
|
}
|
||||||
|
colors {
|
||||||
|
cover_colors: bool = false; "The open media card and the equalizer take the song cover's colours; off, the whole shell uses the wallpaper's"
|
||||||
|
transition_ms: u64 = 400, 0..=5000; "How long a colour change cross-fades, in milliseconds; 0 switches at once"
|
||||||
|
}
|
||||||
|
motion {
|
||||||
|
hover_ms: u64 = 220, 0..=2000; "How long hover highlights take to fade in and out"
|
||||||
|
}
|
||||||
|
surface {
|
||||||
|
design: String ["solid", "frosted", "liquid"] = String::from("solid"); "What the island, the pills, the menus, the control centre and its popups are made of: solid (the wallpaper's gradient with an accent glow), frosted (a thin tint, a hairline rim and a sheen) or liquid (clear glass with a light-catching edge). See-through ones need Hyprland's blur behind the shell: README, Glass"
|
||||||
|
}
|
||||||
|
solid {
|
||||||
|
opacity: f32 = 1.0, 0.05..=1.0; "How opaque the solid design is; below 1 the desktop shows through"
|
||||||
|
glow: f32 = 0.3, 0.0..=1.0; "Strength of the accent glow around each surface"
|
||||||
|
hover_glow: f32 = 0.7, 0.0..=1.0; "Strength of the glow while hovered"
|
||||||
|
}
|
||||||
|
frosted {
|
||||||
|
opacity: f32 = 0.62, 0.05..=1.0; "How opaque the frosted tint is"
|
||||||
|
rim: f32 = 0.19, 0.0..=1.0; "Opacity of the hairline white rim"
|
||||||
|
sheen: f32 = 0.13, 0.0..=1.0; "Opacity of the white highlight along the top edge"
|
||||||
|
shadow: f32 = 0.24, 0.0..=1.0; "Strength of the soft shadow under each surface"
|
||||||
|
}
|
||||||
|
liquid {
|
||||||
|
opacity: f32 = 0.4, 0.05..=1.0; "How opaque the glass is in its middle; clearer at the top left, denser at the bottom right"
|
||||||
|
rim: f32 = 0.33, 0.0..=1.0; "Opacity of the light rim"
|
||||||
|
rim_width: f32 = 1.5, 0.0..=4.0; "Width of the light rim, in pixels"
|
||||||
|
light: f32 = 0.235, 0.0..=1.0; "Opacity of the light caught at the top left edge"
|
||||||
|
glow: f32 = 0.22, 0.0..=1.0; "Strength of the accent glow around each surface"
|
||||||
|
}
|
||||||
|
fallback {
|
||||||
|
accent: Color = Color::rgb(247, 168, 200); "Accent, used until matugen writes ~/.config/amane/theme from the wallpaper"
|
||||||
|
on_accent: Color = Color::rgb(40, 18, 32); "Text and icons on the accent colour, without a theme file"
|
||||||
|
accent_soft: Color = Color::rgb(150, 82, 122); "The softer accent, without a theme file"
|
||||||
|
flame: Color = Color::rgb(255, 228, 240); "Highlights such as the candles' flame tips, without a theme file"
|
||||||
|
text: Color = Color::rgb(241, 236, 246); "Text, without a theme file"
|
||||||
|
dim: Color = Color::rgba(205, 192, 218, 160); "Secondary text, without a theme file"
|
||||||
|
outline: Color = Color::rgba(255, 255, 255, 26); "Outlines, without a theme file"
|
||||||
|
surface: Color = Color::rgb(24, 18, 30); "The middle of the background gradient, without a theme file"
|
||||||
|
surface_high: Color = Color::rgb(35, 26, 44); "The light end of the background gradient, without a theme file"
|
||||||
|
surface_low: Color = Color::rgb(13, 10, 18); "The dark end of the background gradient, without a theme file"
|
||||||
|
}
|
||||||
|
overlays {
|
||||||
|
chip: Color = Color::rgba(255, 255, 255, 18); "Fill of chips and blocks; white with little alpha reads on any dark surface"
|
||||||
|
chip_open: Color = Color::rgba(255, 255, 255, 34); "A chip whose menu is open"
|
||||||
|
chip_hover: Color = Color::rgba(255, 255, 255, 46); "A hovered chip or button"
|
||||||
|
track: Color = Color::rgba(255, 255, 255, 24); "Slider tracks and the time bar"
|
||||||
|
strike: Color = Color::rgba(205, 205, 212, 170); "The line through the icon of a service that isn't running"
|
||||||
|
idle_line: Color = Color::rgba(205, 205, 212, 85); "The wobbling line to a map node that isn't connected"
|
||||||
|
low_battery: Color = Color::rgb(255, 105, 97); "The battery when it runs low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
island {
|
||||||
|
animation {
|
||||||
|
open_ms: u64 = 560, 0..=5000; "How long the island takes to open into the card, and to close"
|
||||||
|
pill_gone: f32 = 0.18, 0.0..=1.0; "Share of the opening over which the pill fades out"
|
||||||
|
shape_done: f32 = 0.6, 0.0..=1.0; "Share of the opening by which the empty shape has grown; the card fades in after it"
|
||||||
|
hover_grow: f32 = 10.0, 0..=100; "How much wider the pill gets when hovered, in pixels"
|
||||||
|
bold: f32 = 2.0, 0..=8; "How much thicker the play, pause, previous and next icons get when hovered"
|
||||||
|
}
|
||||||
|
timing {
|
||||||
|
seek_shown_ms: u64 = 1500, 0..=10000; "How long the time bar keeps showing a seek while the player catches up"
|
||||||
|
cover_check_ms: u64 = 250, 50..=5000; "How often the cover art is checked for a change"
|
||||||
|
}
|
||||||
|
pill {
|
||||||
|
width: f32 = 220.0, 100..=800; "Width of the closed island (the pill)"
|
||||||
|
height: f32 = 38.0, 20..=100; "Height of every pill: the island, workspaces, tray and control centre"
|
||||||
|
top_gap: f32 = 6.0, 0..=100; "Space between the top of the monitor and the pills"
|
||||||
|
shadow: f32 = 34.0, 0..=120; "Room around every pill and panel for its glow"
|
||||||
|
bars: usize = 7, 1..=32; "Sound bars in the pill"
|
||||||
|
bars_width: f32 = 36.0, 8..=200; "Width of the pill's sound bars"
|
||||||
|
bars_height: f32 = 26.0, 4..=100; "Height of the pill's sound bars"
|
||||||
|
bars_boost: f32 = 1.4, 0.2..=5.0; "How much the pill's bars amplify the sound levels"
|
||||||
|
}
|
||||||
|
card {
|
||||||
|
width: f32 = 560.0, 360..=1600; "Width of the open media card"
|
||||||
|
radius: f32 = 28.0, 0..=100; "Corner radius of the open card"
|
||||||
|
pad: f32 = 22.0, 0..=100; "Padding inside the card"
|
||||||
|
gap: f32 = 16.0, 0..=100; "Space between the card's sections"
|
||||||
|
side_gap: f32 = 20.0, 0..=100; "Space between the cd and the song details"
|
||||||
|
}
|
||||||
|
disc {
|
||||||
|
size: f32 = 204.0, 80..=600; "The cd's box, candles included; also the height of the card's top section"
|
||||||
|
cover: f32 = 136.0, 40..=600; "Diameter of the cover on the cd"
|
||||||
|
hole: f32 = 26.0, 0..=200; "Diameter of the cd's hole"
|
||||||
|
candles: usize = 40, 4..=200; "Candles in the ring around the cd"
|
||||||
|
candle_reach: f32 = 20.0, 0..=200; "How far the candles reach past the cover"
|
||||||
|
cover_pixels: u32 = 256, 32..=2048; "Size covers are decoded at, for the pill and the cd"
|
||||||
|
}
|
||||||
|
controls {
|
||||||
|
chip_height: f32 = 24.0, 10..=80; "Height of the output, player and volume chips"
|
||||||
|
output_chip_max: f32 = 150.0, 40..=600; "The widest the output chip gets"
|
||||||
|
volume_chip_width: f32 = 60.0, 20..=300; "Width of the volume chip"
|
||||||
|
bar_height: f32 = 8.0, 1..=40; "Thickness of the time bar"
|
||||||
|
knob: f32 = 16.0, 2..=60; "Size of the time bar's and equalizer's knobs"
|
||||||
|
height: f32 = 44.0, 16..=120; "Height of the row with previous, play and next"
|
||||||
|
}
|
||||||
|
equalizer {
|
||||||
|
header_height: f32 = 26.0, 10..=100; "Height of the equalizer's heading row"
|
||||||
|
slider_height: f32 = 140.0, 40..=600; "Height of the equalizer's sliders"
|
||||||
|
track_width: f32 = 10.0, 1..=60; "Width of a slider's track"
|
||||||
|
label_height: f32 = 14.0, 6..=60; "Height of the band labels under the sliders"
|
||||||
|
curve_steps: usize = 72, 8..=500; "Points on the curve drawn through the bands"
|
||||||
|
preset_height: f32 = 30.0, 10..=100; "Height of a preset button"
|
||||||
|
preset_gap: f32 = 8.0, 0..=60; "Space between the preset buttons"
|
||||||
|
}
|
||||||
|
menus {
|
||||||
|
top: f32 = 92.0, 0..=600; "How far below the card's padding the output and player menus open"
|
||||||
|
row_height: f32 = 30.0, 12..=100; "Height of a row in those menus"
|
||||||
|
rows: usize = 10, 1..=50; "Rows a menu shows before it scrolls"
|
||||||
|
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 {
|
||||||
|
equalizer {
|
||||||
|
max_gain: f32 = 12.0, 1..=30; "How far a band can lift or cut, in dB"
|
||||||
|
bands: [u32; 10] = parse::BANDS, 10..=24000; "The 10 bands' frequencies in Hz. Equalizers started afterwards use them"
|
||||||
|
}
|
||||||
|
presets {
|
||||||
|
flat: [f32; 10] = [0.0; 10], -30..=30; "Gains of the Flat preset, in dB, low band to high"
|
||||||
|
bass: [f32; 10] = [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], -30..=30; "Gains of the Bass preset"
|
||||||
|
treble: [f32; 10] = [-2.0, -1.5, -1.0, 0.0, 1.0, 2.0, 3.0, 4.5, 5.5, 6.0], -30..=30; "Gains of the Treble preset"
|
||||||
|
vocal: [f32; 10] = [-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.5, 2.0, 0.0, -1.0], -30..=30; "Gains of the Vocal preset"
|
||||||
|
pop: [f32; 10] = [-1.0, 1.0, 2.5, 3.5, 2.5, 0.5, -0.5, -0.5, 1.0, 2.0], -30..=30; "Gains of the Pop preset"
|
||||||
|
rock: [f32; 10] = [5.0, 4.0, 2.5, -0.5, -1.5, -0.5, 2.0, 3.5, 4.5, 5.0], -30..=30; "Gains of the Rock preset"
|
||||||
|
jazz: [f32; 10] = [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.5, 2.5, 3.0], -30..=30; "Gains of the Jazz preset"
|
||||||
|
classic: [f32; 10] = [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0], -30..=30; "Gains of the Classic preset"
|
||||||
|
}
|
||||||
|
timing {
|
||||||
|
poll_ms: u64 = 1000, 100..=10000; "How often players' streams, volumes and outputs are read"
|
||||||
|
hold_ms: u64 = 2500, 0..=10000; "How long a dragged volume keeps its place while PipeWire catches up"
|
||||||
|
}
|
||||||
|
spectrum {
|
||||||
|
bars: usize = 24, 4..=128; "Bars cava splits the sound into. Takes effect when the capture next starts"
|
||||||
|
check_ms: u64 = 200, 50..=5000; "How often the shown player is checked, to start or stop capturing its sound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaces {
|
||||||
|
pill {
|
||||||
|
slot: f32 = 28.0, 10..=100; "Size of one workspace's round slot"
|
||||||
|
slot_gap: f32 = 4.0, 0..=40; "Space between slots"
|
||||||
|
inset: f32 = 5.0, 0..=40; "Space between the slots and the pill's edge"
|
||||||
|
left: f32 = 12.0, 0..=400; "Distance from the monitor's left edge"
|
||||||
|
tray_gap: f32 = 8.0, 0..=100; "Space between the workspaces and the tray"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tray {
|
||||||
|
pill {
|
||||||
|
slot: f32 = 28.0, 10..=100; "Size of one tray icon's slot"
|
||||||
|
slot_gap: f32 = 4.0, 0..=40; "Space between slots"
|
||||||
|
inset: f32 = 5.0, 0..=40; "Space between the slots and the pill's edge"
|
||||||
|
icon: f32 = 18.0, 6..=80; "Size of the icons"
|
||||||
|
}
|
||||||
|
menu {
|
||||||
|
width: f32 = 260.0, 100..=800; "Width of an app's menu"
|
||||||
|
pad: f32 = 6.0, 0..=40; "Padding inside the menu"
|
||||||
|
row_height: f32 = 30.0, 12..=100; "Height of a menu row"
|
||||||
|
separator_height: f32 = 9.0, 1..=40; "Height of a separator"
|
||||||
|
max_rows: usize = 20, 1..=100; "The most rows a menu shows"
|
||||||
|
appear_ms: u64 = 180, 0..=2000; "How long the menu takes to appear"
|
||||||
|
close_ms: u64 = 400, 0..=5000; "How long after the pointer leaves the menu closes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
control {
|
||||||
|
services {
|
||||||
|
hotspot: String = String::new(); "The iwd access point profile the hotspot tile starts (/var/lib/iwd/ap/<name>.ap). Empty: the tile says how to set one up"
|
||||||
|
extra_output: String = String::new(); "A second Sound slider for the output whose name contains this, like an AV receiver. Empty: none"
|
||||||
|
}
|
||||||
|
animation {
|
||||||
|
open_ms: u64 = 520, 0..=5000; "How long the panel takes to open and close"
|
||||||
|
menu_ms: u64 = 380, 0..=5000; "How long a menu takes to open from its tile"
|
||||||
|
hover_grow: f32 = 6.0, 0..=100; "How much wider the pill gets when hovered"
|
||||||
|
collapse_ms: u64 = 250, 0..=5000; "How long after the pointer leaves the panel closes"
|
||||||
|
held_ms: u64 = 1200, 0..=10000; "How long a dragged slider shows where it was let go while the system catches up"
|
||||||
|
busy_s: u64 = 10, 1..=120; "How long a clicked tile shows it's working before giving up"
|
||||||
|
pending_s: u64 = 20, 1..=300; "How long a network or device shows as connecting after a click"
|
||||||
|
}
|
||||||
|
pill {
|
||||||
|
pad: f32 = 16.0, 0..=100; "Padding on each side of the pill's icons"
|
||||||
|
gap: f32 = 12.0, 0..=100; "Space between the pill's icons"
|
||||||
|
right: f32 = 12.0, 0..=400; "Distance of the pill and panel from the monitor's right edge"
|
||||||
|
}
|
||||||
|
panel {
|
||||||
|
width: f32 = 360.0, 240..=1000; "Width of the panel"
|
||||||
|
radius: f32 = 26.0, 0..=100; "Corner radius of the panel"
|
||||||
|
pad: f32 = 12.0, 0..=100; "Padding inside the panel"
|
||||||
|
gap: f32 = 10.0, 0..=100; "Space between the panel's tiles and blocks"
|
||||||
|
tile_height: f32 = 60.0, 30..=200; "Height of a connection tile"
|
||||||
|
button: f32 = 34.0, 16..=120; "Size of a tile's round button"
|
||||||
|
footer_height: f32 = 32.0, 16..=120; "Height of the Settings button at the bottom"
|
||||||
|
}
|
||||||
|
modules {
|
||||||
|
radius: f32 = 18.0, 0..=100; "Corner radius of the blocks under the tiles"
|
||||||
|
pad: f32 = 12.0, 0..=100; "Padding inside a block"
|
||||||
|
title_height: f32 = 18.0, 8..=80; "Height of a block's title row"
|
||||||
|
line_gap: f32 = 8.0, 0..=60; "Space between the lines in a block"
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
menus {
|
||||||
|
pad: f32 = 12.0, 0..=100; "Padding inside a menu"
|
||||||
|
header_height: f32 = 28.0, 10..=100; "Height of a menu's title row"
|
||||||
|
row_height: f32 = 44.0, 16..=150; "Height of a menu row"
|
||||||
|
rows: usize = 7, 1..=50; "The most VPN rows the VPN menu lists"
|
||||||
|
note_height: f32 = 18.0, 6..=80; "Height of a note line in a menu"
|
||||||
|
output_height: f32 = 22.0, 8..=100; "Height of a row in the Sound menu"
|
||||||
|
outputs: usize = 8, 1..=50; "The most outputs the Sound menu lists"
|
||||||
|
}
|
||||||
|
ethernet {
|
||||||
|
width: f32 = 500.0, 240..=1200; "Width of the Ethernet details"
|
||||||
|
detail_height: f32 = 28.0, 10..=100; "Height of a detail row"
|
||||||
|
label_width: f32 = 78.0, 20..=300; "Width of a detail's label"
|
||||||
|
copy_width: f32 = 58.0, 20..=200; "Width of a Copy button"
|
||||||
|
}
|
||||||
|
maps {
|
||||||
|
height: f32 = 480.0, 200..=1600; "Height of the Wi-Fi and Bluetooth maps"
|
||||||
|
min_width: f32 = 600.0, 200..=3000; "The narrowest a map gets on a small monitor (where it then runs under the island)"
|
||||||
|
island_gap: f32 = 16.0, 0..=200; "Space between a map and the media island's pill"
|
||||||
|
nodes: usize = 8, 1..=40; "The most networks or devices a map shows; the strongest come first"
|
||||||
|
bubble: f32 = 40.0, 10..=200; "Size of the bubble for this computer in the middle"
|
||||||
|
node_radius: f32 = 22.0, 6..=100; "Radius of a network's or device's disc"
|
||||||
|
icon: f32 = 18.0, 6..=80; "Size of the icons on the map"
|
||||||
|
label_width: f32 = 140.0, 40..=500; "Width of a node's label"
|
||||||
|
label_height: f32 = 30.0, 10..=100; "Height of a node's label"
|
||||||
|
drift_x: f32 = 26.0, 0..=200; "How far nodes float sideways"
|
||||||
|
drift_y: f32 = 16.0, 0..=200; "How far nodes float up and down"
|
||||||
|
ring_s: f32 = 1.6, 0.1..=10.0; "How long a search ring takes to ripple out, in seconds"
|
||||||
|
ring_gap_s: f32 = 0.5, 0.0..=10.0; "Time between the three search rings, in seconds"
|
||||||
|
ring_reach: f32 = 230.0, 20..=2000; "How far the search rings ripple"
|
||||||
|
}
|
||||||
|
polling {
|
||||||
|
links_ms: u64 = 2000, 200..=60000; "How often Wi-Fi, VPN and hotspot state is read"
|
||||||
|
power_ms: u64 = 2000, 200..=60000; "How often the battery and performance profile are read"
|
||||||
|
gpu_ms: u64 = 2000, 200..=60000; "How often the external GPU's programs are scanned while the panel is open"
|
||||||
|
gpu_wait_ms: u64 = 400, 50..=10000; "How often the GPU scan checks whether the panel has opened"
|
||||||
|
wifi_rate_s: u64 = 60, 1..=3600; "How long a Wi-Fi network's link speed is kept before it's asked again"
|
||||||
|
battery_estimate_s: u64 = 30, 1..=3600; "How long a battery time estimate is kept"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
popups {
|
||||||
|
layout {
|
||||||
|
width: f32 = 360.0, 200..=1000; "Width of a popup (password, PIN, failure)"
|
||||||
|
pad: f32 = 16.0, 0..=100; "Padding inside a popup"
|
||||||
|
header_height: f32 = 36.0, 10..=120; "Height of a popup's title row"
|
||||||
|
field_height: f32 = 36.0, 16..=120; "Height of the text field"
|
||||||
|
code_height: f32 = 40.0, 16..=120; "Height of a shown PIN or passkey"
|
||||||
|
line_height: f32 = 18.0, 8..=80; "Height of a line of text"
|
||||||
|
button_width: f32 = 92.0, 30..=300; "Width of a button"
|
||||||
|
button_height: f32 = 32.0, 16..=100; "Height of a button"
|
||||||
|
gap: f32 = 14.0, 0..=100; "Space between a popup's parts"
|
||||||
|
}
|
||||||
|
timing {
|
||||||
|
appear_ms: u64 = 220, 0..=2000; "How long a popup takes to appear"
|
||||||
|
session_s: u64 = 90, 5..=900; "How long a Bluetooth pairing may take before it's stopped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bar {
|
||||||
|
hiding {
|
||||||
|
dwell_ms: u64 = 300, 0..=5000; "How long the pointer rests on the top edge of a fullscreen monitor before the bar shows"
|
||||||
|
hide_ms: u64 = 700, 0..=10000; "How long after the pointer leaves the bar hides again"
|
||||||
|
slide_ms: u64 = 220, 0..=3000; "How long the bar takes to slide in or out"
|
||||||
|
}
|
||||||
|
peeks {
|
||||||
|
workspaces_ms: u64 = 1600, 0..=20000; "How long the workspaces show on a fullscreen monitor after a workspace change"
|
||||||
|
island_ms: u64 = 3500, 0..=20000; "How long the island shows on a fullscreen monitor after a song change"
|
||||||
|
pop_s: f32 = 0.7, 0.0..=5.0; "How long the island's pop lasts on a new song, in seconds"
|
||||||
|
switch_s: f32 = 0.35, 0.0..=5.0; "How long the workspace highlight takes to slide over, in seconds"
|
||||||
|
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
|
||||||
|
static VALUES: LazyLock<RwLock<HashMap<String, Value>>> = LazyLock::new(Default::default);
|
||||||
|
|
||||||
|
// what was wrong in the file when it was last read, for the config window
|
||||||
|
static ERRORS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* config.toml as the shell last wrote or read it. every save and every reread holds
|
||||||
|
* this lock, so the file watch can't read a save halfway or undo a change it raced
|
||||||
|
* with (it once reset settings to their defaults that way), and skips the shell's own saves
|
||||||
|
*/
|
||||||
|
static WRITTEN: Mutex<String> = Mutex::new(String::new());
|
||||||
|
|
||||||
|
fn written() -> std::sync::MutexGuard<'static, String> {
|
||||||
|
WRITTEN.lock().unwrap_or_else(PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: a lock and a hash lookup per call, a few hundred a frame; a snapshot struct if a profile ever shows it
|
||||||
|
fn get<T: Setting>(path: &str, default: impl FnOnce() -> T) -> T {
|
||||||
|
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).and_then(T::read).unwrap_or_else(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file() -> PathBuf {
|
||||||
|
theme::config_file("config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn option(path: &str) -> Option<&'static Opt> {
|
||||||
|
OPTIONS.iter().find(|option| option.path == path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// at startup: config.toml, or the first time, one made from the old shell.conf
|
||||||
|
pub fn load() {
|
||||||
|
let mut written = written();
|
||||||
|
|
||||||
|
let text = fs::read_to_string(file()).unwrap_or_else(|_| {
|
||||||
|
migrate();
|
||||||
|
|
||||||
|
let text = render();
|
||||||
|
|
||||||
|
let _ = write_file(&text);
|
||||||
|
|
||||||
|
text
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_text(&text);
|
||||||
|
|
||||||
|
*written = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// config.toml changed on disk: a hand edit applies, the shell's own saves are skipped. whether it applied
|
||||||
|
pub fn reread() -> bool {
|
||||||
|
let mut written = written();
|
||||||
|
let text = fs::read_to_string(file()).unwrap_or_default();
|
||||||
|
|
||||||
|
if text == *written {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_text(&text);
|
||||||
|
|
||||||
|
*written = text;
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// shell.conf's four settings, moved to their places in config.toml
|
||||||
|
fn migrate() {
|
||||||
|
let old = fs::read_to_string(theme::config_file("shell.conf")).unwrap_or_default();
|
||||||
|
let mut values = VALUES.write().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
|
||||||
|
for (key, text) in parse::settings(&old) {
|
||||||
|
let path = match key.as_str() {
|
||||||
|
"cover_colors" => "appearance.colors.cover_colors",
|
||||||
|
"transition_ms" => "appearance.colors.transition_ms",
|
||||||
|
"hotspot" => "control.services.hotspot",
|
||||||
|
"extra_output" => "control.services.extra_output",
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(option) = option(path)
|
||||||
|
&& let Ok(value) = from_text(option, &text)
|
||||||
|
{
|
||||||
|
values.insert(path.to_string(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the file's text, read anew: what it sets replaces everything set before
|
||||||
|
pub fn apply_text(text: &str) {
|
||||||
|
let (pairs, mut errors) = parse::toml(text);
|
||||||
|
let mut values = HashMap::new();
|
||||||
|
|
||||||
|
for (path, value) in pairs {
|
||||||
|
match check(&path, &value) {
|
||||||
|
Ok(()) => {
|
||||||
|
values.insert(path, value);
|
||||||
|
}
|
||||||
|
Err(why) => errors.push(format!("{path}: {why}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*VALUES.write().unwrap_or_else(PoisonError::into_inner) = values;
|
||||||
|
*ERRORS.lock().unwrap_or_else(PoisonError::into_inner) = errors;
|
||||||
|
|
||||||
|
hypr::sync_soon();
|
||||||
|
}
|
||||||
|
|
||||||
|
// whether a value fits its setting: kind, range, a list's length. Hyprland's are checked by Hyprland
|
||||||
|
fn check(path: &str, value: &Value) -> Result<(), String> {
|
||||||
|
if path.starts_with("hyprland.") {
|
||||||
|
return if matches!(value, Value::Bool(_) | Value::Int(_) | Value::Float(_) | Value::Text(_)) { Ok(()) } else { Err(String::from("needs a value")) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let option = option(path).ok_or("there's no such setting")?;
|
||||||
|
|
||||||
|
let fits = match option.kind {
|
||||||
|
Kind::Bool => matches!(value, Value::Bool(_)),
|
||||||
|
Kind::Int => matches!(value, Value::Int(number) if *number >= 0),
|
||||||
|
Kind::Float => value.number().is_some(),
|
||||||
|
Kind::Text => value.text().is_some(),
|
||||||
|
Kind::Color => value.text().and_then(color).is_some(),
|
||||||
|
Kind::List => match (value, (option.default)()) {
|
||||||
|
(Value::List(items), Value::List(default)) => items.len() == default.len() && items.iter().all(|item| item.number().is_some()),
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if !fits {
|
||||||
|
return Err(format!("needs {}", kind_words(option)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !option.choices.is_empty() && !value.text().is_some_and(|word| option.choices.contains(&word)) {
|
||||||
|
return Err(format!("must be one of {}", option.choices.join(", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let numbers: Vec<f64> = match value {
|
||||||
|
Value::List(items) => items.iter().filter_map(Value::number).collect(),
|
||||||
|
_ => value.number().into_iter().collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((min, max)) = option.range
|
||||||
|
&& numbers.iter().any(|number| *number < min || *number > max)
|
||||||
|
{
|
||||||
|
return Err(format!("must be from {min} to {max}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_words(option: &Opt) -> String {
|
||||||
|
match option.kind {
|
||||||
|
Kind::Bool => String::from("true or false"),
|
||||||
|
Kind::Int => String::from("a whole number"),
|
||||||
|
Kind::Float => String::from("a number"),
|
||||||
|
Kind::Text => String::from("text in quotes"),
|
||||||
|
Kind::Color => String::from("a colour like \"#f7a8c8\" or \"#ffffff12\""),
|
||||||
|
Kind::List => match (option.default)() {
|
||||||
|
Value::List(items) => format!("a list of {} numbers", items.len()),
|
||||||
|
_ => String::from("a list"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn errors() -> Vec<String> {
|
||||||
|
ERRORS.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// a setting's value, its default where config.toml doesn't set it
|
||||||
|
pub fn current(option: &Opt) -> Value {
|
||||||
|
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(option.path).cloned().unwrap_or_else(option.default)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_default(option: &Opt) -> bool {
|
||||||
|
current(option) == (option.default)()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the Hyprland options set here, as (hyprland.decoration.blur.size, value), sorted
|
||||||
|
pub fn overrides() -> Vec<(String, Value)> {
|
||||||
|
let mut found: Vec<(String, Value)> = VALUES
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.iter()
|
||||||
|
.filter(|(path, _)| path.starts_with("hyprland."))
|
||||||
|
.map(|(path, value)| (path.clone(), value.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
found.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn override_of(path: &str) -> Option<Value> {
|
||||||
|
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sets one setting and saves the file; the shell redraws with it at once
|
||||||
|
pub fn set(path: &str, value: Value) -> Result<(), String> {
|
||||||
|
check(path, &value)?;
|
||||||
|
|
||||||
|
let mut written = written();
|
||||||
|
|
||||||
|
VALUES.write().unwrap_or_else(PoisonError::into_inner).insert(path.to_string(), value);
|
||||||
|
|
||||||
|
save(&mut written)
|
||||||
|
}
|
||||||
|
|
||||||
|
// back to the default (a Hyprland option: back to Hyprland's own config)
|
||||||
|
pub fn reset(path: &str) -> Result<(), String> {
|
||||||
|
let mut written = written();
|
||||||
|
|
||||||
|
VALUES.write().unwrap_or_else(PoisonError::into_inner).remove(path);
|
||||||
|
|
||||||
|
save(&mut written)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(written: &mut String) -> Result<(), String> {
|
||||||
|
let text = render();
|
||||||
|
|
||||||
|
write_file(&text).map_err(|error| format!("couldn't write {}: {error}", file().display()))?;
|
||||||
|
|
||||||
|
*written = text;
|
||||||
|
|
||||||
|
theme::touch();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// whole or not at all: a new file renamed over the old, so no reader sees half of it
|
||||||
|
fn write_file(text: &str) -> std::io::Result<()> {
|
||||||
|
let fresh = file().with_extension("toml.new");
|
||||||
|
|
||||||
|
fs::write(&fresh, text)?;
|
||||||
|
fs::rename(&fresh, file())
|
||||||
|
}
|
||||||
|
|
||||||
|
// config.toml as the shell writes it: every setting with its help, then the Hyprland options set here
|
||||||
|
pub fn render() -> String {
|
||||||
|
let mut out = String::from(
|
||||||
|
"# Magic Island settings. The config window (amane ipc call config toggle, or Settings in the\n\
|
||||||
|
# control centre) writes this file; hand edits apply as soon as it's saved. A setting left out\n\
|
||||||
|
# takes its default. Saving from the window rewrites the file, comments included.\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut table = "";
|
||||||
|
|
||||||
|
for option in OPTIONS {
|
||||||
|
let at = &option.path[..option.path.len() - option.name().len() - 1];
|
||||||
|
|
||||||
|
if at != table {
|
||||||
|
out.push_str(&format!("\n[{at}]\n"));
|
||||||
|
|
||||||
|
table = at;
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in wrapped(option.help, 96) {
|
||||||
|
out.push_str(&format!("# {line}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&format!("{} = {}\n", option.name(), parse::toml_text(¤t(option))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let overrides = overrides();
|
||||||
|
|
||||||
|
if !overrides.is_empty() {
|
||||||
|
out.push_str(
|
||||||
|
"\n# Hyprland options changed in the config window. The shell writes them to ~/.config/hypr/amane.lua;\n\
|
||||||
|
# load that last in hyprland.lua with require(\"amane\") so they win over the rest of your config.\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut table = String::new();
|
||||||
|
|
||||||
|
for (path, value) in overrides {
|
||||||
|
let (at, name) = path.rsplit_once('.').unwrap_or(("hyprland", &path));
|
||||||
|
|
||||||
|
if at != table {
|
||||||
|
out.push_str(&format!("\n[{at}]\n"));
|
||||||
|
|
||||||
|
table = at.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&format!("{name} = {}\n", parse::toml_text(&value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// help text as comment lines of at most `width` letters
|
||||||
|
fn wrapped(text: &str, width: usize) -> Vec<String> {
|
||||||
|
let mut lines: Vec<String> = vec![String::new()];
|
||||||
|
|
||||||
|
for word in text.split_whitespace() {
|
||||||
|
let line = lines.last_mut().expect("starts with one line");
|
||||||
|
|
||||||
|
if !line.is_empty() && line.len() + 1 + word.len() > width {
|
||||||
|
lines.push(word.to_string());
|
||||||
|
} else {
|
||||||
|
if !line.is_empty() {
|
||||||
|
line.push(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
line.push_str(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// a value as the config window shows it
|
||||||
|
pub fn shown(value: &Value) -> String {
|
||||||
|
match value {
|
||||||
|
Value::Bool(on) => on.to_string(),
|
||||||
|
Value::Int(number) => number.to_string(),
|
||||||
|
Value::Float(number) => parse::number_text(*number),
|
||||||
|
Value::Text(text) => text.clone(),
|
||||||
|
Value::List(items) => items.iter().map(shown).collect::<Vec<_>>().join(" "),
|
||||||
|
Value::Null | Value::Table(_) => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// what was typed for a setting, as its value; checked when it's set
|
||||||
|
pub fn from_text(option: &Opt, text: &str) -> Result<Value, String> {
|
||||||
|
let text = text.trim();
|
||||||
|
let wrong = || format!("{} needs {}", option.name(), kind_words(option));
|
||||||
|
|
||||||
|
match option.kind {
|
||||||
|
Kind::Bool => match text {
|
||||||
|
"true" | "yes" | "on" | "1" => Ok(Value::Bool(true)),
|
||||||
|
"false" | "no" | "off" | "0" => Ok(Value::Bool(false)),
|
||||||
|
_ => Err(wrong()),
|
||||||
|
},
|
||||||
|
Kind::Int => text.parse::<i64>().map(Value::Int).map_err(|_| wrong()),
|
||||||
|
Kind::Float => text.parse::<f64>().map(Value::Float).map_err(|_| wrong()),
|
||||||
|
Kind::Text => Ok(Value::Text(text.to_string())),
|
||||||
|
Kind::Color => color(text).map(|color| Value::Text(color.hex())).ok_or_else(wrong),
|
||||||
|
Kind::List => {
|
||||||
|
let whole = matches!((option.default)(), Value::List(items) if items.first().is_some_and(|item| matches!(item, Value::Int(_))));
|
||||||
|
|
||||||
|
text.split(|letter: char| letter.is_whitespace() || letter == ',')
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.map(|word| if whole { word.parse().map(Value::Int).ok() } else { word.parse().map(Value::Float).ok() })
|
||||||
|
.collect::<Option<Vec<_>>>()
|
||||||
|
.map(Value::List)
|
||||||
|
.ok_or_else(wrong)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the shell's pages in the window, in the order they're declared
|
||||||
|
pub fn modules() -> Vec<&'static str> {
|
||||||
|
let mut found: Vec<&'static str> = Vec::new();
|
||||||
|
|
||||||
|
for option in OPTIONS {
|
||||||
|
if !found.contains(&option.module()) {
|
||||||
|
found.push(option.module());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
// a page's or group's name as the window shows it
|
||||||
|
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"),
|
||||||
|
"xwayland" => String::from("XWayland"),
|
||||||
|
_ => {
|
||||||
|
let words = id.replace(['_', '-'], " ");
|
||||||
|
let mut letters = words.chars();
|
||||||
|
|
||||||
|
letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a setting's name as a label: "peek_ms" reads "Peek (ms)"
|
||||||
|
pub fn label(name: &str) -> String {
|
||||||
|
for (unit, words) in [("_ms", " (ms)"), ("_s", " (s)")] {
|
||||||
|
if let Some(stem) = name.strip_suffix(unit) {
|
||||||
|
return format!("{}{words}", title(stem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title(name)
|
||||||
|
}
|
||||||
+2096
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
+460
@@ -0,0 +1,460 @@
|
|||||||
|
/*
|
||||||
|
* when the bar shows. on a monitor whose workspace shows a fullscreen window, the island,
|
||||||
|
* the workspaces and the control centre's pill stay out of sight until the pointer rests
|
||||||
|
* on that monitor's top edge for hiding::dwell_ms(); everywhere else they're always there. some
|
||||||
|
* events bring one part in for a moment even then (a peek): the workspaces when the
|
||||||
|
* workspace changes, the island when the song changes or a player appears; never the
|
||||||
|
* control centre. a workspace change also slides the workspaces' highlight across, and a
|
||||||
|
* new song makes the island pop, shown or peeking.
|
||||||
|
*
|
||||||
|
* hyprland says what changed on its event socket and answers questions on its other one,
|
||||||
|
* so nothing here polls hyprland; the media watch only reads the Media service's state.
|
||||||
|
* the edge strip is one pixel along the top, an Overlay layer like the bar, there only
|
||||||
|
* on fullscreen monitors (where it takes the window's top pixel row)
|
||||||
|
*/
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use std::{env, thread};
|
||||||
|
|
||||||
|
use amane::{Animation, Full, Layer, LayerWindow, Media, Monitor, Parent, Rectangle, Service, Vertical, Zone};
|
||||||
|
|
||||||
|
use crate::config::bar::{hiding, peeks};
|
||||||
|
use crate::config::island::pill;
|
||||||
|
use crate::parse;
|
||||||
|
|
||||||
|
// how far a pill slides up to be out of sight, glow and all
|
||||||
|
pub fn hide_h() -> f32 {
|
||||||
|
pill::top_gap() + pill::height() + pill::shadow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the parts an event may bring in alone
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Part {
|
||||||
|
Island,
|
||||||
|
Workspaces,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Edge {
|
||||||
|
// the workspace each monitor shows, the focused monitor, and the monitors showing a fullscreen window
|
||||||
|
showing: HashMap<String, i64>,
|
||||||
|
focused: Option<String>,
|
||||||
|
fullscreen: HashSet<String>,
|
||||||
|
|
||||||
|
// how far the whole bar is in on each monitor, 0 to 1, and where the pointer brought it back
|
||||||
|
shown: HashMap<String, Animation>,
|
||||||
|
revealed: Option<String>,
|
||||||
|
|
||||||
|
// a part brought in by an event: its timeline and when that began
|
||||||
|
peeks: HashMap<(Part, String), (Animation, Instant)>,
|
||||||
|
|
||||||
|
// when the island last popped, and each monitor's workspace before its last change
|
||||||
|
pops: HashMap<(Part, String), Instant>,
|
||||||
|
switched: HashMap<String, (i64, Instant)>,
|
||||||
|
|
||||||
|
// workspaces hyprland made a moment ago (by switching to a new one), by id
|
||||||
|
created: HashMap<i64, Instant>,
|
||||||
|
|
||||||
|
// the monitor whose top edge the pointer rests on, and whether it's over a part of the bar
|
||||||
|
at_edge: Option<String>,
|
||||||
|
over_bar: bool,
|
||||||
|
|
||||||
|
// bumped by every arrival and departure, so a timer an earlier one started does nothing
|
||||||
|
moves: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Edge {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyprland's events, for as long as the shell runs; a lost socket is tried again
|
||||||
|
fn listen() {
|
||||||
|
thread::spawn(watch_media);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Some(events) = socket(".socket2.sock").and_then(|path| UnixStream::connect(path).ok()) {
|
||||||
|
refresh(false);
|
||||||
|
|
||||||
|
for line in BufReader::new(events).lines() {
|
||||||
|
let Ok(line) = line else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let event = line.split(">>").next().unwrap_or("");
|
||||||
|
|
||||||
|
// createworkspacev2>>ID,NAME
|
||||||
|
if event == "createworkspacev2"
|
||||||
|
&& let Some(id) = line.split(">>").nth(1).and_then(|data| data.split(',').next()).and_then(|id| id.parse::<i64>().ok())
|
||||||
|
{
|
||||||
|
let mut edge = Edge::write();
|
||||||
|
|
||||||
|
edge.created.retain(|_, at| at.elapsed().as_secs_f32() < peeks::switch_s());
|
||||||
|
edge.created.insert(id, Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
event.trim_end_matches("v2"),
|
||||||
|
"fullscreen" | "workspace" | "focusedmon" | "openwindow" | "closewindow" | "movewindow" | "moveworkspace" | "activespecial" | "monitoradded" | "monitorremoved"
|
||||||
|
) {
|
||||||
|
refresh(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Edge {
|
||||||
|
// how far the whole bar is in on this monitor: all the way unless it shows a fullscreen window
|
||||||
|
pub fn shown(&self, monitor: &str) -> f32 {
|
||||||
|
self.shown.get(monitor).map_or(1.0, Animation::value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the same for a part an event may bring in alone
|
||||||
|
pub fn part(&self, part: Part, monitor: &str) -> f32 {
|
||||||
|
let peek = self.peeks.get(&(part, monitor.to_string())).map_or(0.0, |(peek, _)| peek.value());
|
||||||
|
|
||||||
|
self.shown(monitor).max(peek)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 to 1 and back over peeks::pop_s() after a new song; the view asks for frames while it's above 0
|
||||||
|
pub fn pop(&self, part: Part, monitor: &str) -> f32 {
|
||||||
|
let Some(at) = self.pops.get(&(part, monitor.to_string())) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let t = at.elapsed().as_secs_f32() / peeks::pop_s().max(0.01);
|
||||||
|
|
||||||
|
if t < 1.0 { (t * PI).sin() * (1.0 - t * 0.5) } else { 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// a workspace change on this monitor still sliding: the workspace it came from, and how far along, 0 to 1
|
||||||
|
pub fn switched(&self, monitor: &str) -> Option<(i64, f32)> {
|
||||||
|
let (from, at) = self.switched.get(monitor)?;
|
||||||
|
let t = at.elapsed().as_secs_f32() / peeks::switch_s().max(0.01);
|
||||||
|
|
||||||
|
(t < 1.0).then_some((*from, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// how far a workspace hyprland just made has grown into the row, 0 to 1 over the switch; 1 for the rest
|
||||||
|
pub fn grown(&self, id: i64) -> f32 {
|
||||||
|
self.created.get(&id).map_or(1.0, |at| (at.elapsed().as_secs_f32() / peeks::switch_s().max(0.01)).min(1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fullscreen_on(&self, monitor: &str) -> bool {
|
||||||
|
self.fullscreen.contains(monitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyprland's sockets for this session
|
||||||
|
fn socket(name: &str) -> Option<PathBuf> {
|
||||||
|
let runtime = env::var("XDG_RUNTIME_DIR").ok()?;
|
||||||
|
let signature = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
||||||
|
|
||||||
|
Some(PathBuf::from(runtime).join("hypr").join(signature).join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// a question to hyprland, as `hyprctl <request>` would ask it; empty if it can't be asked
|
||||||
|
fn hyprctl(request: &str) -> String {
|
||||||
|
let Some(mut stream) = socket(".socket.sock").and_then(|path| UnixStream::connect(path).ok()) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut answer = String::new();
|
||||||
|
|
||||||
|
if stream.write_all(request.as_bytes()).is_ok() {
|
||||||
|
let _ = stream.read_to_string(&mut answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
answer
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* where every monitor is now: its workspace and whether that shows a fullscreen window.
|
||||||
|
* `announce`: a workspace that changed slides the highlight over and, on a hidden bar,
|
||||||
|
* brings the workspaces in for a moment
|
||||||
|
*/
|
||||||
|
fn refresh(announce: bool) {
|
||||||
|
let monitors = parse::hypr_monitors(&hyprctl("monitors"));
|
||||||
|
|
||||||
|
if monitors.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fullscreen_workspaces = parse::hypr_fullscreen_workspaces(&hyprctl("clients"));
|
||||||
|
let fullscreen: HashSet<String> = monitors.iter().filter(|(_, workspace, _)| fullscreen_workspaces.contains(workspace)).map(|(name, _, _)| name.clone()).collect();
|
||||||
|
let focused = monitors.iter().find(|(_, _, focused)| *focused).map(|(name, _, _)| name.clone());
|
||||||
|
|
||||||
|
let same = {
|
||||||
|
let edge = Edge::read();
|
||||||
|
|
||||||
|
edge.fullscreen == fullscreen && edge.focused == focused && monitors.iter().all(|(name, workspace, _)| edge.showing.get(name) == Some(workspace))
|
||||||
|
};
|
||||||
|
|
||||||
|
if same {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut edge = Edge::write();
|
||||||
|
|
||||||
|
for (name, workspace, _) in &monitors {
|
||||||
|
let before = edge.showing.insert(name.clone(), *workspace);
|
||||||
|
|
||||||
|
if announce
|
||||||
|
&& let Some(before) = before
|
||||||
|
&& before != *workspace
|
||||||
|
{
|
||||||
|
edge.switched.insert(name.clone(), (before, Instant::now()));
|
||||||
|
|
||||||
|
if fullscreen.contains(name) {
|
||||||
|
peek(&mut edge, Part::Workspaces, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
edge.showing.retain(|name, _| monitors.iter().any(|(known, _, _)| known == name));
|
||||||
|
edge.focused = focused;
|
||||||
|
edge.fullscreen = fullscreen;
|
||||||
|
|
||||||
|
// the strip goes with the fullscreen window: the pointer can't be on it any more
|
||||||
|
if edge.at_edge.as_ref().is_some_and(|monitor| !edge.fullscreen.contains(monitor)) {
|
||||||
|
edge.at_edge = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
settle(&mut edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// every monitor's bar towards where it belongs: in unless fullscreen, or brought back by the pointer
|
||||||
|
fn settle(edge: &mut Edge) {
|
||||||
|
let monitors: Vec<String> = edge.showing.keys().cloned().collect();
|
||||||
|
|
||||||
|
for monitor in monitors {
|
||||||
|
let wanted = !edge.fullscreen_on(&monitor) || edge.revealed.as_deref() == Some(monitor.as_str());
|
||||||
|
|
||||||
|
edge.shown.entry(monitor).or_insert_with(|| sliding(1.0)).to(if wanted { 1.0 } else { 0.0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// one part in for a while; a newer peek of the same part keeps it longer
|
||||||
|
fn peek(edge: &mut Edge, part: Part, monitor: &str) {
|
||||||
|
let key = (part, monitor.to_string());
|
||||||
|
let began = Instant::now();
|
||||||
|
|
||||||
|
// from wherever the part is now: one already showing (the bar on its way out after a switch to a fullscreen workspace) stays put instead of dipping
|
||||||
|
let now = edge.part(part, monitor);
|
||||||
|
|
||||||
|
let entry = edge.peeks.entry(key.clone()).or_insert_with(|| (sliding(now), began));
|
||||||
|
|
||||||
|
if entry.0.value() < now {
|
||||||
|
entry.0 = sliding(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.0.to(1.0);
|
||||||
|
entry.1 = began;
|
||||||
|
|
||||||
|
let hold = match part {
|
||||||
|
Part::Island => peeks::island_ms(),
|
||||||
|
Part::Workspaces => peeks::workspaces_ms(),
|
||||||
|
};
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_millis(hold));
|
||||||
|
|
||||||
|
let mut edge = Edge::write();
|
||||||
|
|
||||||
|
// under the pointer it stays, until the pointer leaves (hide_soon)
|
||||||
|
if edge.over_bar {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((peeking, at)) = edge.peeks.get_mut(&key)
|
||||||
|
&& *at == began
|
||||||
|
{
|
||||||
|
peeking.to(0.0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* a new song or a new player: the island pops on the focused monitor, and comes in for
|
||||||
|
* a moment there if the bar is hidden. the watch reads Media's state a few times a
|
||||||
|
* second; that's memory, not a player
|
||||||
|
*/
|
||||||
|
fn watch_media() {
|
||||||
|
let mut last: Option<(Vec<String>, String)> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
thread::sleep(Duration::from_millis(peeks::media_check_ms()));
|
||||||
|
|
||||||
|
let (players, song) = {
|
||||||
|
let media = Media::read();
|
||||||
|
|
||||||
|
let players: Vec<String> = media.players().iter().map(|player| player.name().to_string()).collect();
|
||||||
|
let song = media.active().filter(|player| !player.title().is_empty()).map(|player| format!("{}\u{1f}{}\u{1f}{}", player.name(), player.title(), player.artist()));
|
||||||
|
|
||||||
|
(players, song)
|
||||||
|
};
|
||||||
|
|
||||||
|
// a player between songs has no title for a moment: wait for the next one
|
||||||
|
let Some(song) = song.or_else(|| last.as_ref().map(|(_, song)| song.clone())) else {
|
||||||
|
last = Some((players, String::new()));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((known, before)) = &last {
|
||||||
|
let new_player = players.iter().find(|name| !known.contains(name));
|
||||||
|
let new_song = !before.is_empty() && song != *before;
|
||||||
|
|
||||||
|
// the one that changed: a new player, or the one whose song this is
|
||||||
|
let changed = new_player.cloned().or_else(|| new_song.then(|| song.split('\u{1f}').next().unwrap_or("").to_string()));
|
||||||
|
|
||||||
|
if let Some(player) = changed
|
||||||
|
&& !in_front(&player)
|
||||||
|
{
|
||||||
|
announce_media();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
last = Some((players, song));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// whether the focused window is this player's app: then its song change is in plain sight
|
||||||
|
fn in_front(player: &str) -> bool {
|
||||||
|
let window = parse::json(&hyprctl("j/activewindow"));
|
||||||
|
let class = window.first().and_then(|window| window.get("class")).and_then(parse::Value::text).unwrap_or("");
|
||||||
|
|
||||||
|
parse::same_app(class, player)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn announce_media() {
|
||||||
|
let mut edge = Edge::write();
|
||||||
|
|
||||||
|
let Some(monitor) = edge.focused.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
edge.pops.insert((Part::Island, monitor.clone()), Instant::now());
|
||||||
|
|
||||||
|
if edge.fullscreen_on(&monitor) && edge.revealed.as_deref() != Some(monitor.as_str()) {
|
||||||
|
peek(&mut edge, Part::Island, &monitor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the bar's parts call this from their on_hover
|
||||||
|
pub fn over_bar(inside: bool) {
|
||||||
|
let moves = {
|
||||||
|
let mut edge = Edge::write();
|
||||||
|
|
||||||
|
edge.over_bar = inside;
|
||||||
|
edge.moves += 1;
|
||||||
|
edge.moves
|
||||||
|
};
|
||||||
|
|
||||||
|
if !inside {
|
||||||
|
hide_soon(moves);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at_edge(monitor: String) -> impl Fn(bool) + 'static {
|
||||||
|
move |inside| {
|
||||||
|
let moves = {
|
||||||
|
let mut edge = Edge::write();
|
||||||
|
|
||||||
|
edge.at_edge = inside.then(|| monitor.clone());
|
||||||
|
edge.moves += 1;
|
||||||
|
edge.moves
|
||||||
|
};
|
||||||
|
|
||||||
|
if !inside {
|
||||||
|
hide_soon(moves);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let monitor = monitor.clone();
|
||||||
|
|
||||||
|
// still resting there after the dwell: this monitor's bar comes back
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_millis(hiding::dwell_ms()));
|
||||||
|
|
||||||
|
let mut edge = Edge::write();
|
||||||
|
|
||||||
|
if edge.moves == moves {
|
||||||
|
edge.revealed = Some(monitor);
|
||||||
|
|
||||||
|
settle(&mut edge);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gone again after hiding::hide_ms() off the bar and the edge, unless the pointer came back meanwhile; peeks with it
|
||||||
|
fn hide_soon(moves: u32) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_millis(hiding::hide_ms()));
|
||||||
|
|
||||||
|
let mut edge = Edge::write();
|
||||||
|
|
||||||
|
if edge.moves == moves && edge.at_edge.is_none() && !edge.over_bar {
|
||||||
|
edge.revealed = None;
|
||||||
|
|
||||||
|
for (peeking, _) in edge.peeks.values_mut() {
|
||||||
|
peeking.to(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
settle(&mut edge);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sliding(at: f32) -> Animation {
|
||||||
|
Animation::new(at).duration(Duration::from_millis(hiding::slide_ms()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* how far a part of the bar slides up out of the monitor: all of `height` at 0, none
|
||||||
|
* at 1. `open` is the part's own card or panel, which keeps it in while it's open
|
||||||
|
*/
|
||||||
|
pub fn hidden_by(shown: f32, open: f32, height: f32) -> f32 {
|
||||||
|
-height * (1.0 - shown.max(open))
|
||||||
|
}
|
||||||
|
|
||||||
|
// the strip along the top of a fullscreen monitor; registered before the bar, so the bar lies over it
|
||||||
|
pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||||
|
let window = LayerWindow::new().width(Full).height(1.0).anchor_vertical(Vertical::Top).layer(Layer::Overlay).space(Zone::Ignore).namespace("magic-edge");
|
||||||
|
|
||||||
|
// never hidden (see the island's view): empty and click-through where nothing is fullscreen
|
||||||
|
if !Edge::read().fullscreen_on(&monitor.name) {
|
||||||
|
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.child(Rectangle::new().width(Parent).height(Parent).on_hover(at_edge(monitor.name.clone())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* keeps windows out from under the bar: an empty strip as tall as the pills reach,
|
||||||
|
* reserving its height on every monitor. Hyprland lets tiled and maximised windows
|
||||||
|
* respect it and fullscreen ones cover it, which is just when the bar stays and when
|
||||||
|
* it hides; it goes with the shell. the bar's own windows ignore what's reserved, so
|
||||||
|
* they stay at the top edge. reads nothing, so it never redraws
|
||||||
|
*/
|
||||||
|
pub fn spacer(_: &Monitor) -> LayerWindow {
|
||||||
|
LayerWindow::new()
|
||||||
|
.width(Full)
|
||||||
|
.height(pill::top_gap() + pill::height())
|
||||||
|
.anchor_vertical(Vertical::Top)
|
||||||
|
.layer(Layer::Background)
|
||||||
|
.space(Zone::Reserve)
|
||||||
|
.namespace("magic-reserve")
|
||||||
|
.click_through()
|
||||||
|
.child(Rectangle::new().width(0.0).height(0.0))
|
||||||
|
}
|
||||||
+424
@@ -0,0 +1,424 @@
|
|||||||
|
// icons drawn as shapes, not typed, so the shell looks the same whatever fonts are installed
|
||||||
|
|
||||||
|
use amane::{Arc, Canvas, Cap, Circle, Color, Line, Path, Shape, shapes};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub enum Glyph {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Previous,
|
||||||
|
Next,
|
||||||
|
Speaker,
|
||||||
|
Muted,
|
||||||
|
Bluetooth,
|
||||||
|
Chevron,
|
||||||
|
ChevronUp,
|
||||||
|
// pointing left, for a menu's way back
|
||||||
|
Back,
|
||||||
|
Note,
|
||||||
|
|
||||||
|
// wi-fi with 0 to 3 of its arcs lit; the unlit ones are drawn faint
|
||||||
|
Wifi(u8),
|
||||||
|
Hotspot,
|
||||||
|
Lock,
|
||||||
|
Sun,
|
||||||
|
Keyboard,
|
||||||
|
Leaf,
|
||||||
|
Gauge,
|
||||||
|
Bolt,
|
||||||
|
Chip,
|
||||||
|
// this machine, in the middle of the Wi-Fi and Bluetooth maps
|
||||||
|
Laptop,
|
||||||
|
// a network socket, for a wired link
|
||||||
|
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 {
|
||||||
|
bold_icon(glyph, size, color, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// play, pause, previous and next thickened by an outline `weight` px wide in their own colour; 0 draws none
|
||||||
|
pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
|
||||||
|
Canvas::new().width(size).height(size).shapes(glyph_shapes(glyph, size, color, weight, (0.0, 0.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the glyph's shapes with its top left at `origin`, for drawing many icons into one
|
||||||
|
* Canvas: every Canvas widget costs the gpu on its own (7 small icon canvases took a
|
||||||
|
* map from 1.8 to 6.4 ms a frame), shapes inside one canvas cost next to nothing
|
||||||
|
*/
|
||||||
|
pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin: (f32, f32)) -> Vec<Box<dyn Shape>> {
|
||||||
|
let s = size;
|
||||||
|
let (ox, oy) = origin;
|
||||||
|
|
||||||
|
// a fraction of the glyph's box, as a point on the canvas
|
||||||
|
let x = move |k: f32| ox + k * s;
|
||||||
|
let y = move |k: f32| oy + k * s;
|
||||||
|
|
||||||
|
let triangle = |x0: f32, x1: f32| Path::new().move_to(x(x0), y(0.14)).line_to(x(x1), y(0.5)).line_to(x(x0), y(0.86)).close().fill(color).stroke(weight, color);
|
||||||
|
|
||||||
|
let bar = |x0: f32, x1: f32| {
|
||||||
|
Path::new()
|
||||||
|
.move_to(x(x0), y(0.14))
|
||||||
|
.line_to(x(x1), y(0.14))
|
||||||
|
.line_to(x(x1), y(0.86))
|
||||||
|
.line_to(x(x0), y(0.86))
|
||||||
|
.close()
|
||||||
|
.fill(color)
|
||||||
|
.stroke(weight, color)
|
||||||
|
};
|
||||||
|
|
||||||
|
let speaker = || {
|
||||||
|
Path::new()
|
||||||
|
.move_to(x(0.08), y(0.36))
|
||||||
|
.line_to(x(0.28), y(0.36))
|
||||||
|
.line_to(x(0.54), y(0.14))
|
||||||
|
.line_to(x(0.54), y(0.86))
|
||||||
|
.line_to(x(0.28), y(0.64))
|
||||||
|
.line_to(x(0.08), y(0.64))
|
||||||
|
.close()
|
||||||
|
.fill(color)
|
||||||
|
};
|
||||||
|
|
||||||
|
let thin = (0.12 * s).max(1.2);
|
||||||
|
|
||||||
|
let faint = Color::rgba(color.red(), color.green(), color.blue(), color.alpha() / 3);
|
||||||
|
|
||||||
|
let line = |x0: f32, y0: f32, x1: f32, y1: f32| Line::new().from(x(x0), y(y0)).to(x(x1), y(y1)).stroke(thin, color).cap(Cap::Round);
|
||||||
|
|
||||||
|
// a closed outline through the box's corners at these fractions
|
||||||
|
let frame = |x0: f32, y0: f32, x1: f32, y1: f32| Path::new().move_to(x(x0), y(y0)).line_to(x(x1), y(y0)).line_to(x(x1), y(y1)).line_to(x(x0), y(y1)).close();
|
||||||
|
|
||||||
|
match glyph {
|
||||||
|
Glyph::Play => shapes![triangle(0.2, 0.9)],
|
||||||
|
Glyph::Pause => shapes![bar(0.18, 0.42), bar(0.58, 0.82)],
|
||||||
|
Glyph::Next => shapes![triangle(0.08, 0.7), bar(0.74, 0.9)],
|
||||||
|
Glyph::Previous => shapes![triangle(0.92, 0.3), bar(0.1, 0.26)],
|
||||||
|
Glyph::Speaker => shapes![speaker(), Arc::new().center(x(0.56), y(0.5)).radius(0.3 * s).start(45.0).sweep(90.0).stroke(thin, color).cap(Cap::Round)],
|
||||||
|
Glyph::Muted => shapes![speaker(), line(0.68, 0.36, 0.94, 0.64), line(0.94, 0.36, 0.68, 0.64)],
|
||||||
|
Glyph::Bluetooth => shapes![
|
||||||
|
Path::new()
|
||||||
|
.move_to(x(0.28), y(0.3))
|
||||||
|
.line_to(x(0.72), y(0.68))
|
||||||
|
.line_to(x(0.5), y(0.9))
|
||||||
|
.line_to(x(0.5), y(0.1))
|
||||||
|
.line_to(x(0.72), y(0.32))
|
||||||
|
.line_to(x(0.28), y(0.7))
|
||||||
|
.stroke(thin, color)
|
||||||
|
.cap(Cap::Round)
|
||||||
|
],
|
||||||
|
Glyph::Chevron => shapes![Path::new().move_to(x(0.12), y(0.32)).line_to(x(0.5), y(0.7)).line_to(x(0.88), y(0.32)).stroke(1.5, color).cap(Cap::Round)],
|
||||||
|
Glyph::ChevronUp => shapes![Path::new().move_to(x(0.12), y(0.68)).line_to(x(0.5), y(0.3)).line_to(x(0.88), y(0.68)).stroke(1.5, color).cap(Cap::Round)],
|
||||||
|
Glyph::Back => shapes![Path::new().move_to(x(0.64), y(0.14)).line_to(x(0.3), y(0.5)).line_to(x(0.64), y(0.86)).stroke(thin.max(1.6), color).cap(Cap::Round)],
|
||||||
|
// an eighth note: round head, stem, a curved flag
|
||||||
|
Glyph::Note => shapes![
|
||||||
|
Circle::new().center(x(0.32), y(0.74)).radius(0.17 * s).fill(color),
|
||||||
|
line(0.47, 0.74, 0.47, 0.12),
|
||||||
|
Path::new().move_to(x(0.47), y(0.12)).quad_to(x(0.62), y(0.3), x(0.84), y(0.38)).stroke(thin, color).cap(Cap::Round),
|
||||||
|
],
|
||||||
|
// a dot under three arcs fanning upwards
|
||||||
|
Glyph::Wifi(lit) => {
|
||||||
|
let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.8)).radius(0.08 * s).fill(color)];
|
||||||
|
|
||||||
|
for (arc, radius) in [0.26, 0.46, 0.66].into_iter().enumerate() {
|
||||||
|
let shade = if (arc as u8) < lit { color } else { faint };
|
||||||
|
|
||||||
|
parts.push(Box::new(Arc::new().center(x(0.5), y(0.84)).radius(radius * s).start(-45.0).sweep(90.0).stroke(thin, shade).cap(Cap::Round)));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
// a dot sending out waves to both sides
|
||||||
|
Glyph::Hotspot => {
|
||||||
|
let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.09 * s).fill(color)];
|
||||||
|
|
||||||
|
for radius in [0.24, 0.42] {
|
||||||
|
for start in [45.0, 225.0] {
|
||||||
|
parts.push(Box::new(Arc::new().center(x(0.5), y(0.5)).radius(radius * s).start(start).sweep(90.0).stroke(thin, color).cap(Cap::Round)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
Glyph::Lock => shapes![
|
||||||
|
Arc::new().center(x(0.5), y(0.42)).radius(0.19 * s).start(-90.0).sweep(180.0).stroke(thin, color),
|
||||||
|
line(0.31, 0.42, 0.31, 0.52),
|
||||||
|
line(0.69, 0.42, 0.69, 0.52),
|
||||||
|
frame(0.18, 0.5, 0.82, 0.9).fill(color),
|
||||||
|
],
|
||||||
|
Glyph::Sun => {
|
||||||
|
let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.17 * s).fill(color)];
|
||||||
|
|
||||||
|
for ray in 0..8 {
|
||||||
|
let (sin, cos) = (ray as f32 * std::f32::consts::FRAC_PI_4).sin_cos();
|
||||||
|
|
||||||
|
parts.push(Box::new(line(0.5 + 0.3 * sin, 0.5 - 0.3 * cos, 0.5 + 0.43 * sin, 0.5 - 0.43 * cos)));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
Glyph::Keyboard => {
|
||||||
|
let mut parts: Vec<Box<dyn Shape>> = shapes![frame(0.06, 0.24, 0.94, 0.78).stroke(thin, color), line(0.32, 0.64, 0.68, 0.64)];
|
||||||
|
|
||||||
|
for (row, key_y) in [0.38, 0.5].into_iter().enumerate() {
|
||||||
|
for key in 0..4 {
|
||||||
|
let key_x = 0.23 + key as f32 * 0.18 + row as f32 * 0.04;
|
||||||
|
|
||||||
|
parts.push(Box::new(Circle::new().center(x(key_x), y(key_y)).radius(0.045 * s).fill(color)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
Glyph::Leaf => shapes![
|
||||||
|
Path::new().move_to(x(0.18), y(0.84)).quad_to(x(0.14), y(0.2), x(0.84), y(0.16)).quad_to(x(0.84), y(0.82), x(0.18), y(0.84)).close().stroke(thin, color),
|
||||||
|
line(0.18, 0.84, 0.56, 0.46),
|
||||||
|
],
|
||||||
|
Glyph::Gauge => shapes![
|
||||||
|
Arc::new().center(x(0.5), y(0.68)).radius(0.38 * s).start(-90.0).sweep(180.0).stroke(thin, color).cap(Cap::Round),
|
||||||
|
line(0.5, 0.68, 0.68, 0.42),
|
||||||
|
Circle::new().center(x(0.5), y(0.68)).radius(0.07 * s).fill(color),
|
||||||
|
],
|
||||||
|
Glyph::Bolt => shapes![
|
||||||
|
Path::new()
|
||||||
|
.move_to(x(0.6), y(0.05))
|
||||||
|
.line_to(x(0.2), y(0.56))
|
||||||
|
.line_to(x(0.46), y(0.56))
|
||||||
|
.line_to(x(0.38), y(0.95))
|
||||||
|
.line_to(x(0.8), y(0.42))
|
||||||
|
.line_to(x(0.54), y(0.42))
|
||||||
|
.close()
|
||||||
|
.fill(color)
|
||||||
|
],
|
||||||
|
// a die with pins on every side
|
||||||
|
Glyph::Chip => {
|
||||||
|
let mut parts: Vec<Box<dyn Shape>> = shapes![frame(0.24, 0.24, 0.76, 0.76).stroke(thin, color), frame(0.4, 0.4, 0.6, 0.6).fill(color)];
|
||||||
|
|
||||||
|
for at in [0.4, 0.6] {
|
||||||
|
parts.push(Box::new(line(at, 0.08, at, 0.22)));
|
||||||
|
parts.push(Box::new(line(at, 0.78, at, 0.92)));
|
||||||
|
parts.push(Box::new(line(0.08, at, 0.22, at)));
|
||||||
|
parts.push(Box::new(line(0.78, at, 0.92, at)));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
// a screen on a base
|
||||||
|
Glyph::Laptop => shapes![frame(0.18, 0.2, 0.82, 0.66).stroke(thin, color), Path::new().move_to(x(0.06), y(0.78)).line_to(x(0.94), y(0.78)).stroke(thin * 1.4, color).cap(Cap::Round)],
|
||||||
|
// an rj45 socket seen from the front: the outline with its latch notch on top, three contacts inside
|
||||||
|
Glyph::Ethernet => shapes![
|
||||||
|
Path::new()
|
||||||
|
.move_to(x(0.12), y(0.86))
|
||||||
|
.line_to(x(0.12), y(0.3))
|
||||||
|
.line_to(x(0.3), y(0.3))
|
||||||
|
.line_to(x(0.3), y(0.14))
|
||||||
|
.line_to(x(0.7), y(0.14))
|
||||||
|
.line_to(x(0.7), y(0.3))
|
||||||
|
.line_to(x(0.88), y(0.3))
|
||||||
|
.line_to(x(0.88), y(0.86))
|
||||||
|
.close()
|
||||||
|
.stroke(thin, color),
|
||||||
|
line(0.36, 0.46, 0.36, 0.62),
|
||||||
|
line(0.5, 0.46, 0.5, 0.62),
|
||||||
|
line(0.64, 0.46, 0.64, 0.62),
|
||||||
|
],
|
||||||
|
// a ring with eight teeth around a hole
|
||||||
|
Glyph::Gear => {
|
||||||
|
let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.27 * s).stroke(thin * 1.6, color)];
|
||||||
|
|
||||||
|
for tooth in 0..8 {
|
||||||
|
let (sin, cos) = (tooth as f32 * std::f32::consts::FRAC_PI_4).sin_cos();
|
||||||
|
|
||||||
|
parts.push(Box::new(Line::new().from(x(0.5 + 0.3 * sin), y(0.5 - 0.3 * cos)).to(x(0.5 + 0.44 * sin), y(0.5 - 0.44 * cos)).stroke(thin * 1.6, color).cap(Cap::Round)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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
+410
@@ -0,0 +1,410 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||||
|
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::{fs, thread};
|
||||||
|
|
||||||
|
use amane::Service;
|
||||||
|
|
||||||
|
use crate::config;
|
||||||
|
use crate::parse::{self, HyprKind, Value};
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hyprland's own options in the config window. the list and its help come from Hyprland
|
||||||
|
* (`hyprctl descriptions`, `getoption`), so nothing here names an option. a change is
|
||||||
|
* kept in config.toml under [hyprland.*], applied at once with `hl.config` and written
|
||||||
|
* to ~/.config/hypr/amane.lua, which hyprland.lua loads last with require("amane"), so
|
||||||
|
* Hyprland's config keeps owning them
|
||||||
|
*/
|
||||||
|
|
||||||
|
pub struct HyprOption {
|
||||||
|
// "decoration:blur:size"; &'static because text inputs are named by it
|
||||||
|
pub name: &'static str,
|
||||||
|
pub help: String,
|
||||||
|
pub kind: HyprKind,
|
||||||
|
pub default: String,
|
||||||
|
pub range: Option<(f64, f64)>,
|
||||||
|
// named values for a number, like resize_corner's top_left = 1
|
||||||
|
pub choices: Vec<(String, i64)>,
|
||||||
|
pub current: String,
|
||||||
|
// Hyprland's own config sets it
|
||||||
|
pub in_config: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HyprOption {
|
||||||
|
// "decoration.blur.size", as hl.config names it
|
||||||
|
pub fn key(&self) -> String {
|
||||||
|
self.name.replace(':', ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> String {
|
||||||
|
format!("hyprland.{}", self.key())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn category(&self) -> &'static str {
|
||||||
|
self.name.split(':').next().unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// "blur" for decoration:blur:size; options right under a category have none
|
||||||
|
pub fn group(&self) -> &'static str {
|
||||||
|
let parts: Vec<&'static str> = self.name.split(':').collect();
|
||||||
|
|
||||||
|
if parts.len() > 2 { parts[1] } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
self.name.rsplit(':').next().unwrap_or(self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Hypr {
|
||||||
|
pub options: Vec<HyprOption>,
|
||||||
|
pub loaded: bool,
|
||||||
|
// the last change's result: (what happened, whether it went wrong)
|
||||||
|
pub message: Option<(String, bool)>,
|
||||||
|
// hyprland.lua loads amane.lua, so changes outlive a reload
|
||||||
|
pub required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Hypr {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listen() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Job {
|
||||||
|
Load,
|
||||||
|
// an option as Hyprland names it (decoration:blur:size) or as config.toml does (decoration.blur.size)
|
||||||
|
Set(String, String),
|
||||||
|
Reset(String),
|
||||||
|
Sync,
|
||||||
|
}
|
||||||
|
|
||||||
|
// one worker does every hyprctl call, in order; it starts with the first job
|
||||||
|
fn send(job: Job) {
|
||||||
|
static JOBS: OnceLock<Sender<Job>> = OnceLock::new();
|
||||||
|
|
||||||
|
let jobs = JOBS.get_or_init(|| {
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
|
||||||
|
thread::spawn(move || work(&receiver));
|
||||||
|
|
||||||
|
sender
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = jobs.send(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reads every option and its value, for the window
|
||||||
|
pub fn load() {
|
||||||
|
send(Job::Load);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(name: &str, text: &str) {
|
||||||
|
send(Job::Set(name.to_string(), text.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(name: &str) {
|
||||||
|
send(Job::Reset(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// config.toml was read: write amane.lua to match, once things settle
|
||||||
|
pub fn sync_soon() {
|
||||||
|
send(Job::Sync);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* a slider sends many values a second: only the newest per option is applied, and
|
||||||
|
* amane.lua is written once nothing has changed for half a second, since Hyprland may
|
||||||
|
* reload its whole config whenever a file it loaded changes
|
||||||
|
*/
|
||||||
|
fn work(jobs: &Receiver<Job>) {
|
||||||
|
let mut unwritten = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let first = match jobs.recv_timeout(Duration::from_millis(500)) {
|
||||||
|
Ok(job) => job,
|
||||||
|
Err(RecvTimeoutError::Timeout) => {
|
||||||
|
if unwritten {
|
||||||
|
write_lua();
|
||||||
|
|
||||||
|
unwritten = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(RecvTimeoutError::Disconnected) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut batch: Vec<Job> = vec![first];
|
||||||
|
|
||||||
|
batch.extend(jobs.try_iter());
|
||||||
|
|
||||||
|
for (index, job) in batch.iter().enumerate() {
|
||||||
|
let newer = |name: &str| batch[index + 1..].iter().any(|later| matches!(later, Job::Set(other, _) | Job::Reset(other) if other == name));
|
||||||
|
|
||||||
|
match job {
|
||||||
|
Job::Load => read_all(),
|
||||||
|
Job::Set(name, text) if !newer(name) => {
|
||||||
|
if let Some(name) = resolve(name) {
|
||||||
|
apply(name, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
unwritten = true;
|
||||||
|
}
|
||||||
|
Job::Reset(name) if !newer(name) => {
|
||||||
|
if let Some(name) = resolve(name) {
|
||||||
|
give_back(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
unwritten = false;
|
||||||
|
}
|
||||||
|
Job::Sync => unwritten = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland's name for an option given either way; reads the list first if it hasn't been
|
||||||
|
fn resolve(given: &str) -> Option<&'static str> {
|
||||||
|
if !Hypr::read().loaded {
|
||||||
|
read_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = given.replace(':', ".");
|
||||||
|
let found = Hypr::read().options.iter().find(|option| option.key() == key).map(|option| option.name);
|
||||||
|
|
||||||
|
if found.is_none() {
|
||||||
|
Hypr::write().message = Some((format!("Hyprland has no option {given}"), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hyprctl(arguments: &[&str]) -> String {
|
||||||
|
Command::new("hyprctl").args(arguments).output().map(|done| String::from_utf8_lossy(&done.stdout).into_owned()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// option names live as long as the shell: each is leaked once, however often the list is read
|
||||||
|
fn interned(name: &str) -> &'static str {
|
||||||
|
static NAMES: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
let mut names = NAMES.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
|
||||||
|
if let Some(known) = names.iter().find(|known| **known == name) {
|
||||||
|
return known;
|
||||||
|
}
|
||||||
|
|
||||||
|
let leaked: &'static str = Box::leak(name.to_string().into_boxed_str());
|
||||||
|
|
||||||
|
names.push(leaked);
|
||||||
|
|
||||||
|
leaked
|
||||||
|
}
|
||||||
|
|
||||||
|
// (kind, value as text, set by Hyprland's own config) of these options, in one hyprctl call
|
||||||
|
fn values_of(names: &[&str]) -> HashMap<String, (HyprKind, String, bool)> {
|
||||||
|
if names.is_empty() {
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let batch = names.iter().map(|name| format!("j/getoption {name}")).collect::<Vec<_>>().join("; ");
|
||||||
|
|
||||||
|
parse::json(&hyprctl(&["--batch", &batch])).iter().filter_map(parse::hypr_option).map(|(name, kind, text, set)| (name, (kind, text, set))).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_all() {
|
||||||
|
let described = parse::json(&hyprctl(&["descriptions", "-j"]));
|
||||||
|
|
||||||
|
let Some(Value::List(list)) = described.first() else {
|
||||||
|
Hypr::write().message = Some((String::from("Hyprland didn't answer `hyprctl descriptions`"), true));
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let names: Vec<&str> = list.iter().filter_map(|option| option.get("name")?.text()).collect();
|
||||||
|
let values = values_of(&names);
|
||||||
|
|
||||||
|
let options: Vec<HyprOption> = list
|
||||||
|
.iter()
|
||||||
|
.filter_map(|option| {
|
||||||
|
let name = option.get("name")?.text()?;
|
||||||
|
let (kind, current, in_config) = values.get(name)?.clone();
|
||||||
|
|
||||||
|
let number = |key: &str| option.get(key).and_then(Value::number);
|
||||||
|
|
||||||
|
let choices = match option.get("map") {
|
||||||
|
Some(Value::List(pairs)) => pairs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|pair| match pair {
|
||||||
|
Value::Table(entries) => entries.first().and_then(|(label, value)| Some((label.clone(), value.number()? as i64))),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(HyprOption {
|
||||||
|
name: interned(name),
|
||||||
|
help: option.get("description").and_then(Value::text).unwrap_or("").to_string(),
|
||||||
|
kind,
|
||||||
|
default: option.get("default").and_then(|value| parse::hypr_text(kind, value)).unwrap_or_default(),
|
||||||
|
range: number("min").zip(number("max")),
|
||||||
|
choices,
|
||||||
|
current,
|
||||||
|
in_config,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let required = fs::read_to_string(hypr_dir().join("hyprland.lua")).is_ok_and(|lua| ["require(\"amane\")", "require('amane')", "require \"amane\""].iter().any(|line| lua.contains(line)));
|
||||||
|
|
||||||
|
let mut hypr = Hypr::write();
|
||||||
|
|
||||||
|
hypr.options = options;
|
||||||
|
hypr.loaded = true;
|
||||||
|
hypr.required = required;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_of(name: &str) -> Option<HyprKind> {
|
||||||
|
if let Some(option) = Hypr::read().options.iter().find(|option| option.name == name) {
|
||||||
|
return Some(option.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
values_of(&[name]).get(name).map(|(kind, _, _)| *kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a change from the window: checked, kept in config.toml, applied at once
|
||||||
|
fn apply(name: &'static str, text: &str) {
|
||||||
|
let Some(kind) = kind_of(name) else {
|
||||||
|
Hypr::write().message = Some((format!("Hyprland has no option {name}"), true));
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let lua = match parse::hypr_lua(kind, text) {
|
||||||
|
Ok(lua) => lua,
|
||||||
|
Err(why) => {
|
||||||
|
Hypr::write().message = Some((format!("{name} {why}"), true));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = name.replace(':', ".");
|
||||||
|
|
||||||
|
let value = match kind {
|
||||||
|
HyprKind::Bool => Value::Bool(lua == "true"),
|
||||||
|
HyprKind::Int => lua.parse().map(Value::Int).unwrap_or(Value::Text(lua.clone())),
|
||||||
|
HyprKind::Float => lua.parse().map(Value::Float).unwrap_or(Value::Text(lua.clone())),
|
||||||
|
_ => Value::Text(text.trim().to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let answer = hyprctl(&["eval", &format!("hl.config({{ [{}] = {lua} }})", parse::quoted(&key))]);
|
||||||
|
|
||||||
|
let message = if answer.trim() == "ok" {
|
||||||
|
match config::set(&format!("hyprland.{key}"), value) {
|
||||||
|
Ok(()) => (format!("{key} = {}", text.trim()), false),
|
||||||
|
Err(why) => (why, true),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// "error: return hl.config(...):1: error setting 'x': why" says only the last part
|
||||||
|
(answer.rsplit(": ").next().unwrap_or(&answer).trim().to_string(), true)
|
||||||
|
};
|
||||||
|
|
||||||
|
refresh(name);
|
||||||
|
|
||||||
|
Hypr::write().message = Some(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// back to Hyprland's own value: out of config.toml and amane.lua, then Hyprland reloads its config
|
||||||
|
fn give_back(name: &'static str) {
|
||||||
|
let key = name.replace(':', ".");
|
||||||
|
|
||||||
|
let message = match config::reset(&format!("hyprland.{key}")) {
|
||||||
|
Ok(()) => {
|
||||||
|
write_lua();
|
||||||
|
|
||||||
|
hyprctl(&["reload", "config-only"]);
|
||||||
|
|
||||||
|
(format!("{key} is back to your Hyprland config's value"), false)
|
||||||
|
}
|
||||||
|
Err(why) => (why, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
read_all();
|
||||||
|
|
||||||
|
Hypr::write().message = Some(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(name: &str) {
|
||||||
|
if let Some((_, text, set)) = values_of(&[name]).remove(name) {
|
||||||
|
let mut hypr = Hypr::write();
|
||||||
|
|
||||||
|
if let Some(option) = hypr.options.iter_mut().find(|option| option.name == name) {
|
||||||
|
option.current = text;
|
||||||
|
option.in_config = set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hypr_dir() -> PathBuf {
|
||||||
|
theme::config_home().join("hypr")
|
||||||
|
}
|
||||||
|
|
||||||
|
// amane.lua from the [hyprland.*] tables; written only when it would change
|
||||||
|
fn write_lua() {
|
||||||
|
let overrides = config::overrides();
|
||||||
|
let names: Vec<String> = overrides.iter().map(|(path, _)| path.trim_start_matches("hyprland.").to_string()).collect();
|
||||||
|
|
||||||
|
// config.toml names options with dots only; Hyprland's names use colons between sections
|
||||||
|
let known: HashMap<String, HyprKind> = {
|
||||||
|
let hypr = Hypr::read();
|
||||||
|
|
||||||
|
if hypr.loaded { hypr.options.iter().map(|option| (option.key(), option.kind)).collect() } else { HashMap::new() }
|
||||||
|
};
|
||||||
|
|
||||||
|
let kinds: HashMap<String, HyprKind> = if names.iter().all(|key| known.contains_key(key)) {
|
||||||
|
known
|
||||||
|
} else {
|
||||||
|
let all = parse::json(&hyprctl(&["descriptions", "-j"]));
|
||||||
|
let every: Vec<&str> = match all.first() {
|
||||||
|
Some(Value::List(list)) => list.iter().filter_map(|option| option.get("name")?.text()).collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
let wanted: Vec<&str> = every.into_iter().filter(|name| names.contains(&name.replace(':', "."))).collect();
|
||||||
|
|
||||||
|
values_of(&wanted).into_iter().map(|(name, (kind, _, _))| (name.replace(':', "."), kind)).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
for ((_, value), key) in overrides.iter().zip(&names) {
|
||||||
|
let Some(kind) = kinds.get(key) else { continue };
|
||||||
|
|
||||||
|
if let Ok(lua) = parse::hypr_lua(*kind, &config::shown(value)) {
|
||||||
|
lines.push(format!(" [{}] = {lua},", parse::quoted(key)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lua = format!(
|
||||||
|
"-- Written by the amane shell from the [hyprland] tables in ~/.config/amane/config.toml (its\n\
|
||||||
|
-- config window, or hand edits); changes made here are overwritten. Load it last in\n\
|
||||||
|
-- hyprland.lua with require(\"amane\") so these win over the rest of the config.\n\
|
||||||
|
{}",
|
||||||
|
if lines.is_empty() { String::new() } else { format!("hl.config({{\n{}\n}})\n", lines.join("\n")) }
|
||||||
|
);
|
||||||
|
|
||||||
|
let path = hypr_dir().join("amane.lua");
|
||||||
|
|
||||||
|
if fs::read_to_string(&path).ok().as_deref() != Some(lua.as_str()) {
|
||||||
|
let _ = fs::write(path, lua);
|
||||||
|
}
|
||||||
|
}
|
||||||
+475
-431
File diff suppressed because it is too large
Load Diff
+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))
|
||||||
|
}
|
||||||
+48
-1
@@ -1,14 +1,61 @@
|
|||||||
|
mod ai;
|
||||||
mod art;
|
mod art;
|
||||||
|
mod config;
|
||||||
|
mod control;
|
||||||
|
mod edge;
|
||||||
|
mod glyph;
|
||||||
|
mod home;
|
||||||
|
mod hypr;
|
||||||
mod island;
|
mod island;
|
||||||
|
mod last;
|
||||||
|
mod map;
|
||||||
|
mod notices;
|
||||||
mod parse;
|
mod parse;
|
||||||
|
mod pods;
|
||||||
|
mod prompt;
|
||||||
|
mod settings;
|
||||||
mod sound;
|
mod sound;
|
||||||
mod spectrum;
|
mod spectrum;
|
||||||
|
mod stats;
|
||||||
|
mod surface;
|
||||||
|
mod system;
|
||||||
|
mod png;
|
||||||
|
mod theme;
|
||||||
|
mod tray;
|
||||||
|
mod workspaces;
|
||||||
|
|
||||||
use amane::{App, Palette, Service};
|
use amane::{App, Palette, Service};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// config.toml first: the font is chosen once, before anything draws
|
||||||
|
config::load();
|
||||||
|
|
||||||
// the card's glow follows the cover art, which art::Art copies to this one path
|
// the card's glow follows the cover art, which art::Art copies to this one path
|
||||||
Palette::write().open(&art::palette_file(), 8);
|
Palette::write().open(&art::palette_file(), 8);
|
||||||
|
|
||||||
App::new().font("Fira Code").ipc("island", island::command).window(island::view).run();
|
// one island on every monitor (the card opens only on one of them), a workspace pill top left
|
||||||
|
// and the control centre top right on each; all three hidden until the pointer rests on the
|
||||||
|
// top edge (edge.rs), whose strip comes first so the bar lies over it. the config window
|
||||||
|
// (settings.rs) is a normal window, opened on demand
|
||||||
|
App::new()
|
||||||
|
.font(&config::appearance::font::family())
|
||||||
|
.ipc("island", island::command)
|
||||||
|
.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 popups lie over the control centre
|
||||||
|
.window_per_monitor(notices::popups)
|
||||||
|
.window_per_monitor(prompt::view)
|
||||||
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
+278
@@ -0,0 +1,278 @@
|
|||||||
|
/*
|
||||||
|
* the Wi-Fi and Bluetooth menus as a map: this desktop as a bubble in the middle and
|
||||||
|
* every network or device floating around it, nearer the stronger it is. a line ties
|
||||||
|
* each one to the bubble: grey and wobbling while it isn't connected, running out from
|
||||||
|
* the bubble towards it while it connects, solid (with a pulse travelling along it)
|
||||||
|
* once connected. it moves all the time, so the menu asks for frames while it shows
|
||||||
|
*/
|
||||||
|
use std::f32::consts::{PI, TAU};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use amane::{Blend, Button, Canvas, Cap, Center, Circle, Color, Column, Full, Path, Pointer, Rectangle, Shape, Stack, Start, Text, Weight, Widget, shapes};
|
||||||
|
|
||||||
|
use crate::config::appearance::{overlays, text};
|
||||||
|
use crate::config::control::maps;
|
||||||
|
use crate::glyph::{Glyph, glyph_shapes};
|
||||||
|
use crate::island::faded;
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
// the map's sizes and timings are settings (config.rs: control.maps.*)
|
||||||
|
|
||||||
|
// a click on the bubble shows its rings at least until all three have run out once
|
||||||
|
pub fn ripple_s() -> f32 {
|
||||||
|
2.0 * maps::ring_gap_s() + maps::ring_s()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub enum Link {
|
||||||
|
Idle,
|
||||||
|
Connecting,
|
||||||
|
Connected,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Node {
|
||||||
|
pub glyph: Glyph,
|
||||||
|
pub name: String,
|
||||||
|
pub detail: String,
|
||||||
|
|
||||||
|
// 0 far out, 1 close to the bubble
|
||||||
|
pub nearness: f32,
|
||||||
|
pub link: Link,
|
||||||
|
|
||||||
|
pub lit: f32,
|
||||||
|
pub on_hover: Box<dyn Fn(bool)>,
|
||||||
|
// left and right clicks, told apart by the button
|
||||||
|
pub on_click: Option<Box<dyn Fn(Button)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// this desktop's bubble in the middle: its name, what to know, and a click that searches again
|
||||||
|
pub struct Centre<'a> {
|
||||||
|
pub host: &'a str,
|
||||||
|
pub note: Option<&'a str>,
|
||||||
|
|
||||||
|
// seconds since a search began (see Control::searching): rings run out from the bubble
|
||||||
|
pub searching: Option<f32>,
|
||||||
|
|
||||||
|
pub lit: f32,
|
||||||
|
pub on_hover: Box<dyn Fn(bool)>,
|
||||||
|
pub on_click: Box<dyn Fn()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// seconds since the shell first drew a map: the clock every float and wobble runs on
|
||||||
|
pub fn clock() -> f32 {
|
||||||
|
static START: OnceLock<Instant> = OnceLock::new();
|
||||||
|
|
||||||
|
START.get_or_init(Instant::now).elapsed().as_secs_f32()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectangle {
|
||||||
|
let theme = theme::now();
|
||||||
|
let t = clock();
|
||||||
|
|
||||||
|
let (cx, cy) = (width / 2.0, height / 2.0);
|
||||||
|
let bubble = maps::bubble() * (1.0 + 0.08 * centre.lit) + (t * 1.3).sin() * 1.5;
|
||||||
|
|
||||||
|
// the ellipse the farthest ones sit on, wider than tall like the menu, with room for their labels above or below
|
||||||
|
let (rx, ry) = (width / 2.0 - maps::label_width() / 2.0 - maps::drift_x() - 6.0, cy - maps::node_radius() - maps::label_height() - maps::drift_y() - 9.0);
|
||||||
|
|
||||||
|
// and the one the nearest stay outside of, clear of the bubble, its ring and its name
|
||||||
|
let (inner_x, inner_y) = (maps::bubble() + maps::node_radius() + 150.0, maps::bubble() + maps::node_radius() + 80.0);
|
||||||
|
|
||||||
|
let count = nodes.len().max(1) as f32;
|
||||||
|
|
||||||
|
let mut idle = Path::new();
|
||||||
|
let mut running = Path::new();
|
||||||
|
let mut solid = Path::new();
|
||||||
|
let mut glow = Path::new();
|
||||||
|
let mut dots: Vec<Box<dyn Shape>> = Vec::new();
|
||||||
|
let mut discs: Vec<Box<dyn Shape>> = Vec::new();
|
||||||
|
|
||||||
|
let mut floating: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
for (index, node) in nodes.into_iter().enumerate() {
|
||||||
|
let phase = index as f32;
|
||||||
|
|
||||||
|
// evenly round, a quarter step past the top, so no one sits right on top of or under the bubble
|
||||||
|
let angle = -PI / 2.0 + TAU * (phase + 0.25) / count;
|
||||||
|
// the stronger the nearer, but never far in, so neighbours' labels don't meet
|
||||||
|
let reach = 1.0 - 0.1 * node.nearness.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
// pushed out to the inner ellipse if the nearness took it inside
|
||||||
|
let (ex, ey) = (angle.cos() * rx * reach, angle.sin() * ry * reach);
|
||||||
|
let inside = ((ex / inner_x).powi(2) + (ey / inner_y).powi(2)).sqrt();
|
||||||
|
let push = if inside < 1.0 { 1.0 / inside.max(0.01) } else { 1.0 };
|
||||||
|
|
||||||
|
// a slow float, each on its own rhythm, two waves so the path never quite repeats
|
||||||
|
let drift = (
|
||||||
|
((t * 0.31 + phase * 1.7).sin() * 0.7 + (t * 0.53 + phase * 0.9).sin() * 0.3) * maps::drift_x(),
|
||||||
|
((t * 0.27 + phase * 2.3).cos() * 0.7 + (t * 0.47 + phase * 1.3).cos() * 0.3) * maps::drift_y(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let x = cx + ex * push + drift.0;
|
||||||
|
let y = cy + ey * push + drift.1;
|
||||||
|
|
||||||
|
let (dx, dy) = (x - cx, y - cy);
|
||||||
|
let length = (dx * dx + dy * dy).sqrt().max(1.0);
|
||||||
|
let (ux, uy) = (dx / length, dy / length);
|
||||||
|
|
||||||
|
let start = (cx + ux * bubble, cy + uy * bubble);
|
||||||
|
let span = (length - bubble - maps::node_radius()).max(0.0);
|
||||||
|
|
||||||
|
let point = |along: f32, swing: f32| (start.0 + ux * span * along - uy * swing, start.1 + uy * span * along + ux * swing);
|
||||||
|
|
||||||
|
match node.link {
|
||||||
|
Link::Idle | Link::Connecting => {
|
||||||
|
// still at both ends, swinging in between, the swing running outwards
|
||||||
|
let amplitude = if node.link == Link::Idle { 5.0 } else { 2.5 };
|
||||||
|
|
||||||
|
let mut path = idle.move_to(start.0, start.1);
|
||||||
|
|
||||||
|
for step in 1..=28 {
|
||||||
|
let along = step as f32 / 28.0;
|
||||||
|
let swing = amplitude * (along * PI).sin() * (along * 3.0 * TAU - t * 2.4 + phase).sin();
|
||||||
|
let (px, py) = point(along, swing);
|
||||||
|
|
||||||
|
path = path.line_to(px, py);
|
||||||
|
}
|
||||||
|
|
||||||
|
idle = path;
|
||||||
|
|
||||||
|
if node.link == Link::Connecting {
|
||||||
|
// the line runs out from the bubble to the device, over and over until it's connected
|
||||||
|
let reached = (t * 0.7 + phase * 0.13).fract();
|
||||||
|
let reached = 1.0 - (1.0 - reached).powi(2);
|
||||||
|
let tip = point(reached, 0.0);
|
||||||
|
|
||||||
|
running = running.move_to(start.0, start.1).line_to(tip.0, tip.1);
|
||||||
|
|
||||||
|
dots.push(Box::new(Circle::new().center(tip.0, tip.1).radius(3.5).fill(theme.flame)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Link::Connected => {
|
||||||
|
let end = point(1.0, 0.0);
|
||||||
|
|
||||||
|
solid = solid.move_to(start.0, start.1).line_to(end.0, end.1);
|
||||||
|
glow = glow.move_to(start.0, start.1).line_to(end.0, end.1);
|
||||||
|
|
||||||
|
// a pulse travelling out along it
|
||||||
|
let pulse = point((t * 0.45 + phase * 0.2).fract(), 0.0);
|
||||||
|
|
||||||
|
dots.push(Box::new(Circle::new().center(pulse.0, pulse.1).radius(2.5).fill(theme.flame)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (fill, mark) = match node.link {
|
||||||
|
Link::Connected => (theme.accent, theme.on_accent),
|
||||||
|
Link::Connecting => (Color::blend(overlays::chip_open(), theme.accent, 0.45), theme.text),
|
||||||
|
Link::Idle => (overlays::chip_open(), theme.text),
|
||||||
|
};
|
||||||
|
|
||||||
|
let lit = node.lit;
|
||||||
|
|
||||||
|
// its disc and icon go into the map's one canvas (see glyph_shapes), on top of every line
|
||||||
|
let radius = maps::node_radius() * (1.0 + 0.12 * lit);
|
||||||
|
|
||||||
|
discs.push(Box::new(Circle::new().center(x, y).radius(radius).fill(Color::blend(fill, theme.flame, 0.2 * lit))));
|
||||||
|
discs.push(Box::new(Circle::new().center(x, y).radius(radius).stroke(1.0, Color::blend(theme.outline, theme.accent, lit))));
|
||||||
|
discs.extend(glyph_shapes(node.glyph, maps::icon(), mark, 0.0, (x - maps::icon() / 2.0, y - maps::icon() / 2.0)));
|
||||||
|
|
||||||
|
// what's left as a widget is only where the pointer finds it
|
||||||
|
let target = Rectangle::new().width(2.0 * maps::node_radius()).height(2.0 * maps::node_radius()).radius(Full).translate(x - maps::node_radius(), y - maps::node_radius()).on_hover(node.on_hover);
|
||||||
|
|
||||||
|
let target = match node.on_click {
|
||||||
|
Some(click) => target.cursor(Pointer).on_click(click),
|
||||||
|
None => target,
|
||||||
|
};
|
||||||
|
|
||||||
|
floating.push(Box::new(target));
|
||||||
|
|
||||||
|
let bold = node.link == Link::Connected;
|
||||||
|
|
||||||
|
// away from the bubble: above the ones above it, so a label never lies on its own line
|
||||||
|
let label_y = if y < cy - maps::node_radius() { y - maps::node_radius() - 5.0 - maps::label_height() } else { y + maps::node_radius() + 5.0 };
|
||||||
|
|
||||||
|
floating.push(Box::new(
|
||||||
|
Rectangle::new().width(maps::label_width()).height(maps::label_height()).align_child(Center, Start).translate(x - maps::label_width() / 2.0, label_y).child(
|
||||||
|
Column::new(vec![
|
||||||
|
Box::new(centred(Text::new(node.name).size(text::label()).weight(if bold { Weight::Bold } else { Weight::Regular }).color(Color::blend(theme.text, theme.flame, 0.3 * lit)).elide(), 15.0)),
|
||||||
|
Box::new(centred(Text::new(node.detail).size(text::tiny()).color(theme.dim).elide(), 13.0)),
|
||||||
|
])
|
||||||
|
.gap(2.0),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines: Vec<Box<dyn Shape>> = shapes![
|
||||||
|
idle.stroke(1.5, overlays::idle_line()).cap(Cap::Round),
|
||||||
|
glow.stroke(7.0, faded(theme.accent, 45)).cap(Cap::Round),
|
||||||
|
solid.stroke(2.4, theme.accent).cap(Cap::Round),
|
||||||
|
running.stroke(2.2, faded(theme.accent, 220)).cap(Cap::Round),
|
||||||
|
];
|
||||||
|
|
||||||
|
// while it searches, rings run out from the bubble and fade, the first one from the moment of the click
|
||||||
|
if let Some(since) = centre.searching {
|
||||||
|
for ring in 0..3 {
|
||||||
|
let age = since - ring as f32 * maps::ring_gap_s();
|
||||||
|
|
||||||
|
if age < 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let grown = (age / maps::ring_s().max(0.01)).fract();
|
||||||
|
let eased = 1.0 - (1.0 - grown).powi(2);
|
||||||
|
|
||||||
|
lines.push(Box::new(
|
||||||
|
Circle::new().center(cx, cy).radius(bubble + 4.0 + eased * maps::ring_reach()).stroke(2.0, faded(theme.accent, (150.0 * (1.0 - grown)) as u8)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble + 9.0).fill(faded(theme.accent, 38))));
|
||||||
|
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble).fill(Color::blend(theme.accent, theme.flame, 0.3 * centre.lit))));
|
||||||
|
lines.extend(glyph_shapes(Glyph::Laptop, 34.0, theme.on_accent, 0.0, (cx - 17.0, cy - 17.0)));
|
||||||
|
lines.extend(dots);
|
||||||
|
lines.extend(discs);
|
||||||
|
|
||||||
|
let mut layers: Vec<Box<dyn Widget>> = vec![Box::new(Canvas::new().width(width).height(height).shapes(lines))];
|
||||||
|
|
||||||
|
// this desktop's name under its bubble, and what to know under that (on hover: what a click does)
|
||||||
|
layers.push(Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(maps::label_width() * 2.0)
|
||||||
|
.height(16.0)
|
||||||
|
.align_child(Center, Center)
|
||||||
|
.translate(cx - maps::label_width(), cy + maps::bubble() + 12.0)
|
||||||
|
.child(Text::new(centre.host).size(text::body()).weight(Weight::Bold).color(theme.text)),
|
||||||
|
));
|
||||||
|
|
||||||
|
let note = if centre.lit > 0.5 { Some("Click to search again") } else { centre.note };
|
||||||
|
|
||||||
|
if let Some(note) = note {
|
||||||
|
layers.push(Box::new(
|
||||||
|
Rectangle::new().width(maps::label_width() * 3.0).height(16.0).align_child(Center, Center).translate(cx - maps::label_width() * 1.5, cy + maps::bubble() + 32.0).child(Text::new(note).size(text::small()).color(theme.dim)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// the bubble's own target, under the nodes'
|
||||||
|
let on_click = centre.on_click;
|
||||||
|
|
||||||
|
layers.push(Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(2.0 * maps::bubble())
|
||||||
|
.height(2.0 * maps::bubble())
|
||||||
|
.radius(Full)
|
||||||
|
.translate(cx - maps::bubble(), cy - maps::bubble())
|
||||||
|
.cursor(Pointer)
|
||||||
|
.on_hover(centre.on_hover)
|
||||||
|
.on_click(move |_| on_click()),
|
||||||
|
));
|
||||||
|
|
||||||
|
layers.extend(floating);
|
||||||
|
|
||||||
|
Rectangle::new().width(width).height(height).child(Stack::new(layers).width(width).height(height))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centred(text: Text, height: f32) -> Rectangle {
|
||||||
|
Rectangle::new().width(maps::label_width()).height(height).align_child(Center, Center).child(text)
|
||||||
|
}
|
||||||
+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))
|
||||||
|
}
|
||||||
+2754
-7
File diff suppressed because it is too large
Load Diff
+121
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* a png from raw rgba pixels, for tray icons that come as pixels (IconPixmap) rather
|
||||||
|
* than a name: amane draws images from files. stdlib only, so the deflate stream is
|
||||||
|
* stored, not compressed; tray icons are a few kB either way
|
||||||
|
*/
|
||||||
|
|
||||||
|
// the image, straight rgba (not premultiplied), rows top to bottom
|
||||||
|
pub fn encode(width: u32, height: u32, rgba: &[u8]) -> Vec<u8> {
|
||||||
|
let row = width as usize * 4;
|
||||||
|
|
||||||
|
// every row starts with its filter byte, 0: none
|
||||||
|
let mut raw = Vec::with_capacity((row + 1) * height as usize);
|
||||||
|
|
||||||
|
for line in rgba.chunks(row).take(height as usize) {
|
||||||
|
raw.push(0);
|
||||||
|
raw.extend_from_slice(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut header = Vec::with_capacity(13);
|
||||||
|
header.extend_from_slice(&width.to_be_bytes());
|
||||||
|
header.extend_from_slice(&height.to_be_bytes());
|
||||||
|
// 8 bits per channel, colour type 6 (rgba), deflate, adaptive filtering, no interlace
|
||||||
|
header.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||||
|
|
||||||
|
let mut png = vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
|
||||||
|
|
||||||
|
chunk(&mut png, b"IHDR", &header);
|
||||||
|
chunk(&mut png, b"IDAT", &zlib_stored(&raw));
|
||||||
|
chunk(&mut png, b"IEND", &[]);
|
||||||
|
|
||||||
|
png
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* IconPixmap's pixels: argb32 in network byte order, so a, r, g, b per pixel; the
|
||||||
|
* same as rgba for amane
|
||||||
|
*/
|
||||||
|
pub fn argb_to_rgba(argb: &[u8]) -> Vec<u8> {
|
||||||
|
argb.chunks_exact(4).flat_map(|pixel| [pixel[1], pixel[2], pixel[3], pixel[0]]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chunk(png: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||||
|
png.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||||
|
png.extend_from_slice(kind);
|
||||||
|
png.extend_from_slice(data);
|
||||||
|
|
||||||
|
let mut checked = kind.to_vec();
|
||||||
|
checked.extend_from_slice(data);
|
||||||
|
|
||||||
|
png.extend_from_slice(&crc32(&checked).to_be_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// zlib around deflate's stored blocks, at most 65535 bytes each
|
||||||
|
fn zlib_stored(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = vec![0x78, 0x01];
|
||||||
|
let blocks: Vec<&[u8]> = if data.is_empty() { vec![&[]] } else { data.chunks(65_535).collect() };
|
||||||
|
|
||||||
|
for (index, block) in blocks.iter().enumerate() {
|
||||||
|
let last = index + 1 == blocks.len();
|
||||||
|
let length = block.len() as u16;
|
||||||
|
|
||||||
|
out.push(u8::from(last));
|
||||||
|
out.extend_from_slice(&length.to_le_bytes());
|
||||||
|
out.extend_from_slice(&(!length).to_le_bytes());
|
||||||
|
out.extend_from_slice(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.extend_from_slice(&adler32(data).to_be_bytes());
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crc32(data: &[u8]) -> u32 {
|
||||||
|
let mut crc = 0xffff_ffff_u32;
|
||||||
|
|
||||||
|
for byte in data {
|
||||||
|
crc ^= u32::from(*byte);
|
||||||
|
|
||||||
|
for _ in 0..8 {
|
||||||
|
crc = if crc & 1 == 1 { (crc >> 1) ^ 0xedb8_8320 } else { crc >> 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
!crc
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adler32(data: &[u8]) -> u32 {
|
||||||
|
let (mut a, mut b) = (1_u32, 0_u32);
|
||||||
|
|
||||||
|
for byte in data {
|
||||||
|
a = (a + u32::from(*byte)) % 65_521;
|
||||||
|
b = (b + a) % 65_521;
|
||||||
|
}
|
||||||
|
|
||||||
|
(b << 16) | a
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checksums_and_layout() {
|
||||||
|
// the well-known values: IEND's crc, and adler32 of "Wikipedia"
|
||||||
|
assert_eq!(crc32(b"IEND"), 0xae42_6082);
|
||||||
|
assert_eq!(adler32(b"Wikipedia"), 0x11e6_0398);
|
||||||
|
|
||||||
|
let png = encode(2, 1, &[255, 0, 0, 255, 0, 0, 255, 128]);
|
||||||
|
|
||||||
|
assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']);
|
||||||
|
assert_eq!(&png[12..16], b"IHDR");
|
||||||
|
assert_eq!(&png[png.len() - 12..], &[0, 0, 0, 0, b'I', b'E', b'N', b'D', 0xae, 0x42, 0x60, 0x82]);
|
||||||
|
|
||||||
|
// one row: filter byte, then the pixels, in one final stored block
|
||||||
|
let idat = &png[33 + 8..];
|
||||||
|
assert_eq!(&idat[..2], &[0x78, 0x01]);
|
||||||
|
assert_eq!(&idat[2..7], &[1, 9, 0, 0xf6, 0xff]);
|
||||||
|
|
||||||
|
assert_eq!(argb_to_rgba(&[128, 1, 2, 3]), [1, 2, 3, 128]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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())
|
||||||
|
}
|
||||||
|
|
||||||
+669
@@ -0,0 +1,669 @@
|
|||||||
|
/*
|
||||||
|
* the small popup over the control centre: it asks what joining a Wi-Fi network or
|
||||||
|
* pairing a Bluetooth device needs, shows a known network's saved password, and says
|
||||||
|
* why a connection failed. the shell serves nothing on D-Bus for this: joining goes
|
||||||
|
* through iwctl and pairing through a bluetoothctl session, which bring their own
|
||||||
|
* agents. bluetoothctl's agent asks on its output and takes the answer on its input
|
||||||
|
*/
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||||
|
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use amane::{
|
||||||
|
Animation, Blend, Center, Color, Column, End, Full, Horizontal, InputArea, Key, Keyboard, Layer, LayerWindow, Margin,
|
||||||
|
Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Start, Text, TextInput, Vertical, Weight, Widget,
|
||||||
|
Zone, children,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::config::appearance::{motion, overlays, text};
|
||||||
|
use crate::config::control::pill as control_pill;
|
||||||
|
use crate::config::island::pill as island_pill;
|
||||||
|
use crate::config::popups::{layout, timing};
|
||||||
|
use crate::control::{self, Control};
|
||||||
|
use crate::glyph::{Glyph, icon};
|
||||||
|
use crate::island::{self, veiled};
|
||||||
|
use crate::parse::{self, Pairing};
|
||||||
|
use crate::system;
|
||||||
|
use crate::{surface, theme};
|
||||||
|
|
||||||
|
// the popup's sizes are settings (config.rs: popups.*)
|
||||||
|
fn inner_w() -> f32 {
|
||||||
|
layout::width() - 2.0 * layout::pad()
|
||||||
|
}
|
||||||
|
|
||||||
|
// a bluetoothctl session that hasn't ended after popups.timing.session_s is stopped
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum Ask {
|
||||||
|
// something to type and send: a new network's password, a Bluetooth PIN or passkey
|
||||||
|
Password,
|
||||||
|
Pin,
|
||||||
|
Passkey,
|
||||||
|
|
||||||
|
// yes or no: does this passkey show on the device too (empty: may it pair at all)
|
||||||
|
Confirm(String),
|
||||||
|
|
||||||
|
// type this on the device's own keyboard
|
||||||
|
Show(String),
|
||||||
|
|
||||||
|
// a known network's saved password
|
||||||
|
Saved(String),
|
||||||
|
|
||||||
|
// why joining, pairing or connecting didn't work
|
||||||
|
Failed { title: String, reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct Request {
|
||||||
|
pub ask: Ask,
|
||||||
|
pub wifi: bool,
|
||||||
|
|
||||||
|
// the network's or device's name, and what iwctl joins: the SSID
|
||||||
|
pub name: String,
|
||||||
|
target: String,
|
||||||
|
|
||||||
|
// a text input of its own, so nothing typed for an earlier question shows up
|
||||||
|
input: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Prompt {
|
||||||
|
request: Option<Request>,
|
||||||
|
|
||||||
|
// what's typed so far, for the button that sends it
|
||||||
|
typed: String,
|
||||||
|
|
||||||
|
// what went to the clipboard: the saved password (0) or that row of the details
|
||||||
|
copied: Option<usize>,
|
||||||
|
|
||||||
|
appear: Animation,
|
||||||
|
hover: HashMap<u8, Animation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Prompt {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { request: None, typed: String::new(), copied: None, appear: Animation::new(0.0), hover: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// changes only through input, iwctl's answers and bluetoothctl's questions
|
||||||
|
fn listen() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the bluetoothctl that's pairing or connecting now: its input for the agent's answers, the process to stop it
|
||||||
|
struct Session {
|
||||||
|
id: u32,
|
||||||
|
child: Child,
|
||||||
|
input: ChildStdin,
|
||||||
|
}
|
||||||
|
|
||||||
|
static SESSION: Mutex<Option<Session>> = Mutex::new(None);
|
||||||
|
|
||||||
|
// how many sessions and questions there have been, for their ids
|
||||||
|
static COUNT: Mutex<u32> = Mutex::new(0);
|
||||||
|
|
||||||
|
fn session() -> MutexGuard<'static, Option<Session>> {
|
||||||
|
SESSION.lock().unwrap_or_else(PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next() -> u32 {
|
||||||
|
let mut count = COUNT.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
|
||||||
|
*count += 1;
|
||||||
|
|
||||||
|
*count
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: one leaked input id per question (a few bytes); fine for questions asked by hand
|
||||||
|
fn new_input() -> &'static str {
|
||||||
|
Box::leak(format!("magic-prompt-{}", next()).into_boxed_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(ask: Ask, wifi: bool, name: &str, target: &str) {
|
||||||
|
let mut prompt = Prompt::write();
|
||||||
|
|
||||||
|
prompt.request = Some(Request { ask, wifi, name: name.to_string(), target: target.to_string(), input: new_input() });
|
||||||
|
prompt.typed.clear();
|
||||||
|
prompt.copied = None;
|
||||||
|
prompt.appear = Animation::new(0.0).duration(Duration::from_millis(timing::appear_ms()));
|
||||||
|
prompt.appear.to(1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close() {
|
||||||
|
Prompt::write().request = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed(wifi: bool, name: &str, what: &str, reason: String) {
|
||||||
|
control::clear_pending();
|
||||||
|
|
||||||
|
open(Ask::Failed { title: format!("Couldn't {what} {name}"), reason }, wifi, name, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wi-Fi
|
||||||
|
|
||||||
|
// the Wi-Fi map: a saved or open network is joined at once, a new one asks for its password first
|
||||||
|
pub fn join(ssid: &str, needs_password: bool) {
|
||||||
|
if needs_password {
|
||||||
|
open(Ask::Password, true, ssid, ssid);
|
||||||
|
} else {
|
||||||
|
joining(ssid.to_string(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn joining(ssid: String, passphrase: Option<String>) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
if let Err(reason) = system::join_wifi(&ssid, passphrase.as_deref()) {
|
||||||
|
// iwd says "Operation failed" when the handshake fails, which a wrong password is the usual cause of
|
||||||
|
let reason = if passphrase.is_some() && reason == "Operation failed" { String::from("Operation failed. Is the password right?") } else { reason };
|
||||||
|
|
||||||
|
failed(true, &ssid, "join", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
system::refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// a right-click on a saved network: its password, read from iwd's profile
|
||||||
|
pub fn reveal(ssid: &str) {
|
||||||
|
let ssid = ssid.to_string();
|
||||||
|
|
||||||
|
thread::spawn(move || match system::saved_password(&ssid) {
|
||||||
|
Ok(password) => open(Ask::Saved(password), true, &ssid, &ssid),
|
||||||
|
Err(reason) => open(Ask::Failed { title: format!("No password for {ssid}"), reason }, true, &ssid, ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// a password or an address to the clipboard through wl-copy, on its input so it never shows in the process list; `done` once it's there
|
||||||
|
pub fn copy(password: &str, done: impl FnOnce() + Send + 'static) {
|
||||||
|
let password = password.to_string();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let Ok(mut child) = Command::new("wl-copy").stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let written = child.stdin.take().is_some_and(|mut input| input.write_all(password.as_bytes()).is_ok());
|
||||||
|
|
||||||
|
// wl-copy leaves a child of its own behind to serve the clipboard and exits
|
||||||
|
if child.wait().is_ok_and(|status| status.success()) && written {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bluetooth
|
||||||
|
|
||||||
|
/*
|
||||||
|
* pairs (then trusts and connects) or connects a device through bluetoothctl. its
|
||||||
|
* agent's questions open the popup, whose answers go to its input; how it ends shows
|
||||||
|
* as a reason when it fails. one session at a time: a new one stops the last
|
||||||
|
*/
|
||||||
|
pub fn bluetooth(address: &str, name: &str, pair: bool) {
|
||||||
|
stop_session();
|
||||||
|
|
||||||
|
let (address, name) = (address.to_string(), name.to_string());
|
||||||
|
|
||||||
|
thread::spawn(move || run_session(&address, &name, pair));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_session(address: &str, name: &str, pair: bool) {
|
||||||
|
let spawned = Command::new("bluetoothctl").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
|
||||||
|
|
||||||
|
let Ok(mut child) = spawned else {
|
||||||
|
failed(false, name, if pair { "pair" } else { "connect" }, String::from("bluetoothctl couldn't start"));
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (Some(mut input), Some(mut output)) = (child.stdin.take(), child.stdout.take()) else {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// its own agent, the default one while it runs, so the device's questions come to it
|
||||||
|
let first = if pair { format!("pair {address}\n") } else { format!("connect {address}\n") };
|
||||||
|
let _ = input.write_all(format!("agent KeyboardDisplay\ndefault-agent\n{first}").as_bytes());
|
||||||
|
|
||||||
|
let id = next();
|
||||||
|
|
||||||
|
*session() = Some(Session { id, child, input });
|
||||||
|
|
||||||
|
// stopped if it hasn't ended in time, which ends the reading below too
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_secs(timing::session_s()));
|
||||||
|
|
||||||
|
let late = session().as_ref().is_some_and(|session| session.id == id);
|
||||||
|
|
||||||
|
if late {
|
||||||
|
stop_session();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut printed = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 512];
|
||||||
|
|
||||||
|
// whole lines already looked at, and a question on the last, unfinished line already asked
|
||||||
|
let mut seen = 0;
|
||||||
|
let mut asked: Option<String> = None;
|
||||||
|
let mut paired = !pair;
|
||||||
|
|
||||||
|
while let Ok(read) = output.read(&mut chunk) {
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
printed.extend_from_slice(&chunk[..read]);
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&printed).into_owned();
|
||||||
|
let finished = text.ends_with('\n') || text.ends_with('\r');
|
||||||
|
let lines = parse::plain_lines(&text);
|
||||||
|
|
||||||
|
// the agent's questions end without a newline: the last line counts once it's a question
|
||||||
|
let whole = if finished { lines.len() } else { lines.len().saturating_sub(1) };
|
||||||
|
|
||||||
|
let mut events: Vec<(String, Pairing)> = Vec::new();
|
||||||
|
|
||||||
|
for line in &lines[seen.min(whole)..whole] {
|
||||||
|
if asked.as_deref() != Some(line.as_str())
|
||||||
|
&& let Some(event) = parse::pairing_event(line)
|
||||||
|
{
|
||||||
|
events.push((line.clone(), event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen = whole;
|
||||||
|
|
||||||
|
if !finished
|
||||||
|
&& let Some(last) = lines.last()
|
||||||
|
&& asked.as_deref() != Some(last.as_str())
|
||||||
|
&& let Some(event) = parse::pairing_event(last)
|
||||||
|
{
|
||||||
|
asked = Some(last.clone());
|
||||||
|
events.push((last.clone(), event));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_, event) in events {
|
||||||
|
match event {
|
||||||
|
Pairing::AskPin => open(Ask::Pin, false, name, address),
|
||||||
|
Pairing::AskPasskey => open(Ask::Passkey, false, name, address),
|
||||||
|
Pairing::Confirm(code) => open(Ask::Confirm(code), false, name, address),
|
||||||
|
Pairing::Accept => open(Ask::Confirm(String::new()), false, name, address),
|
||||||
|
// a device asking to use one of its services: pairing it was the yes
|
||||||
|
Pairing::Authorize => send("yes\n"),
|
||||||
|
Pairing::Show(code) => open(Ask::Show(code), false, name, address),
|
||||||
|
Pairing::Paired => {
|
||||||
|
paired = true;
|
||||||
|
|
||||||
|
// a code it showed is done with; trusted, it reconnects by itself later
|
||||||
|
close_unless_failed();
|
||||||
|
send(&format!("trust {address}\nconnect {address}\n"));
|
||||||
|
}
|
||||||
|
Pairing::Connected => {
|
||||||
|
close_unless_failed();
|
||||||
|
send("quit\n");
|
||||||
|
}
|
||||||
|
Pairing::Failed(error) => {
|
||||||
|
send("quit\n");
|
||||||
|
failed(false, name, if paired { "connect" } else { "pair" }, parse::bluez_reason(&error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ended: reap it, unless a newer session took its place
|
||||||
|
let ended = {
|
||||||
|
let mut current = session();
|
||||||
|
|
||||||
|
if current.as_ref().is_some_and(|session| session.id == id) { current.take() } else { None }
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(mut ended) = ended {
|
||||||
|
let _ = ended.child.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
control::clear_pending();
|
||||||
|
system::refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// an answer for bluetoothctl's agent, or a command for bluetoothctl
|
||||||
|
fn send(text: &str) {
|
||||||
|
if let Some(session) = session().as_mut() {
|
||||||
|
let _ = session.input.write_all(text.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_session() {
|
||||||
|
let Some(mut stopped) = session().take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// its agent goes with it, so bluez gives up on the pairing
|
||||||
|
let _ = stopped.child.kill();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let _ = stopped.child.wait();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// a question or code still showing goes; a reason already showing stays
|
||||||
|
fn close_unless_failed() {
|
||||||
|
let mut prompt = Prompt::write();
|
||||||
|
|
||||||
|
if !matches!(prompt.request.as_ref().map(|request| &request.ask), Some(Ask::Failed { .. })) {
|
||||||
|
prompt.request = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the popup's answers
|
||||||
|
|
||||||
|
// whether what's typed can be sent
|
||||||
|
fn ready(ask: &Ask, typed: &str) -> bool {
|
||||||
|
match ask {
|
||||||
|
// wpa passphrases are 8 to 63 characters
|
||||||
|
Ask::Password => (8..=63).contains(&typed.chars().count()),
|
||||||
|
Ask::Pin => !typed.trim().is_empty(),
|
||||||
|
Ask::Passkey => typed.trim().parse::<u32>().is_ok_and(|passkey| passkey <= 999_999),
|
||||||
|
Ask::Show(_) => false,
|
||||||
|
Ask::Confirm(_) | Ask::Saved(_) | Ask::Failed { .. } => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the user's answer: typed text, or yes, or just closing what was shown
|
||||||
|
pub fn answer(typed: &str) {
|
||||||
|
let Some(request) = Prompt::read().request.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !ready(&request.ask, typed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
close();
|
||||||
|
|
||||||
|
match request.ask {
|
||||||
|
Ask::Password => joining(request.target, Some(typed.to_string())),
|
||||||
|
Ask::Pin | Ask::Passkey => send(&format!("{}\n", typed.trim())),
|
||||||
|
Ask::Confirm(_) => send("yes\n"),
|
||||||
|
Ask::Show(_) | Ask::Saved(_) | Ask::Failed { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// no: a pairing stops (bluetoothctl and its agent go), a password not given joins nothing
|
||||||
|
pub fn refuse() {
|
||||||
|
let Some(request) = Prompt::write().request.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match request.ask {
|
||||||
|
Ask::Password => control::clear_pending(),
|
||||||
|
Ask::Pin | Ask::Passkey | Ask::Confirm(_) | Ask::Show(_) => {
|
||||||
|
stop_session();
|
||||||
|
control::clear_pending();
|
||||||
|
}
|
||||||
|
Ask::Saved(_) | Ask::Failed { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `amane ipc call control prompt answer <text>|cancel`, for scripts and tests
|
||||||
|
pub fn command(words: &[&str]) -> String {
|
||||||
|
match words {
|
||||||
|
["answer", text] => answer(text),
|
||||||
|
["answer"] => answer(""),
|
||||||
|
["cancel"] => refuse(),
|
||||||
|
_ => return String::from("usage: control prompt answer <text>|cancel"),
|
||||||
|
}
|
||||||
|
|
||||||
|
String::from("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn showing() -> bool {
|
||||||
|
Prompt::read().request.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
// for `control status`: what the popup shows, as one word
|
||||||
|
pub fn status() -> &'static str {
|
||||||
|
match Prompt::read().request.as_ref().map(|request| &request.ask) {
|
||||||
|
None => "none",
|
||||||
|
Some(Ask::Password) => "password",
|
||||||
|
Some(Ask::Pin) => "pin",
|
||||||
|
Some(Ask::Passkey) => "passkey",
|
||||||
|
Some(Ask::Confirm(_)) => "confirm",
|
||||||
|
Some(Ask::Show(_)) => "show",
|
||||||
|
Some(Ask::Saved(_)) => "saved",
|
||||||
|
Some(Ask::Failed { .. }) => "failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hovering(spot: u8) -> impl Fn(bool) + 'static {
|
||||||
|
move |inside| {
|
||||||
|
Prompt::write()
|
||||||
|
.hover
|
||||||
|
.entry(spot)
|
||||||
|
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms())))
|
||||||
|
.to(if inside { 1.0 } else { 0.0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the popup: a small card over the middle of the control centre's map, with the
|
||||||
|
* question, a field, a code or a reason, and its buttons. it takes the keyboard
|
||||||
|
* while it shows: Enter answers, Escape cancels. on one monitor only, the control
|
||||||
|
* centre's or the focused one
|
||||||
|
*/
|
||||||
|
pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||||
|
theme::begin();
|
||||||
|
|
||||||
|
let prompt = Prompt::read();
|
||||||
|
|
||||||
|
let here = {
|
||||||
|
let control = Control::read();
|
||||||
|
|
||||||
|
let on = if control.expanded() { control.opened_on().map(String::from) } else { island::focused_monitor() };
|
||||||
|
|
||||||
|
on.is_none_or(|name| name == monitor.name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let window = |width: f32, height: f32| {
|
||||||
|
LayerWindow::new()
|
||||||
|
.width(width)
|
||||||
|
.height(height)
|
||||||
|
.anchor_vertical(Vertical::Top)
|
||||||
|
.anchor_horizontal(Horizontal::Right)
|
||||||
|
.layer(Layer::Overlay)
|
||||||
|
.space(Zone::Ignore)
|
||||||
|
.namespace("magic-prompt")
|
||||||
|
};
|
||||||
|
|
||||||
|
// never hidden (see the island's view): a tiny, empty click-through window instead
|
||||||
|
let Some(request) = prompt.request.as_ref().filter(|_| here) else {
|
||||||
|
return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||||
|
};
|
||||||
|
|
||||||
|
let theme = theme::now();
|
||||||
|
let colors = theme.surfaces();
|
||||||
|
let name = &request.name;
|
||||||
|
|
||||||
|
let (title, question) = match &request.ask {
|
||||||
|
Ask::Password => (format!("Join {name}"), "Enter its Wi-Fi password"),
|
||||||
|
Ask::Pin => (format!("Pair {name}"), "Enter its PIN"),
|
||||||
|
Ask::Passkey => (format!("Pair {name}"), "Enter the six-digit passkey it shows"),
|
||||||
|
Ask::Confirm(code) if code.is_empty() => (format!("Pair {name}"), "Allow it to pair with this computer?"),
|
||||||
|
Ask::Confirm(_) => (format!("Pair {name}"), "Does the device show this code too?"),
|
||||||
|
Ask::Show(_) => (format!("Pair {name}"), "Type this code on it, then press Enter"),
|
||||||
|
Ask::Saved(_) => (name.clone(), "Its saved Wi-Fi password"),
|
||||||
|
Ask::Failed { title, .. } => (title.clone(), "What went wrong"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mark = match (&request.ask, request.wifi) {
|
||||||
|
(Ask::Failed { .. }, _) => Glyph::Muted,
|
||||||
|
(_, true) => Glyph::Lock,
|
||||||
|
(_, false) => Glyph::Bluetooth,
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = Row::new(children![
|
||||||
|
Rectangle::new().width(layout::header_height()).height(layout::header_height()).radius(Full).fill(theme.accent).align_child(Center, Center).child(icon(mark, 17.0, theme.on_accent)),
|
||||||
|
Column::new(children![
|
||||||
|
Text::new(title).size(text::popup()).weight(Weight::Bold).color(theme.text).elide(),
|
||||||
|
Text::new(question).size(text::small()).color(theme.dim).elide(),
|
||||||
|
])
|
||||||
|
.gap(3.0)
|
||||||
|
.width(inner_w() - layout::header_height() - 12.0),
|
||||||
|
])
|
||||||
|
.gap(12.0)
|
||||||
|
.align(Center);
|
||||||
|
|
||||||
|
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(header)];
|
||||||
|
let mut body_h = 0.0;
|
||||||
|
|
||||||
|
match &request.ask {
|
||||||
|
Ask::Password | Ask::Pin | Ask::Passkey => {
|
||||||
|
let field = TextInput::new(request.input)
|
||||||
|
.size(text::popup())
|
||||||
|
.color(theme.text)
|
||||||
|
.placeholder(match request.ask {
|
||||||
|
Ask::Password => "password",
|
||||||
|
Ask::Pin => "PIN",
|
||||||
|
_ => "000000",
|
||||||
|
})
|
||||||
|
.focused()
|
||||||
|
.on_change(|typed| Prompt::write().typed = typed)
|
||||||
|
.on_submit(|typed| answer(&typed));
|
||||||
|
|
||||||
|
let field = if request.ask == Ask::Password { field.password() } else { field };
|
||||||
|
|
||||||
|
rows.push(Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(inner_w())
|
||||||
|
.height(layout::field_height())
|
||||||
|
.radius(10.0)
|
||||||
|
.fill(overlays::chip())
|
||||||
|
.border(1.0, theme.outline)
|
||||||
|
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 12.0 })
|
||||||
|
.align_child(Start, Center)
|
||||||
|
.child(field),
|
||||||
|
));
|
||||||
|
|
||||||
|
body_h = layout::field_height();
|
||||||
|
}
|
||||||
|
Ask::Confirm(code) | Ask::Show(code) if !code.is_empty() => {
|
||||||
|
rows.push(Box::new(big_line(&spaced(code), 26.0, theme.accent)));
|
||||||
|
|
||||||
|
body_h = layout::code_height();
|
||||||
|
}
|
||||||
|
Ask::Saved(password) => {
|
||||||
|
let password = password.clone();
|
||||||
|
|
||||||
|
// a click on it copies it too
|
||||||
|
rows.push(Box::new(big_line(&password, 18.0, theme.text).cursor(Pointer).on_click(move |_| copy(&password, || Prompt::write().copied = Some(0)))));
|
||||||
|
|
||||||
|
body_h = layout::code_height();
|
||||||
|
}
|
||||||
|
Ask::Failed { reason, .. } => {
|
||||||
|
rows.push(Box::new(Rectangle::new().width(inner_w()).height(layout::line_height()).align_child(Start, Center).child(Text::new(reason.as_str()).size(text::label()).color(theme.text).elide())));
|
||||||
|
|
||||||
|
body_h = layout::line_height();
|
||||||
|
}
|
||||||
|
Ask::Confirm(_) | Ask::Show(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the buttons: Cancel while something is asked (Copy for a password), and what answers (just a close for what's only shown)
|
||||||
|
let lit = |spot: u8| prompt.hover.get(&spot).map_or(0.0, Animation::value);
|
||||||
|
|
||||||
|
let mut buttons: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
if let Ask::Saved(password) = &request.ask {
|
||||||
|
let password = password.clone();
|
||||||
|
let label = if prompt.copied.is_some() { "Copied" } else { "Copy" };
|
||||||
|
|
||||||
|
buttons.push(Box::new(button(label, Color::blend(overlays::chip_open(), overlays::chip_hover(), lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(move |_| copy(&password, || Prompt::write().copied = Some(0)))));
|
||||||
|
} else if !matches!(request.ask, Ask::Failed { .. }) {
|
||||||
|
buttons.push(Box::new(button("Cancel", Color::blend(overlays::chip_open(), overlays::chip_hover(), lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(|_| refuse())));
|
||||||
|
}
|
||||||
|
|
||||||
|
let primary = match request.ask {
|
||||||
|
Ask::Password => Some("Join"),
|
||||||
|
Ask::Show(_) => None,
|
||||||
|
Ask::Saved(_) => Some("Close"),
|
||||||
|
Ask::Failed { .. } => Some("OK"),
|
||||||
|
_ => Some("Pair"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(label) = primary {
|
||||||
|
let ok = ready(&request.ask, &prompt.typed);
|
||||||
|
|
||||||
|
let fill = if ok { Color::blend(theme.accent, theme.flame, 0.35 * lit(1)) } else { overlays::chip() };
|
||||||
|
let ink = if ok { theme.on_accent } else { theme.dim };
|
||||||
|
|
||||||
|
let primary = button(label, fill, ink, if ok { lit(1) } else { 0.0 }).on_hover(hovering(1));
|
||||||
|
|
||||||
|
// a click answers with what's typed, as Enter does
|
||||||
|
let primary = if ok {
|
||||||
|
let typed = prompt.typed.clone();
|
||||||
|
|
||||||
|
primary.cursor(Pointer).on_click(move |_| answer(&typed))
|
||||||
|
} else {
|
||||||
|
primary
|
||||||
|
};
|
||||||
|
|
||||||
|
buttons.push(Box::new(primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(Box::new(Row::new(vec![Box::new(Row::new(buttons).gap(10.0)) as Box<dyn Widget>]).justify(End).width(inner_w()).height(layout::button_height())));
|
||||||
|
|
||||||
|
let parts = if body_h > 0.0 { 3.0 } else { 2.0 };
|
||||||
|
let card_h = 2.0 * layout::pad() + layout::header_height() + body_h + layout::button_height() + layout::gap() * (parts - 1.0);
|
||||||
|
|
||||||
|
let appear = prompt.appear.value();
|
||||||
|
|
||||||
|
let content = Rectangle::new().width(layout::width()).height(card_h).padding(layout::pad()).child(Column::new(rows).gap(layout::gap()));
|
||||||
|
|
||||||
|
// raised a little over the control centre: a stronger glow and rim than a resting surface
|
||||||
|
let card = surface::shell(layout::width(), card_h, 22.0, &colors, 0.375, veiled(content, layout::width(), card_h, 22.0, appear, &colors)).translate(0.0, 8.0 * (1.0 - appear));
|
||||||
|
|
||||||
|
// over the middle of the map menu, a little below the control centre's top
|
||||||
|
let (right, top) = (control_pill::right() + (control::map_width(monitor.width as f32) - layout::width()) / 2.0 - island_pill::shadow(), island_pill::top_gap() + 110.0 - island_pill::shadow());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the keyboard only while there's something to type: the other popups come on their
|
||||||
|
* own (a reason, a code) and mustn't take keys meant for another window; a click
|
||||||
|
* gives them the keyboard for Enter and Escape
|
||||||
|
*/
|
||||||
|
let typing = matches!(request.ask, Ask::Password | Ask::Pin | Ask::Passkey);
|
||||||
|
|
||||||
|
let window = window(layout::width() + 2.0 * island_pill::shadow(), card_h + 2.0 * island_pill::shadow())
|
||||||
|
.margin(Margin { top: top.max(0.0) as i32, right: right.max(0.0) as i32, bottom: 0, left: 0 })
|
||||||
|
.keyboard(if typing { Keyboard::Exclusive } else { Keyboard::OnDemand })
|
||||||
|
.on_key(|key| match key {
|
||||||
|
Key::Escape => refuse(),
|
||||||
|
// nothing to type: Enter is the yes, or the close
|
||||||
|
Key::Enter => {
|
||||||
|
let answers = Prompt::read().request.as_ref().is_some_and(|request| matches!(request.ask, Ask::Confirm(_) | Ask::Saved(_) | Ask::Failed { .. }));
|
||||||
|
|
||||||
|
if answers {
|
||||||
|
answer("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
let region = InputArea { x: island_pill::shadow() as i32, y: island_pill::shadow() as i32, width: layout::width() as i32, height: card_h.ceil() as i32 };
|
||||||
|
|
||||||
|
window.input_region(vec![region]).child(Rectangle::new().width(Parent).height(Parent).padding(island_pill::shadow()).align_child(Start, Start).child(card))
|
||||||
|
}
|
||||||
|
|
||||||
|
// a code spaced in threes, the way devices show it
|
||||||
|
fn spaced(code: &str) -> String {
|
||||||
|
code.chars().enumerate().flat_map(|(index, digit)| (index > 0 && index % 3 == 0).then_some(' ').into_iter().chain(std::iter::once(digit))).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn big_line(text: &str, size: f32, color: Color) -> Rectangle {
|
||||||
|
Rectangle::new().width(inner_w()).height(layout::code_height()).align_child(Center, Center).child(Text::new(text).size(size).weight(Weight::Bold).color(color).elide())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button(label: &str, fill: Color, ink: Color, lit: f32) -> Rectangle {
|
||||||
|
Rectangle::new().width(layout::button_width()).height(layout::button_height()).radius(10.0).fill(fill).align_child(Center, Center).scale(1.0 + 0.04 * lit).child(Text::new(label).size(text::body()).weight(Weight::Bold).color(ink))
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+767
@@ -0,0 +1,767 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use amane::{
|
||||||
|
Center, Color, Column, Cursor, End, Full, Key, Padding, Rectangle, Row, ScrollArea, Service, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget, Window, children, close_window, open_window, window_size,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::config::{self, Kind, Opt};
|
||||||
|
use crate::hypr::{self, Hypr, HyprOption};
|
||||||
|
use crate::parse::{HyprKind, Value};
|
||||||
|
use crate::theme::{self, Colors};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the config window: every shell setting (config.rs) and every Hyprland option (hypr.rs)
|
||||||
|
* on pages in a sidebar, with a search over all of them. a normal window, opened from the
|
||||||
|
* control centre's Settings button or `amane ipc call config open|toggle`
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NAME: &str = "magic-island-settings";
|
||||||
|
const TITLE: &str = "Magic Island settings";
|
||||||
|
|
||||||
|
// the window's own layout; its text sizes come from appearance.text like the rest
|
||||||
|
const SIDEBAR_W: f32 = 240.0;
|
||||||
|
const PAD: f32 = 20.0;
|
||||||
|
const CONTROL_W: f32 = 320.0;
|
||||||
|
const FIELD_H: f32 = 30.0;
|
||||||
|
const SLIDER_W: f32 = 200.0;
|
||||||
|
const NUMBER_W: f32 = 104.0;
|
||||||
|
const RESET_W: f32 = 56.0;
|
||||||
|
|
||||||
|
const SEARCH: &str = "settings-search";
|
||||||
|
|
||||||
|
pub struct Ui {
|
||||||
|
// a shell page ("island") or a Hyprland one ("hyprland:decoration")
|
||||||
|
page: String,
|
||||||
|
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, focus_search: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listen() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open() {
|
||||||
|
Ui::write().focus_search = true;
|
||||||
|
|
||||||
|
open_window(NAME, view);
|
||||||
|
|
||||||
|
hypr::load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// whether the window is open: amane can't say, Hyprland lists it by title
|
||||||
|
fn is_open() -> bool {
|
||||||
|
Command::new("hyprctl").arg("clients").output().is_ok_and(|done| String::from_utf8_lossy(&done.stdout).contains(&format!("title: {TITLE}\n")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `amane ipc call config ...`: open, close, toggle, page <id>, search <words>,
|
||||||
|
* get <path>, set <path> <value>, reset <path>, status. paths as in config.toml;
|
||||||
|
* hyprland.<option> ones go to Hyprland
|
||||||
|
*/
|
||||||
|
pub fn command(arguments: &[String]) -> String {
|
||||||
|
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
|
||||||
|
|
||||||
|
match words.as_slice() {
|
||||||
|
["open"] => open(),
|
||||||
|
["close"] => close_window(NAME),
|
||||||
|
["toggle"] => {
|
||||||
|
if is_open() {
|
||||||
|
close_window(NAME);
|
||||||
|
} else {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
["page", page] => {
|
||||||
|
go_to(page);
|
||||||
|
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
["search", rest @ ..] => {
|
||||||
|
Ui::write().query = rest.join(" ");
|
||||||
|
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
["get", path] => {
|
||||||
|
return match config::option(path) {
|
||||||
|
Some(option) => config::shown(&config::current(option)),
|
||||||
|
None => match path.strip_prefix("hyprland.") {
|
||||||
|
Some(key) => Hypr::read().options.iter().find(|option| option.key() == key).map_or_else(|| String::from("not loaded; open the window first"), |option| option.current.clone()),
|
||||||
|
None => format!("no setting {path}"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
["set", path, value @ ..] => return set_text(path, &value.join(" ")).map_or_else(|why| why, |()| String::from("ok")),
|
||||||
|
["reset", path] => {
|
||||||
|
return match path.strip_prefix("hyprland.") {
|
||||||
|
Some(key) => {
|
||||||
|
hypr::reset(key);
|
||||||
|
|
||||||
|
String::from("ok")
|
||||||
|
}
|
||||||
|
None => config::reset(path).map_or_else(|why| why, |()| String::from("ok")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
["status"] => {
|
||||||
|
let ui = Ui::read();
|
||||||
|
|
||||||
|
return format!("open={} page={} errors={} search={}", is_open(), ui.page, config::errors().len(), ui.query);
|
||||||
|
}
|
||||||
|
_ => return String::from("usage: config open|close|toggle|page <id>|search <words>|get <path>|set <path> <value>|reset <path>|status"),
|
||||||
|
}
|
||||||
|
|
||||||
|
String::from("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn go_to(page: &str) {
|
||||||
|
let mut ui = Ui::write();
|
||||||
|
|
||||||
|
ui.page = page.to_string();
|
||||||
|
ui.query.clear();
|
||||||
|
ui.message = None;
|
||||||
|
|
||||||
|
if page.starts_with("hyprland:") {
|
||||||
|
hypr::load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a typed value for a setting; Hyprland's are checked here when the list is known, then applied by hypr.rs
|
||||||
|
fn set_text(path: &str, text: &str) -> Result<(), String> {
|
||||||
|
if let Some(key) = path.strip_prefix("hyprland.") {
|
||||||
|
let kind = Hypr::read().options.iter().find(|option| option.key() == key).map(|option| option.kind);
|
||||||
|
|
||||||
|
if let Some(kind) = kind {
|
||||||
|
crate::parse::hypr_lua(kind, text).map_err(|why| format!("{key} {why}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
hypr::set(key, text);
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let option = config::option(path).ok_or(format!("no setting {path}"))?;
|
||||||
|
|
||||||
|
config::from_text(option, text).and_then(|value| config::set(path, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn say(result: Result<(), String>, done: String) {
|
||||||
|
Ui::write().message = Some(match result {
|
||||||
|
Ok(()) => (done, false),
|
||||||
|
Err(why) => (why, true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
let (width, height) = match window_size() {
|
||||||
|
(0.0, _) | (_, 0.0) => (1100.0, 760.0),
|
||||||
|
size => size,
|
||||||
|
};
|
||||||
|
|
||||||
|
let main_w = (width - SIDEBAR_W).max(400.0);
|
||||||
|
let row_w = main_w - 2.0 * PAD - 12.0;
|
||||||
|
|
||||||
|
let query = ui.query.trim().to_lowercase();
|
||||||
|
|
||||||
|
// the rows to show: a search goes through every page
|
||||||
|
let mut sections: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
let shell_page = !ui.page.starts_with("hyprland:");
|
||||||
|
let category = ui.page.strip_prefix("hyprland:").unwrap_or("");
|
||||||
|
|
||||||
|
let title = if !query.is_empty() {
|
||||||
|
format!("Search: {}", ui.query.trim())
|
||||||
|
} else if shell_page {
|
||||||
|
config::title(&ui.page)
|
||||||
|
} else {
|
||||||
|
format!("Hyprland · {}", config::title(category))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut notes: Vec<(String, bool)> = Vec::new();
|
||||||
|
|
||||||
|
if shell_page || !query.is_empty() {
|
||||||
|
notes.extend(config::errors().into_iter().map(|error| (format!("config.toml {error}"), true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shell_page || !query.is_empty()) && hypr.loaded && !hypr.required && !config::overrides().is_empty() {
|
||||||
|
notes.push((String::from("Hyprland options set here apply now, but a Hyprland reload drops them: add require(\"amane\") as the last line of ~/.config/hypr/hyprland.lua"), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
for note in notes {
|
||||||
|
sections.push(Box::new(banner(¬e.0, note.1, row_w)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// shell settings, by group
|
||||||
|
let mut group = "";
|
||||||
|
let mut page = "";
|
||||||
|
|
||||||
|
for option in config::OPTIONS {
|
||||||
|
let shown = if query.is_empty() { shell_page && option.module() == ui.page } else { option.path.contains(&query) || option.help.to_lowercase().contains(&query) };
|
||||||
|
|
||||||
|
if !shown {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if option.group() != group || option.module() != page {
|
||||||
|
let heading = if query.is_empty() { config::title(option.group()) } else { format!("{} › {}", config::title(option.module()), config::title(option.group())) };
|
||||||
|
|
||||||
|
sections.push(Box::new(heading_row(&heading, row_w)));
|
||||||
|
|
||||||
|
group = option.group();
|
||||||
|
page = option.module();
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(Box::new(shell_row(option, row_w, &colors)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland's, by group
|
||||||
|
if !shell_page || !query.is_empty() {
|
||||||
|
if !hypr.loaded {
|
||||||
|
sections.push(Box::new(Text::new("Reading Hyprland's options…").size(config::appearance::text::body()).color(colors.dim)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut group: Option<(&str, &str)> = None;
|
||||||
|
|
||||||
|
for option in &hypr.options {
|
||||||
|
let shown = if query.is_empty() { option.category() == category } else { option.name.contains(&query) || option.help.to_lowercase().contains(&query) };
|
||||||
|
|
||||||
|
if !shown {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let here = (option.category(), option.group());
|
||||||
|
|
||||||
|
if group != Some(here) {
|
||||||
|
let named = if option.group().is_empty() { String::new() } else { config::title(option.group()) };
|
||||||
|
|
||||||
|
let heading = match (query.is_empty(), named.is_empty()) {
|
||||||
|
(true, true) => String::from("General"),
|
||||||
|
(true, false) => named,
|
||||||
|
(false, true) => format!("Hyprland › {}", config::title(option.category())),
|
||||||
|
(false, false) => format!("Hyprland › {} › {named}", config::title(option.category())),
|
||||||
|
};
|
||||||
|
|
||||||
|
sections.push(Box::new(heading_row(&heading, row_w)));
|
||||||
|
|
||||||
|
group = Some(here);
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(Box::new(hypr_row(option, row_w, &colors)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sections.is_empty() {
|
||||||
|
sections.push(Box::new(Text::new("Nothing matches.").size(config::appearance::text::body()).color(colors.dim)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = if shell_page { ui.message.clone() } else { hypr.message.clone().or(ui.message.clone()) };
|
||||||
|
|
||||||
|
let (words, wrong) = message.unwrap_or_else(|| (format!("Saved to {} as you change things; Enter applies a typed value", config::file().display()), false));
|
||||||
|
|
||||||
|
let footer = Text::new(words).size(config::appearance::text::small()).color(if wrong { config::appearance::overlays::low_battery() } else { colors.dim }).elide();
|
||||||
|
|
||||||
|
let main = Rectangle::new()
|
||||||
|
.width(main_w)
|
||||||
|
.height(height)
|
||||||
|
.padding(Padding { top: PAD, right: PAD, bottom: 12.0, left: PAD })
|
||||||
|
.child(Column::new(children![
|
||||||
|
Text::new(title).size(config::appearance::text::title()).weight(Weight::Bold).color(colors.text),
|
||||||
|
ScrollArea::new("settings-rows", Column::new(sections).gap(6.0)).width(main_w - 2.0 * PAD).height(height - PAD - 12.0 - 40.0 - 24.0),
|
||||||
|
Rectangle::new().width(main_w - 2.0 * PAD).height(20.0).align_child(Start, Center).child(footer),
|
||||||
|
]).gap(10.0));
|
||||||
|
|
||||||
|
Window::new()
|
||||||
|
.title(TITLE)
|
||||||
|
.size(1100.0, 760.0)
|
||||||
|
.resizable(true)
|
||||||
|
/*
|
||||||
|
* 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, focus_search), main])),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
entries.push(Box::new(entry(module.to_string(), config::title(module), ui, colors)));
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(Box::new(caption("HYPRLAND", colors)));
|
||||||
|
|
||||||
|
let mut categories: Vec<&str> = Vec::new();
|
||||||
|
|
||||||
|
for option in &hypr.options {
|
||||||
|
if !categories.contains(&option.category()) {
|
||||||
|
categories.push(option.category());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if categories.is_empty() {
|
||||||
|
entries.push(Box::new(Text::new(if hypr.loaded { "Hyprland didn't answer" } else { "Reading…" }).size(config::appearance::text::small()).color(colors.dim)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for category in categories {
|
||||||
|
entries.push(Box::new(entry(format!("hyprland:{category}"), config::title(category), ui, colors)));
|
||||||
|
}
|
||||||
|
|
||||||
|
synced(SEARCH, &ui.query);
|
||||||
|
|
||||||
|
let search = Rectangle::new()
|
||||||
|
.width(SIDEBAR_W - 24.0)
|
||||||
|
.height(FIELD_H)
|
||||||
|
.radius(10.0)
|
||||||
|
.fill(config::appearance::overlays::chip())
|
||||||
|
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
|
||||||
|
.align_child(Start, Center)
|
||||||
|
.cursor(Cursor::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)
|
||||||
|
.height(height)
|
||||||
|
.fill(colors.surface)
|
||||||
|
.padding(12.0)
|
||||||
|
.child(Column::new(children![search, ScrollArea::new("settings-pages", Column::new(entries).gap(2.0)).width(SIDEBAR_W - 24.0).height(height - 24.0 - FIELD_H - 12.0)]).gap(12.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn caption(text: &str, colors: &Colors) -> Rectangle {
|
||||||
|
Rectangle::new()
|
||||||
|
.width(SIDEBAR_W - 24.0)
|
||||||
|
.height(30.0)
|
||||||
|
.padding(Padding { top: 12.0, right: 0.0, bottom: 0.0, left: 10.0 })
|
||||||
|
.child(Text::new(text).size(config::appearance::text::tiny()).weight(Weight::Bold).color(colors.dim))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(id: String, title: String, ui: &Ui, colors: &Colors) -> Rectangle {
|
||||||
|
let chosen = ui.query.is_empty() && ui.page == id;
|
||||||
|
let lit = ui.hover.as_deref() == Some(id.as_str());
|
||||||
|
|
||||||
|
let fill = if chosen { colors.accent } else if lit { config::appearance::overlays::chip_hover() } else { Color::TRANSPARENT };
|
||||||
|
|
||||||
|
let (hovered, clicked) = (id.clone(), id);
|
||||||
|
|
||||||
|
Rectangle::new()
|
||||||
|
.width(SIDEBAR_W - 24.0)
|
||||||
|
.height(30.0)
|
||||||
|
.radius(8.0)
|
||||||
|
.fill(fill)
|
||||||
|
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
|
||||||
|
.align_child(Start, Center)
|
||||||
|
.cursor(Cursor::Pointer)
|
||||||
|
.on_hover(move |inside| {
|
||||||
|
let mut ui = Ui::write();
|
||||||
|
|
||||||
|
if inside {
|
||||||
|
ui.hover = Some(hovered.clone());
|
||||||
|
} else if ui.hover.as_deref() == Some(hovered.as_str()) {
|
||||||
|
ui.hover = None;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_click(move |_| {
|
||||||
|
SYNCED.with_borrow_mut(|synced| synced.remove(SEARCH));
|
||||||
|
|
||||||
|
go_to(&clicked);
|
||||||
|
})
|
||||||
|
.child(Text::new(title).size(config::appearance::text::body()).color(if chosen { colors.on_accent } else { colors.text }).elide())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn heading_row(text: &str, width: f32) -> Rectangle {
|
||||||
|
Rectangle::new()
|
||||||
|
.width(width)
|
||||||
|
.height(34.0)
|
||||||
|
.padding(Padding { top: 14.0, right: 0.0, bottom: 0.0, left: 4.0 })
|
||||||
|
.child(Text::new(text.to_uppercase()).size(config::appearance::text::small()).weight(Weight::Bold).color(theme::now().accent))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn banner(text: &str, wrong: bool, width: f32) -> Rectangle {
|
||||||
|
let colors = theme::now();
|
||||||
|
let words = Text::new(text).size(config::appearance::text::small()).color(colors.text).wrap();
|
||||||
|
let height = words.height_in(width - 24.0) + 16.0;
|
||||||
|
|
||||||
|
Rectangle::new()
|
||||||
|
.width(width)
|
||||||
|
.height(height)
|
||||||
|
.radius(10.0)
|
||||||
|
.fill(if wrong { faded(config::appearance::overlays::low_battery(), 60) } else { config::appearance::overlays::chip() })
|
||||||
|
.padding(Padding { top: 8.0, right: 12.0, bottom: 8.0, left: 12.0 })
|
||||||
|
.child(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn faded(color: Color, alpha: u8) -> Color {
|
||||||
|
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* one setting: its label and key, the help under them, the control on the right, and
|
||||||
|
* Reset while it differs from its default
|
||||||
|
*/
|
||||||
|
fn row(label: String, key: String, help: String, control: Box<dyn Widget>, reset: Option<Box<dyn Fn()>>, width: f32) -> Rectangle {
|
||||||
|
let colors = theme::now();
|
||||||
|
let text_w = width - CONTROL_W - RESET_W - 2.0 * 14.0 - 24.0;
|
||||||
|
|
||||||
|
let help = Text::new(help).size(config::appearance::text::small()).color(colors.dim).wrap();
|
||||||
|
let help_h = help.height_in(text_w);
|
||||||
|
|
||||||
|
let label_h = config::appearance::text::body() * 1.5;
|
||||||
|
let height = (label_h + 2.0 + help_h).max(FIELD_H) + 20.0;
|
||||||
|
|
||||||
|
let reset: Box<dyn Widget> = match reset {
|
||||||
|
Some(reset) => Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(RESET_W)
|
||||||
|
.height(24.0)
|
||||||
|
.radius(Full)
|
||||||
|
.fill(config::appearance::overlays::chip())
|
||||||
|
.align_child(Center, Center)
|
||||||
|
.cursor(Cursor::Pointer)
|
||||||
|
.on_click(move |_| reset())
|
||||||
|
.child(Text::new("Reset").size(config::appearance::text::tiny()).color(colors.text)),
|
||||||
|
),
|
||||||
|
None => Box::new(Rectangle::new().width(RESET_W).height(24.0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Rectangle::new()
|
||||||
|
.width(width)
|
||||||
|
.height(height)
|
||||||
|
.radius(12.0)
|
||||||
|
.fill(config::appearance::overlays::chip())
|
||||||
|
.padding(Padding { top: 10.0, right: 14.0, bottom: 10.0, left: 14.0 })
|
||||||
|
.child(
|
||||||
|
Row::new(children![
|
||||||
|
Column::new(children![
|
||||||
|
Row::new(children![
|
||||||
|
Text::new(label).size(config::appearance::text::body()).weight(Weight::Bold).color(colors.text),
|
||||||
|
Text::new(key).size(config::appearance::text::tiny()).color(colors.dim).elide(),
|
||||||
|
])
|
||||||
|
.gap(10.0)
|
||||||
|
.align(Center)
|
||||||
|
.width(text_w)
|
||||||
|
.height(label_h),
|
||||||
|
Rectangle::new().width(text_w).height(help_h).child(help),
|
||||||
|
])
|
||||||
|
.gap(2.0),
|
||||||
|
Row::new(vec![control, reset]).gap(12.0).align(Center).justify(End).width(CONTROL_W + RESET_W + 12.0),
|
||||||
|
])
|
||||||
|
.justify(SpaceBetween)
|
||||||
|
.align(Center)
|
||||||
|
.width(width - 28.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_row(option: &'static Opt, width: f32, colors: &Colors) -> Rectangle {
|
||||||
|
let value = config::current(option);
|
||||||
|
|
||||||
|
let control: Box<dyn Widget> = match (option.kind, &value) {
|
||||||
|
(Kind::Bool, Value::Bool(on)) => {
|
||||||
|
let on = *on;
|
||||||
|
|
||||||
|
Box::new(switch(on, move || say(config::set(option.path, Value::Bool(!on)), format!("{} = {}", option.path, !on))))
|
||||||
|
}
|
||||||
|
(Kind::Text, Value::Text(current)) if !option.choices.is_empty() => Box::new(chips(
|
||||||
|
option
|
||||||
|
.choices
|
||||||
|
.iter()
|
||||||
|
.map(|&word| (String::from(word), current == word, Box::new(move || say(config::set(option.path, Value::Text(String::from(word))), format!("{} = {word}", option.path))) as Box<dyn Fn()>))
|
||||||
|
.collect(),
|
||||||
|
colors,
|
||||||
|
)),
|
||||||
|
(Kind::Int | Kind::Float, _) if option.range.is_some() => {
|
||||||
|
let (min, max) = option.range.unwrap_or((0.0, 1.0));
|
||||||
|
let whole = option.kind == Kind::Int;
|
||||||
|
let number = value.number().unwrap_or(min);
|
||||||
|
|
||||||
|
Box::new(
|
||||||
|
Row::new(children![
|
||||||
|
slider((number - min) / (max - min), move |fraction| {
|
||||||
|
let picked = stepped(min + fraction * (max - min), min, max, whole);
|
||||||
|
let value = if whole { Value::Int(picked as i64) } else { Value::Float(picked) };
|
||||||
|
|
||||||
|
say(config::set(option.path, value), format!("{} = {}", option.path, config::shown(&if whole { Value::Int(picked as i64) } else { Value::Float(picked) })));
|
||||||
|
}),
|
||||||
|
field(option.path, &config::shown(&value), NUMBER_W, None, move |text| say(set_text(option.path, &text), format!("{} = {text}", option.path))),
|
||||||
|
])
|
||||||
|
.gap(12.0)
|
||||||
|
.align(Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let swatch = (option.kind == Kind::Color).then(|| value.text().and_then(config::color)).flatten();
|
||||||
|
|
||||||
|
Box::new(field(option.path, &config::shown(&value), CONTROL_W, swatch, move |text| say(set_text(option.path, &text), format!("{} = {text}", option.path))))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let reset: Option<Box<dyn Fn()>> = (!config::is_default(option)).then(|| Box::new(move || say(config::reset(option.path), format!("{} is back to its default", option.path))) as Box<dyn Fn()>);
|
||||||
|
|
||||||
|
let mut help = option.help.to_string();
|
||||||
|
|
||||||
|
if !config::is_default(option) {
|
||||||
|
help.push_str(&format!(" · default {}", config::shown(&(option.default)())));
|
||||||
|
}
|
||||||
|
|
||||||
|
row(config::label(option.name()), option.path.to_string(), help, control, reset, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hypr_row(option: &HyprOption, width: f32, colors: &Colors) -> Rectangle {
|
||||||
|
let name = option.name;
|
||||||
|
let path = option.path();
|
||||||
|
let overridden = config::override_of(&path).is_some();
|
||||||
|
|
||||||
|
let control: Box<dyn Widget> = match option.kind {
|
||||||
|
HyprKind::Bool => {
|
||||||
|
let on = option.current == "true";
|
||||||
|
|
||||||
|
Box::new(switch(on, move || hypr::set(name, if on { "false" } else { "true" })))
|
||||||
|
}
|
||||||
|
HyprKind::Int if !option.choices.is_empty() => Box::new(choices(option, colors)),
|
||||||
|
HyprKind::Int | HyprKind::Float if option.range.is_some() => {
|
||||||
|
let (min, max) = option.range.unwrap_or((0.0, 1.0));
|
||||||
|
let whole = option.kind == HyprKind::Int;
|
||||||
|
let number: f64 = option.current.parse().unwrap_or(min);
|
||||||
|
|
||||||
|
Box::new(
|
||||||
|
Row::new(children![
|
||||||
|
slider((number - min) / (max - min), move |fraction| {
|
||||||
|
let picked = stepped(min + fraction * (max - min), min, max, whole);
|
||||||
|
|
||||||
|
hypr::set(name, &if whole { (picked as i64).to_string() } else { picked.to_string() });
|
||||||
|
}),
|
||||||
|
field(name, &option.current, NUMBER_W, None, move |text| hypr::set(name, &text)),
|
||||||
|
])
|
||||||
|
.gap(12.0)
|
||||||
|
.align(Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let swatch = (option.kind == HyprKind::Gradient).then(|| first_color(&option.current)).flatten();
|
||||||
|
|
||||||
|
Box::new(field(name, &option.current, CONTROL_W, swatch, move |text| hypr::set(name, &text)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let reset: Option<Box<dyn Fn()>> = overridden.then(|| Box::new(move || hypr::reset(name)) as Box<dyn Fn()>);
|
||||||
|
|
||||||
|
let mut help = option.help.clone();
|
||||||
|
|
||||||
|
if !option.default.is_empty() {
|
||||||
|
help.push_str(&format!(" · default {}", option.default));
|
||||||
|
}
|
||||||
|
|
||||||
|
if overridden {
|
||||||
|
help.push_str(" · set here");
|
||||||
|
} else if option.in_config {
|
||||||
|
help.push_str(" · set in your Hyprland config");
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = option.label().replace("col.", "colour_").replace('.', "_");
|
||||||
|
|
||||||
|
row(config::label(&label), path, help, control, reset, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "rgba(80d9ffff) ..." as its first colour, for the swatch
|
||||||
|
fn first_color(gradient: &str) -> Option<Color> {
|
||||||
|
let word = gradient.split_whitespace().next()?;
|
||||||
|
|
||||||
|
config::color(word.trim_start_matches("rgba(").trim_end_matches(')'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// a value from a slider: whole numbers, or steps of about a hundredth of the range
|
||||||
|
fn stepped(value: f64, min: f64, max: f64, whole: bool) -> f64 {
|
||||||
|
let value = value.clamp(min, max);
|
||||||
|
|
||||||
|
if whole {
|
||||||
|
return value.round();
|
||||||
|
}
|
||||||
|
|
||||||
|
let step = 10f64.powf(((max - min).log10()).floor() - 2.0);
|
||||||
|
|
||||||
|
((value / step).round() * step * 1e6).round() / 1e6
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch(on: bool, toggle: impl Fn() + 'static) -> Rectangle {
|
||||||
|
let colors = theme::now();
|
||||||
|
|
||||||
|
Rectangle::new()
|
||||||
|
.width(46.0)
|
||||||
|
.height(26.0)
|
||||||
|
.radius(Full)
|
||||||
|
.fill(if on { colors.accent } else { config::appearance::overlays::track() })
|
||||||
|
.padding(4.0)
|
||||||
|
.align_child(Start, Center)
|
||||||
|
.cursor(Cursor::Pointer)
|
||||||
|
.on_click(move |_| toggle())
|
||||||
|
.child(Rectangle::new().width(18.0).height(18.0).radius(Full).fill(if on { colors.on_accent } else { colors.dim }).translate(if on { 20.0 } else { 0.0 }, 0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slider(fraction: f64, pick: impl Fn(f64) + Clone + 'static) -> Rectangle {
|
||||||
|
let colors = theme::now();
|
||||||
|
let fraction = fraction.clamp(0.0, 1.0) as f32;
|
||||||
|
let knob = 16.0;
|
||||||
|
|
||||||
|
let pick_drag = pick.clone();
|
||||||
|
|
||||||
|
Rectangle::new()
|
||||||
|
.width(SLIDER_W)
|
||||||
|
.height(FIELD_H)
|
||||||
|
.align_child(Start, Center)
|
||||||
|
.cursor(Cursor::Pointer)
|
||||||
|
.on_click(|_| {})
|
||||||
|
.on_drag(move |point| pick_drag(f64::from(((point.x - knob / 2.0) / (SLIDER_W - knob)).clamp(0.0, 1.0))))
|
||||||
|
.on_scroll(move |scroll| pick(f64::from((fraction + if scroll.y < 0.0 { 0.01 } else { -0.01 }).clamp(0.0, 1.0))))
|
||||||
|
.child(Stack::new(children![
|
||||||
|
Rectangle::new().width(SLIDER_W).height(6.0).radius(Full).fill(config::appearance::overlays::track()).translate(0.0, (knob - 6.0) / 2.0),
|
||||||
|
Rectangle::new().width(knob / 2.0 + fraction * (SLIDER_W - knob)).height(6.0).radius(Full).fill(colors.accent).translate(0.0, (knob - 6.0) / 2.0),
|
||||||
|
Rectangle::new().width(knob).height(knob).radius(Full).fill(colors.flame).translate(fraction * (SLIDER_W - knob), 0.0),
|
||||||
|
]).width(SLIDER_W).height(knob))
|
||||||
|
}
|
||||||
|
|
||||||
|
// a Hyprland number with named values: one chip per name
|
||||||
|
// a Hyprland option's named values as chips
|
||||||
|
fn choices(option: &HyprOption, colors: &Colors) -> Row {
|
||||||
|
let name = option.name;
|
||||||
|
|
||||||
|
chips(
|
||||||
|
option
|
||||||
|
.choices
|
||||||
|
.iter()
|
||||||
|
.map(|(label, value)| {
|
||||||
|
let value = value.to_string();
|
||||||
|
|
||||||
|
(label.replace('_', " "), option.current == value, Box::new(move || hypr::set(name, &value)) as Box<dyn Fn()>)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
colors,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// one choice: its label, whether it's the current one, what a click does
|
||||||
|
type Choice = (String, bool, Box<dyn Fn()>);
|
||||||
|
|
||||||
|
fn chips(items: Vec<Choice>, colors: &Colors) -> Row {
|
||||||
|
let chips: Vec<Box<dyn Widget>> = items
|
||||||
|
.into_iter()
|
||||||
|
.map(|(label, chosen, pick)| {
|
||||||
|
let letters = label.chars().count() as f32;
|
||||||
|
|
||||||
|
Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(letters * config::appearance::text::small() * config::appearance::font::letter_width() + 18.0)
|
||||||
|
.height(26.0)
|
||||||
|
.radius(Full)
|
||||||
|
.fill(if chosen { colors.accent } else { config::appearance::overlays::chip() })
|
||||||
|
.align_child(Center, Center)
|
||||||
|
.cursor(Cursor::Pointer)
|
||||||
|
.on_click(move |_| pick())
|
||||||
|
.child(Text::new(label).size(config::appearance::text::small()).color(if chosen { colors.on_accent } else { colors.text })),
|
||||||
|
) as Box<dyn Widget>
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Row::new(chips).gap(6.0).align(Center).justify(End).width(CONTROL_W)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the text inputs' texts as last set from their values, so typing isn't overwritten until a value changes
|
||||||
|
thread_local! {
|
||||||
|
static SYNCED: RefCell<HashMap<&'static str, String>> = RefCell::new(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synced(id: &'static str, text: &str) {
|
||||||
|
SYNCED.with_borrow_mut(|synced| {
|
||||||
|
if synced.get(id).map(String::as_str) != Some(text) {
|
||||||
|
TextInput::set_text(id, text);
|
||||||
|
|
||||||
|
synced.insert(id, text.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(id: &'static str, text: &str, width: f32, swatch: Option<Color>, submit: impl Fn(String) + 'static) -> Row {
|
||||||
|
let colors = theme::now();
|
||||||
|
|
||||||
|
synced(id, text);
|
||||||
|
|
||||||
|
let swatch_w = if swatch.is_some() { FIELD_H + 8.0 } else { 0.0 };
|
||||||
|
|
||||||
|
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(color) = swatch {
|
||||||
|
parts.push(Box::new(Rectangle::new().width(FIELD_H).height(FIELD_H).radius(8.0).fill(color).border(1.0, colors.outline)));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(width - swatch_w)
|
||||||
|
.height(FIELD_H)
|
||||||
|
.radius(8.0)
|
||||||
|
.fill(config::appearance::overlays::chip())
|
||||||
|
.border(1.0, colors.outline)
|
||||||
|
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
|
||||||
|
.align_child(Start, Center)
|
||||||
|
.cursor(Cursor::Text)
|
||||||
|
.child(TextInput::new(id).size(config::appearance::text::body()).color(colors.text).on_submit(submit)),
|
||||||
|
));
|
||||||
|
|
||||||
|
Row::new(parts).gap(8.0).align(Center)
|
||||||
|
}
|
||||||
+88
-29
@@ -2,27 +2,28 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use std::{env, fs, thread};
|
use std::{fs, thread};
|
||||||
|
|
||||||
use amane::{Argument, Bus, Media, Service};
|
use amane::{Argument, Bus, Media, Service};
|
||||||
|
|
||||||
use crate::art;
|
use crate::art;
|
||||||
|
use crate::config::sound::{equalizer, presets as gains, timing};
|
||||||
use crate::parse::{self, EQ_PREFIX, Sink, Stream};
|
use crate::parse::{self, EQ_PREFIX, Sink, Stream};
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
// how far a band can lift or cut, in dB
|
// the presets with their gains for the 10 bands, low to high (config.rs: sound.presets.*)
|
||||||
pub const MAX_GAIN: f32 = 12.0;
|
pub fn presets() -> [(&'static str, [f32; 10]); 8] {
|
||||||
|
[
|
||||||
// gains for BANDS (31 Hz .. 16 kHz)
|
("Flat", gains::flat()),
|
||||||
pub const EQ_PRESETS: [(&str, [f32; 10]); 8] = [
|
("Bass", gains::bass()),
|
||||||
("Flat", [0.0; 10]),
|
("Treble", gains::treble()),
|
||||||
("Bass", [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
("Vocal", gains::vocal()),
|
||||||
("Treble", [-2.0, -1.5, -1.0, 0.0, 1.0, 2.0, 3.0, 4.5, 5.5, 6.0]),
|
("Pop", gains::pop()),
|
||||||
("Vocal", [-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.5, 2.0, 0.0, -1.0]),
|
("Rock", gains::rock()),
|
||||||
("Pop", [-1.0, 1.0, 2.5, 3.5, 2.5, 0.5, -0.5, -0.5, 1.0, 2.0]),
|
("Jazz", gains::jazz()),
|
||||||
("Rock", [5.0, 4.0, 2.5, -0.5, -1.5, -0.5, 2.0, 3.5, 4.5, 5.0]),
|
("Classic", gains::classic()),
|
||||||
("Jazz", [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.5, 2.5, 3.0]),
|
]
|
||||||
("Classic", [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0]),
|
}
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub struct Eq {
|
pub struct Eq {
|
||||||
@@ -68,6 +69,9 @@ pub struct Sound {
|
|||||||
outputs: Vec<Sink>,
|
outputs: Vec<Sink>,
|
||||||
channels: Vec<Channel>,
|
channels: Vec<Channel>,
|
||||||
|
|
||||||
|
// the sink new sound goes to (`pactl get-default-sink`)
|
||||||
|
default_output: String,
|
||||||
|
|
||||||
// by player identity, so "Cider" keeps its curve across restarts
|
// by player identity, so "Cider" keeps its curve across restarts
|
||||||
eqs: BTreeMap<String, Eq>,
|
eqs: BTreeMap<String, Eq>,
|
||||||
unsaved: bool,
|
unsaved: bool,
|
||||||
@@ -97,7 +101,7 @@ impl Service for Sound {
|
|||||||
poll(&mut known);
|
poll(&mut known);
|
||||||
save();
|
save();
|
||||||
|
|
||||||
thread::sleep(Duration::from_secs(1));
|
thread::sleep(Duration::from_millis(timing::poll_ms()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +111,63 @@ impl Sound {
|
|||||||
&self.outputs
|
&self.outputs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn default_output(&self) -> &str {
|
||||||
|
&self.default_output
|
||||||
|
}
|
||||||
|
|
||||||
|
// the first output whose pactl name or description contains `wanted`; none for an empty `wanted`
|
||||||
|
pub fn output_named(&self, wanted: &str) -> Option<&Sink> {
|
||||||
|
let wanted = wanted.to_lowercase();
|
||||||
|
|
||||||
|
(!wanted.is_empty())
|
||||||
|
.then(|| self.outputs.iter().find(|sink| sink.description.to_lowercase().contains(&wanted) || sink.name.to_lowercase().contains(&wanted)))
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
// a whole output's volume, unmuting it, as the control centre's sliders set it
|
||||||
|
pub fn set_output_volume(name: &str, volume: u8) {
|
||||||
|
let mut sound = Self::write();
|
||||||
|
|
||||||
|
sound.hold();
|
||||||
|
|
||||||
|
let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if sink.muted {
|
||||||
|
run(&["pactl", "set-sink-mute", name, "0"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
run(&["pactl", "set-sink-volume", name, &format!("{volume}%")]);
|
||||||
|
|
||||||
|
sink.volume = volume;
|
||||||
|
sink.muted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_output_mute(name: &str) {
|
||||||
|
let mut sound = Self::write();
|
||||||
|
|
||||||
|
sound.hold();
|
||||||
|
|
||||||
|
let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
sink.muted = !sink.muted;
|
||||||
|
|
||||||
|
run(&["pactl", "set-sink-mute", name, if sink.muted { "1" } else { "0" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// where new sound goes; players without an output of their own follow it
|
||||||
|
pub fn set_default_output(name: &str) {
|
||||||
|
let mut sound = Self::write();
|
||||||
|
|
||||||
|
sound.hold();
|
||||||
|
sound.default_output = name.to_string();
|
||||||
|
|
||||||
|
run(&["pactl", "set-default-sink", name]);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn channel(&self, player: &str) -> Option<&Channel> {
|
pub fn channel(&self, player: &str) -> Option<&Channel> {
|
||||||
self.channels.iter().find(|channel| channel.player == player)
|
self.channels.iter().find(|channel| channel.player == player)
|
||||||
}
|
}
|
||||||
@@ -124,7 +185,7 @@ impl Sound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn hold(&mut self) {
|
fn hold(&mut self) {
|
||||||
self.held_until = Some(Instant::now() + Duration::from_millis(2500));
|
self.held_until = Some(Instant::now() + Duration::from_millis(timing::hold_ms()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// from input handlers: sets and unmutes every stream of one player
|
// from input handlers: sets and unmutes every stream of one player
|
||||||
@@ -200,7 +261,7 @@ impl Sound {
|
|||||||
|
|
||||||
// from a dragged equalizer band; half-dB steps keep pw-cli calls few
|
// from a dragged equalizer band; half-dB steps keep pw-cli calls few
|
||||||
pub fn set_band(player: &str, identity: &str, band: usize, gain: f32) {
|
pub fn set_band(player: &str, identity: &str, band: usize, gain: f32) {
|
||||||
let gain = ((gain * 2.0).round() / 2.0).clamp(-MAX_GAIN, MAX_GAIN);
|
let gain = ((gain * 2.0).round() / 2.0).clamp(-equalizer::max_gain(), equalizer::max_gain());
|
||||||
|
|
||||||
let gains = {
|
let gains = {
|
||||||
let mut sound = Self::write();
|
let mut sound = Self::write();
|
||||||
@@ -226,18 +287,18 @@ impl Sound {
|
|||||||
|
|
||||||
// false when there is no preset by that name
|
// false when there is no preset by that name
|
||||||
pub fn preset(player: &str, identity: &str, name: &str) -> bool {
|
pub fn preset(player: &str, identity: &str, name: &str) -> bool {
|
||||||
let Some((preset, gains)) = EQ_PRESETS.iter().find(|(preset, _)| preset.eq_ignore_ascii_case(name)) else {
|
let Some((preset, gains)) = presets().into_iter().find(|(preset, _)| preset.eq_ignore_ascii_case(name)) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut sound = Self::write();
|
let mut sound = Self::write();
|
||||||
|
|
||||||
sound.eqs.insert(identity.to_string(), Eq { preset: preset.to_string(), gains: *gains });
|
sound.eqs.insert(identity.to_string(), Eq { preset: preset.to_string(), gains });
|
||||||
sound.unsaved = true;
|
sound.unsaved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
apply(player, identity, gains);
|
apply(player, identity, &gains);
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -350,10 +411,12 @@ fn poll(known: &mut BTreeSet<String>) {
|
|||||||
|
|
||||||
let outputs: Vec<Sink> = sinks.into_iter().filter(|sink| !sink.name.starts_with(EQ_PREFIX)).collect();
|
let outputs: Vec<Sink> = sinks.into_iter().filter(|sink| !sink.name.starts_with(EQ_PREFIX)).collect();
|
||||||
|
|
||||||
|
let default_output = amane::output("pactl get-default-sink");
|
||||||
|
|
||||||
let stale = {
|
let stale = {
|
||||||
let sound = Sound::read();
|
let sound = Sound::read();
|
||||||
|
|
||||||
!sound.held() && (sound.channels != channels || sound.outputs != outputs)
|
!sound.held() && (sound.channels != channels || sound.outputs != outputs || sound.default_output != default_output)
|
||||||
};
|
};
|
||||||
|
|
||||||
if stale {
|
if stale {
|
||||||
@@ -361,6 +424,7 @@ fn poll(known: &mut BTreeSet<String>) {
|
|||||||
|
|
||||||
sound.channels = channels;
|
sound.channels = channels;
|
||||||
sound.outputs = outputs;
|
sound.outputs = outputs;
|
||||||
|
sound.default_output = default_output;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +445,7 @@ fn start_eq(key: &str, identity: &str, gains: &[f32; 10], target: &str) {
|
|||||||
|
|
||||||
let config = eq_file(key);
|
let config = eq_file(key);
|
||||||
|
|
||||||
if fs::write(&config, parse::eq_config(key, identity, gains, target)).is_ok() {
|
if fs::write(&config, parse::eq_config(key, identity, &equalizer::bands(), gains, target)).is_ok() {
|
||||||
run(&["setsid", "-f", "pipewire", "-c", &config.display().to_string()]);
|
run(&["setsid", "-f", "pipewire", "-c", &config.display().to_string()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,12 +456,7 @@ fn eq_file(key: &str) -> PathBuf {
|
|||||||
|
|
||||||
// saved equalizers live with the shell's config, so they outlast logouts
|
// saved equalizers live with the shell's config, so they outlast logouts
|
||||||
fn store_file() -> PathBuf {
|
fn store_file() -> PathBuf {
|
||||||
let config = env::var_os("XDG_CONFIG_HOME")
|
theme::config_file("island-eq")
|
||||||
.map(PathBuf::from)
|
|
||||||
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
config.join("amane").join("island-eq")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save() {
|
fn save() {
|
||||||
|
|||||||
+12
-5
@@ -9,10 +9,10 @@ use amane::{Media, Service};
|
|||||||
use crate::art;
|
use crate::art;
|
||||||
use crate::island::{self, Island};
|
use crate::island::{self, Island};
|
||||||
use crate::parse;
|
use crate::parse;
|
||||||
|
use crate::config::sound::spectrum as settings;
|
||||||
use crate::sound::Sound;
|
use crate::sound::Sound;
|
||||||
|
|
||||||
// cava's bars, low to high frequencies; the ring mirrors them
|
// cava's bars (sound.spectrum.bars), low to high frequencies; the ring mirrors them
|
||||||
pub const BARS: usize = 24;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* the shown player's own sound as bars: `pw-record --target <its stream>`
|
* the shown player's own sound as bars: `pw-record --target <its stream>`
|
||||||
@@ -35,6 +35,12 @@ impl Service for Spectrum {
|
|||||||
fn listen() {
|
fn listen() {
|
||||||
let config = art::folder().join("cava.conf");
|
let config = art::folder().join("cava.conf");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* a cava left by an earlier shell: stopping the shell doesn't stop its cava,
|
||||||
|
* which reads on from a closed pipe and ignores SIGTERM. none of ours runs yet
|
||||||
|
*/
|
||||||
|
let _ = Command::new("pkill").arg("-KILL").arg("-f").arg(&config).status();
|
||||||
|
|
||||||
let _ = fs::write(&config, cava_config());
|
let _ = fs::write(&config, cava_config());
|
||||||
|
|
||||||
let mut running: Option<Capture> = None;
|
let mut running: Option<Capture> = None;
|
||||||
@@ -52,17 +58,18 @@ impl Service for Spectrum {
|
|||||||
running = wanted.and_then(|stream| Capture::start(stream, &config));
|
running = wanted.and_then(|stream| Capture::start(stream, &config));
|
||||||
}
|
}
|
||||||
|
|
||||||
thread::sleep(Duration::from_millis(200));
|
thread::sleep(Duration::from_millis(settings::check_ms()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cava_config() -> String {
|
fn cava_config() -> String {
|
||||||
format!(
|
format!(
|
||||||
"[general]\nframerate = 60\nbars = {BARS}\n\
|
"[general]\nframerate = 60\nbars = {}\n\
|
||||||
[input]\nmethod = fifo\nsource = /dev/stdin\nsample_rate = 44100\nsample_bits = 16\n\
|
[input]\nmethod = fifo\nsource = /dev/stdin\nsample_rate = 44100\nsample_bits = 16\n\
|
||||||
[output]\nmethod = raw\nraw_target = /dev/stdout\ndata_format = ascii\nascii_max_range = 100\nchannels = mono\n\
|
[output]\nmethod = raw\nraw_target = /dev/stdout\ndata_format = ascii\nascii_max_range = 100\nchannels = mono\n\
|
||||||
[smoothing]\nnoise_reduction = 70\n"
|
[smoothing]\nnoise_reduction = 70\n",
|
||||||
|
settings::bars()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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))
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
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::faded;
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* what the island, its idle pill, the workspace and tray pills, the tray menu, the control
|
||||||
|
* centre and its popup are made of: one place, so a design is this file. `shell` wraps
|
||||||
|
* `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 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 a = alpha(frosted::opacity());
|
||||||
|
let sheen = frosted::sheen();
|
||||||
|
|
||||||
|
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 light = liquid::light();
|
||||||
|
|
||||||
|
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
|
||||||
|
_ => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
fn alpha(opacity: f32) -> u8 {
|
||||||
|
(opacity.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn white(opacity: f32) -> Color {
|
||||||
|
Color::rgba(255, 255, 255, alpha(opacity))
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+965
@@ -0,0 +1,965 @@
|
|||||||
|
/*
|
||||||
|
* what the control centre shows about the machine: wi-fi and the hotspot through iwd,
|
||||||
|
* openvpn through openrc, power (battery, firmware profile, keyboard light) through
|
||||||
|
* sysfs, and the external gpu with the programs that have it open. changes that need
|
||||||
|
* root go through the user's `doas -n` rule with fixed command lines; nothing that
|
||||||
|
* comes from another program ever reaches a shell
|
||||||
|
*/
|
||||||
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::{Mutex, PoisonError};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use std::{fs, thread};
|
||||||
|
|
||||||
|
use amane::{Argument, Bluetooth, Bus, Service, Value};
|
||||||
|
|
||||||
|
use crate::parse;
|
||||||
|
use crate::config::control::{polling, services};
|
||||||
|
|
||||||
|
const IWD: &str = "net.connman.iwd";
|
||||||
|
const DEVICE: &str = "net.connman.iwd.Device";
|
||||||
|
const STATION: &str = "net.connman.iwd.Station";
|
||||||
|
const ACCESS_POINT: &str = "net.connman.iwd.AccessPoint";
|
||||||
|
|
||||||
|
// wi-fi, hotspot and vpn
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Default)]
|
||||||
|
pub struct Wifi {
|
||||||
|
// the iwd daemon runs; without it the tile is grey and a click starts its service
|
||||||
|
pub service: bool,
|
||||||
|
|
||||||
|
// the device's object path, like /net/connman/iwd/0/4, and its interface name for iwctl, like wlan0; empty without a device
|
||||||
|
pub device: String,
|
||||||
|
pub device_name: String,
|
||||||
|
pub powered: bool,
|
||||||
|
|
||||||
|
// the station's state: connected, connecting, disconnected, ...
|
||||||
|
pub state: String,
|
||||||
|
pub network: String,
|
||||||
|
|
||||||
|
// 0 to 3 arcs
|
||||||
|
pub bars: u8,
|
||||||
|
|
||||||
|
// the device is an access point, whether its hotspot is up, and who is on it
|
||||||
|
pub access_point: bool,
|
||||||
|
pub hotspot: bool,
|
||||||
|
pub hotspot_name: String,
|
||||||
|
pub clients: usize,
|
||||||
|
|
||||||
|
// the networks in range, the connected one first, then by signal
|
||||||
|
pub networks: Vec<Network>,
|
||||||
|
|
||||||
|
// iwd is scanning for networks
|
||||||
|
pub scanning: bool,
|
||||||
|
|
||||||
|
// the connected network's receive link rate in Mbit/s, looked up at most once a minute
|
||||||
|
pub rate: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// a wired network card (not wi-fi, not virtual), from sysfs
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct Wired {
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
// a cable is in and the link is up
|
||||||
|
pub connected: bool,
|
||||||
|
|
||||||
|
// the link speed it negotiated, in Mbit/s
|
||||||
|
pub speed: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct Network {
|
||||||
|
// iwd's object path, which Connect is called on
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub secured: bool,
|
||||||
|
|
||||||
|
// 802.1x: a username and password, which the popup doesn't ask for
|
||||||
|
pub enterprise: bool,
|
||||||
|
pub known: bool,
|
||||||
|
pub connected: bool,
|
||||||
|
|
||||||
|
// iwd is joining it right now
|
||||||
|
pub connecting: bool,
|
||||||
|
pub bars: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub enum VpnState {
|
||||||
|
Stopped,
|
||||||
|
Connecting,
|
||||||
|
Connected,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct Vpn {
|
||||||
|
// the openrc service, like openvpn.swiss
|
||||||
|
pub service: String,
|
||||||
|
pub state: VpnState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vpn {
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
self.service.trim_start_matches("openvpn.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Links {
|
||||||
|
pub wifi: Wifi,
|
||||||
|
pub wired: Vec<Wired>,
|
||||||
|
pub vpns: Vec<Vpn>,
|
||||||
|
|
||||||
|
// bluez's openrc service is installed, so a click can start it
|
||||||
|
pub bluetooth_service: bool,
|
||||||
|
|
||||||
|
// this machine's name, for the bubble in the middle of the maps
|
||||||
|
pub host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Links {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listen() {
|
||||||
|
loop {
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(polling::links_ms()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reads everything again outside the lock, and writes only what changed
|
||||||
|
pub fn refresh() {
|
||||||
|
let wifi = wifi();
|
||||||
|
let wired = wired();
|
||||||
|
let vpns = vpns();
|
||||||
|
let bluetooth_service = Path::new("/etc/init.d/bluetooth").exists();
|
||||||
|
let host = read(Path::new("/proc/sys/kernel/hostname"));
|
||||||
|
|
||||||
|
let stale = {
|
||||||
|
let links = Links::read();
|
||||||
|
|
||||||
|
links.wifi != wifi || links.wired != wired || links.vpns != vpns || links.bluetooth_service != bluetooth_service || links.host != host
|
||||||
|
};
|
||||||
|
|
||||||
|
if stale {
|
||||||
|
*Links::write() = Links { wifi, wired, vpns, bluetooth_service, host };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// every real wired card: one with a device behind it, that isn't wi-fi
|
||||||
|
fn wired() -> Vec<Wired> {
|
||||||
|
let Ok(entries) = fs::read_dir("/sys/class/net") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut wired: Vec<Wired> = entries
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|card| read(&card.join("type")) == "1" && card.join("device").exists() && !card.join("wireless").exists() && !card.join("phy80211").exists())
|
||||||
|
.map(|card| {
|
||||||
|
// carrier and speed can't be read while the card is down
|
||||||
|
let connected = read(&card.join("carrier")) == "1";
|
||||||
|
let speed = read(&card.join("speed")).parse().ok().filter(|speed| connected && *speed > 0);
|
||||||
|
|
||||||
|
Wired { name: card.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(), connected, speed }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
wired.sort_by(|one, other| one.name.cmp(&other.name));
|
||||||
|
|
||||||
|
wired
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the connected network's receive rate from iwd's diagnostics (in 100 kbit/s), asked at
|
||||||
|
* most once a minute, and again when the network changes: it shifts with every packet
|
||||||
|
*/
|
||||||
|
fn wifi_rate(bus: &Bus, device: &str, network: &str) -> Option<u32> {
|
||||||
|
static LAST: Mutex<Option<(Instant, String, Option<u32>)>> = Mutex::new(None);
|
||||||
|
|
||||||
|
let mut last = LAST.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
|
||||||
|
if let Some((asked, on, rate)) = last.as_ref()
|
||||||
|
&& on == network
|
||||||
|
&& asked.elapsed() < Duration::from_secs(polling::wifi_rate_s())
|
||||||
|
{
|
||||||
|
return *rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
let diagnostics = bus.call(IWD, device, "net.connman.iwd.StationDiagnostic", "GetDiagnostics", &[]);
|
||||||
|
let rate = Some(diagnostics.get("RxBitrate").number()).filter(|rate| *rate > 0.0).map(|rate| (rate / 10.0).round() as u32);
|
||||||
|
|
||||||
|
*last = Some((Instant::now(), network.to_string(), rate));
|
||||||
|
|
||||||
|
rate
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wifi() -> Wifi {
|
||||||
|
let bus = Bus::system();
|
||||||
|
|
||||||
|
// anything sent to iwd starts it (d-bus activation), so talk to it only while it runs
|
||||||
|
let running = bus
|
||||||
|
.call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", &[Argument::from(IWD)])
|
||||||
|
.bool();
|
||||||
|
|
||||||
|
if !running {
|
||||||
|
return Wifi::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Value::Map(objects) = bus.call(IWD, "/", "org.freedesktop.DBus.ObjectManager", "GetManagedObjects", &[]) else {
|
||||||
|
return Wifi { service: true, ..Wifi::default() };
|
||||||
|
};
|
||||||
|
|
||||||
|
// the first wi-fi device; a laptop has one
|
||||||
|
let Some((path, interfaces)) = objects.iter().find(|(_, interfaces)| matches!(interfaces.get(DEVICE), Value::Map(_))) else {
|
||||||
|
return Wifi { service: true, ..Wifi::default() };
|
||||||
|
};
|
||||||
|
|
||||||
|
let device = interfaces.get(DEVICE);
|
||||||
|
let station = interfaces.get(STATION);
|
||||||
|
let access_point = interfaces.get(ACCESS_POINT);
|
||||||
|
|
||||||
|
let connected = station.get("ConnectedNetwork").text();
|
||||||
|
let network = objects.get(connected).map_or("", |network| network.get("net.connman.iwd.Network").get("Name").text());
|
||||||
|
|
||||||
|
// iwd names the network it's joining as connected already; the state tells the two apart
|
||||||
|
let state = station.get("State").text();
|
||||||
|
let joined = state == "connected" || state == "roaming";
|
||||||
|
|
||||||
|
// every network in range as [path, signal in hundredths of a dBm], strongest first
|
||||||
|
let ordered = if matches!(station, Value::Map(_)) { bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]) } else { Value::Nothing };
|
||||||
|
|
||||||
|
let mut networks: Vec<Network> = ordered
|
||||||
|
.list()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let path = entry.list().first()?.text();
|
||||||
|
let found = objects.get(path)?.get("net.connman.iwd.Network");
|
||||||
|
let signal = entry.list().get(1).map_or(-10_000.0, |signal| signal.number());
|
||||||
|
|
||||||
|
Some(Network {
|
||||||
|
path: path.to_string(),
|
||||||
|
name: found.get("Name").text().to_string(),
|
||||||
|
secured: found.get("Type").text() != "open",
|
||||||
|
enterprise: found.get("Type").text() == "8021x",
|
||||||
|
known: !found.get("KnownNetwork").text().is_empty(),
|
||||||
|
connected: path == connected && joined,
|
||||||
|
connecting: path == connected && !joined,
|
||||||
|
bars: parse::wifi_bars(signal / 100.0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// a stable sort keeps the signal order within each group
|
||||||
|
networks.sort_by_key(|network| !network.connected);
|
||||||
|
|
||||||
|
let bars = networks.iter().find(|network| network.connected).map_or(0, |network| network.bars);
|
||||||
|
|
||||||
|
let hotspot = access_point.get("Started").bool();
|
||||||
|
|
||||||
|
let clients = if hotspot {
|
||||||
|
bus.call(IWD, path, "net.connman.iwd.AccessPointDiagnostic", "GetDiagnostics", &[]).list().len()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let rate = if joined { wifi_rate(&bus, path, network) } else { None };
|
||||||
|
|
||||||
|
Wifi {
|
||||||
|
service: true,
|
||||||
|
device: path.clone(),
|
||||||
|
device_name: device.get("Name").text().to_string(),
|
||||||
|
powered: device.get("Powered").bool(),
|
||||||
|
state: station.get("State").text().to_string(),
|
||||||
|
network: network.to_string(),
|
||||||
|
bars,
|
||||||
|
access_point: device.get("Mode").text() == "ap",
|
||||||
|
hotspot,
|
||||||
|
hotspot_name: access_point.get("Name").text().to_string(),
|
||||||
|
clients,
|
||||||
|
networks,
|
||||||
|
scanning: station.get("Scanning").bool(),
|
||||||
|
rate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vpns() -> Vec<Vpn> {
|
||||||
|
let Ok(entries) = fs::read_dir("/etc/init.d") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut vpns: Vec<Vpn> = entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|entry| entry.file_name().into_string().ok())
|
||||||
|
.filter(|name| name.starts_with("openvpn.") && parse::safe_name(name))
|
||||||
|
.map(|service| Vpn { state: openrc_state(&service), service })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
vpns.sort_by(|one, other| one.service.cmp(&other.service));
|
||||||
|
|
||||||
|
vpns
|
||||||
|
}
|
||||||
|
|
||||||
|
// gentoo's openvpn service stays inactive until the tunnel is up; then its up.sh marks it started
|
||||||
|
fn openrc_state(service: &str) -> VpnState {
|
||||||
|
let marked = |state: &str| Path::new("/run/openrc").join(state).join(service).exists();
|
||||||
|
|
||||||
|
if marked("started") {
|
||||||
|
VpnState::Connected
|
||||||
|
} else if marked("inactive") || marked("starting") {
|
||||||
|
VpnState::Connecting
|
||||||
|
} else {
|
||||||
|
VpnState::Stopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a root command through the user's doas rule; false when doas wants a password or the command fails
|
||||||
|
fn root(arguments: &[&str]) -> bool {
|
||||||
|
Command::new("doas")
|
||||||
|
.arg("-n")
|
||||||
|
.args(arguments)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.is_ok_and(|status| status.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the wi-fi tile: starts iwd's service while it's stopped; while it runs, turns the
|
||||||
|
* device on or off. a running hotspot is stopped instead, which makes it a client again
|
||||||
|
*/
|
||||||
|
pub fn toggle_wifi() {
|
||||||
|
let wifi = Links::read().wifi.clone();
|
||||||
|
|
||||||
|
if !wifi.service {
|
||||||
|
root(&["rc-service", "iwd", "start"]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if wifi.device.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if wifi.access_point {
|
||||||
|
stop_hotspot(&wifi.device);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bus::system().set_property(IWD, &wifi.device, DEVICE, "Powered", Argument::from(!wifi.powered));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* one radio: while the device is an access point it isn't a wi-fi client. the hotspot
|
||||||
|
* starts from iwd's profile /var/lib/iwd/ap/<name>.ap, named by control.services.hotspot
|
||||||
|
*/
|
||||||
|
pub fn toggle_hotspot() {
|
||||||
|
let wifi = Links::read().wifi.clone();
|
||||||
|
let name = services::hotspot();
|
||||||
|
|
||||||
|
if !wifi.service {
|
||||||
|
root(&["rc-service", "iwd", "start"]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if wifi.device.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if wifi.access_point {
|
||||||
|
stop_hotspot(&wifi.device);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bus = Bus::system();
|
||||||
|
|
||||||
|
if !wifi.powered {
|
||||||
|
bus.set_property(IWD, &wifi.device, DEVICE, "Powered", Argument::from(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.set_property(IWD, &wifi.device, DEVICE, "Mode", Argument::from("ap"));
|
||||||
|
|
||||||
|
// the access point's interface shows up once the mode has switched
|
||||||
|
for _ in 0..20 {
|
||||||
|
if matches!(bus.property(IWD, &wifi.device, ACCESS_POINT, "Started"), Value::Bool(_)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(150));
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.call(IWD, &wifi.device, ACCESS_POINT, "StartProfile", &[Argument::from(name.as_str())]);
|
||||||
|
|
||||||
|
// no profile by that name: back to being a wi-fi client rather than an access point doing nothing
|
||||||
|
if !bus.property(IWD, &wifi.device, ACCESS_POINT, "Started").bool() {
|
||||||
|
bus.set_property(IWD, &wifi.device, DEVICE, "Mode", Argument::from("station"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_hotspot(device: &str) {
|
||||||
|
let bus = Bus::system();
|
||||||
|
|
||||||
|
bus.call(IWD, device, ACCESS_POINT, "Stop", &[]);
|
||||||
|
bus.set_property(IWD, device, DEVICE, "Mode", Argument::from("station"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* joins a network with iwctl, which brings its own agent (the password goes on its
|
||||||
|
* command line, --dont-ask without one) and says why when it fails. blocks until
|
||||||
|
* iwd has joined or given up, so call it off the drawing thread
|
||||||
|
*/
|
||||||
|
pub fn join_wifi(ssid: &str, passphrase: Option<&str>) -> Result<(), String> {
|
||||||
|
let device = Links::read().wifi.device_name.clone();
|
||||||
|
|
||||||
|
if device.is_empty() {
|
||||||
|
return Err(String::from("iwd sees no Wi-Fi device"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut iwctl = Command::new("iwctl");
|
||||||
|
|
||||||
|
match passphrase {
|
||||||
|
Some(passphrase) => iwctl.arg("--passphrase").arg(passphrase),
|
||||||
|
None => iwctl.arg("--dont-ask"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = iwctl.args(["station", &device, "connect", ssid]).stdin(Stdio::null()).output().map_err(|error| format!("iwctl couldn't start: {error}"))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let printed = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
|
||||||
|
|
||||||
|
Err(parse::plain_lines(&printed).pop().unwrap_or_else(|| String::from("iwctl gave no reason")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* a known network's password, from iwd's profile: /var/lib/iwd is root's, so through
|
||||||
|
* doas. the file name comes from parse::iwd_profiles, never straight from the SSID
|
||||||
|
*/
|
||||||
|
pub fn saved_password(ssid: &str) -> Result<String, String> {
|
||||||
|
for profile in parse::iwd_profiles(ssid) {
|
||||||
|
let Ok(output) = Command::new("doas").args(["-n", "cat"]).arg(Path::new("/var/lib/iwd").join(&profile)).stdin(Stdio::null()).output() else {
|
||||||
|
return Err(String::from("doas couldn't start"));
|
||||||
|
};
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
return parse::psk_passphrase(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| String::from("Its profile holds no password"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// doas says so when it wants a password; a missing file is the next name to try
|
||||||
|
if String::from_utf8_lossy(&output.stderr).contains("doas:") && !String::from_utf8_lossy(&output.stderr).contains("cat:") {
|
||||||
|
return Err(String::from("doas wants a password, so it can't be read"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(String::from("No saved password for it"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* what the Ethernet popup lists about a wired card: (label, shown value, what Copy puts
|
||||||
|
* on the clipboard). `ip` for addresses and routes, sysfs for the rest; blocks, so off
|
||||||
|
* the drawing thread
|
||||||
|
*/
|
||||||
|
pub fn wired_details(card: &str) -> Vec<(String, String, Option<String>)> {
|
||||||
|
let sys = Path::new("/sys/class/net").join(card);
|
||||||
|
let ip = |arguments: &[&str]| Command::new("ip").args(arguments).stdin(Stdio::null()).output().map(|output| String::from_utf8_lossy(&output.stdout).into_owned()).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut rows: Vec<(String, String, Option<String>)> = Vec::new();
|
||||||
|
let mut row = |label: &str, value: String, copy: Option<String>| rows.push((label.to_string(), value, copy));
|
||||||
|
|
||||||
|
let driver = fs::read_link(sys.join("device/driver")).ok().and_then(|path| path.file_name().map(|name| name.to_string_lossy().into_owned()));
|
||||||
|
row("Interface", driver.map_or(card.to_string(), |driver| format!("{card} · {driver}")), None);
|
||||||
|
|
||||||
|
let connected = read(&sys.join("carrier")) == "1";
|
||||||
|
let speed = read(&sys.join("speed")).parse::<u32>().ok().filter(|speed| connected && *speed > 0);
|
||||||
|
let duplex = read(&sys.join("duplex"));
|
||||||
|
|
||||||
|
let status = match speed {
|
||||||
|
Some(speed) if !duplex.is_empty() => format!("Connected · {} · {duplex} duplex", parse::speed_words(speed)),
|
||||||
|
Some(speed) => format!("Connected · {}", parse::speed_words(speed)),
|
||||||
|
None if connected => String::from("Connected"),
|
||||||
|
None => String::from("No cable"),
|
||||||
|
};
|
||||||
|
row("Status", status, None);
|
||||||
|
|
||||||
|
// addresses as shown with their prefix, copied without it; link-local IPv6 last
|
||||||
|
let mut addresses = parse::ip_addresses(&ip(&["-o", "addr", "show", "dev", card]));
|
||||||
|
addresses.sort_by_key(|(_, family, scope)| (*family != "IPv4", scope != "global"));
|
||||||
|
|
||||||
|
for (address, family, scope) in addresses {
|
||||||
|
let label = if scope == "link" { "Link-local" } else { family };
|
||||||
|
let bare = address.split('/').next().unwrap_or(&address).to_string();
|
||||||
|
|
||||||
|
row(label, address, Some(bare));
|
||||||
|
}
|
||||||
|
|
||||||
|
let gateways = parse::ip_gateways(&ip(&["-o", "route", "show", "default", "dev", card]));
|
||||||
|
|
||||||
|
if !gateways.is_empty() {
|
||||||
|
row("Gateway", gateways.join(", "), Some(gateways.join(" ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let servers = parse::nameservers(&read(Path::new("/etc/resolv.conf")));
|
||||||
|
|
||||||
|
if !servers.is_empty() {
|
||||||
|
row("DNS", servers.join(", "), Some(servers.join(" ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mac = read(&sys.join("address"));
|
||||||
|
row("MAC", mac.clone(), Some(mac));
|
||||||
|
|
||||||
|
row("MTU", read(&sys.join("mtu")), None);
|
||||||
|
|
||||||
|
let bytes = |name: &str| read(&sys.join("statistics").join(name)).parse::<u64>().unwrap_or(0);
|
||||||
|
row("Traffic", format!("↓ {} ↑ {} since boot", parse::bytes_words(bytes("rx_bytes")), parse::bytes_words(bytes("tx_bytes"))), None);
|
||||||
|
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// leaves the network through iwctl, like joining
|
||||||
|
pub fn disconnect_wifi() {
|
||||||
|
let device = Links::read().wifi.device_name.clone();
|
||||||
|
|
||||||
|
if !device.is_empty() {
|
||||||
|
let _ = Command::new("iwctl").args(["station", &device, "disconnect"]).stdin(Stdio::null()).output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a fresh list for the Wi-Fi map, through iwctl; iwd's Scanning shows while it runs
|
||||||
|
pub fn scan_wifi() {
|
||||||
|
let wifi = Links::read().wifi.clone();
|
||||||
|
|
||||||
|
if wifi.service && wifi.powered && !wifi.access_point && !wifi.device_name.is_empty() {
|
||||||
|
let _ = Command::new("iwctl").args(["station", &wifi.device_name, "scan"]).stdin(Stdio::null()).output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// looks for devices for a while through bluetoothctl, whose discovery ends when it does
|
||||||
|
pub fn scan_bluetooth() {
|
||||||
|
let (available, powered) = {
|
||||||
|
let bluetooth = Bluetooth::read();
|
||||||
|
|
||||||
|
(bluetooth.available(), bluetooth.powered())
|
||||||
|
};
|
||||||
|
|
||||||
|
if available && powered {
|
||||||
|
let _ = Command::new("bluetoothctl").args(["--timeout", "15", "scan", "on"]).stdin(Stdio::null()).output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// back to how the desktop runs without Wi-Fi: iwd not running at all
|
||||||
|
pub fn stop_wifi_service() {
|
||||||
|
root(&["rc-service", "iwd", "stop"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_vpn(service: &str) {
|
||||||
|
if !parse::safe_name(service) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let running = Links::read().vpns.iter().any(|vpn| vpn.service == service && vpn.state != VpnState::Stopped);
|
||||||
|
|
||||||
|
root(&["rc-service", service, if running { "stop" } else { "start" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// bluez running: power the adapter on or off; installed but stopped: start its service
|
||||||
|
pub fn toggle_bluetooth() {
|
||||||
|
let (available, powered) = {
|
||||||
|
let bluetooth = Bluetooth::read();
|
||||||
|
|
||||||
|
(bluetooth.available(), bluetooth.powered())
|
||||||
|
};
|
||||||
|
|
||||||
|
if available {
|
||||||
|
Bluetooth::set_powered(!powered);
|
||||||
|
} else if Links::read().bluetooth_service {
|
||||||
|
root(&["rc-service", "bluetooth", "start"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// power
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Default)]
|
||||||
|
pub struct Charge {
|
||||||
|
pub percent: u8,
|
||||||
|
|
||||||
|
// Charging, Discharging, Not charging, Full
|
||||||
|
pub status: String,
|
||||||
|
|
||||||
|
// until empty or full
|
||||||
|
pub minutes: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Default)]
|
||||||
|
pub struct Light {
|
||||||
|
pub name: String,
|
||||||
|
pub level: u32,
|
||||||
|
pub max: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Default)]
|
||||||
|
pub struct Power {
|
||||||
|
// none without a laptop battery; a mouse's doesn't count
|
||||||
|
pub battery: Option<Charge>,
|
||||||
|
|
||||||
|
// the firmware's profile and the ones it offers, like low-power balanced performance
|
||||||
|
pub profile: String,
|
||||||
|
pub profiles: Vec<String>,
|
||||||
|
|
||||||
|
pub keyboard: Option<Light>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Power {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listen() {
|
||||||
|
loop {
|
||||||
|
refresh_power();
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(polling::power_ms()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_power() {
|
||||||
|
let power = Power {
|
||||||
|
battery: charge(),
|
||||||
|
profile: read(Path::new("/sys/firmware/acpi/platform_profile")),
|
||||||
|
profiles: read(Path::new("/sys/firmware/acpi/platform_profile_choices")).split_whitespace().map(String::from).collect(),
|
||||||
|
keyboard: keyboard(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let stale = *Power::read() != power;
|
||||||
|
|
||||||
|
if stale {
|
||||||
|
*Power::write() = power;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// when the time left was last worked out: the power draw jumps from poll to poll, so it's kept for half a minute
|
||||||
|
static ESTIMATE: Mutex<Option<(Instant, String, Option<u32>)>> = Mutex::new(None);
|
||||||
|
|
||||||
|
fn charge() -> Option<Charge> {
|
||||||
|
let folder = fs::read_dir("/sys/class/power_supply").ok()?.flatten().map(|entry| entry.path()).find(|path| {
|
||||||
|
read(&path.join("type")) == "Battery" && read(&path.join("scope")) != "Device"
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let number = |name: &str| read(&folder.join(name)).parse::<u64>().unwrap_or(0);
|
||||||
|
|
||||||
|
let status = read(&folder.join("status"));
|
||||||
|
|
||||||
|
// energy (µWh and µW) on most laptops, charge (µAh and µA) on some
|
||||||
|
let (now, full, rate) = if folder.join("energy_now").exists() {
|
||||||
|
(number("energy_now"), number("energy_full"), number("power_now"))
|
||||||
|
} else {
|
||||||
|
(number("charge_now"), number("charge_full"), number("current_now"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut estimate = ESTIMATE.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
|
||||||
|
let minutes = match &*estimate {
|
||||||
|
Some((at, was, minutes)) if *was == status && at.elapsed() < Duration::from_secs(polling::battery_estimate_s()) => *minutes,
|
||||||
|
_ => {
|
||||||
|
let minutes = parse::battery_minutes(&status, now, full, rate);
|
||||||
|
|
||||||
|
*estimate = Some((Instant::now(), status.clone(), minutes));
|
||||||
|
|
||||||
|
minutes
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Charge { percent: number("capacity").min(100) as u8, status, minutes })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyboard() -> Option<Light> {
|
||||||
|
let path = fs::read_dir("/sys/class/leds").ok()?.flatten().map(|entry| entry.path()).find(|path| {
|
||||||
|
path.file_name().and_then(|name| name.to_str()).is_some_and(|name| name.ends_with("kbd_backlight"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let number = |name: &str| read(&path.join(name)).parse::<u32>().unwrap_or(0);
|
||||||
|
|
||||||
|
Some(Light {
|
||||||
|
name: path.file_name()?.to_string_lossy().into_owned(),
|
||||||
|
level: number("brightness"),
|
||||||
|
max: number("max_brightness"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the firmware profile (fans, power limits) and the cpu's governor and energy preference
|
||||||
|
* with it, so a mode means the same everywhere. fixed scripts only, run as root
|
||||||
|
*/
|
||||||
|
pub fn set_profile(profile: &str) {
|
||||||
|
let script = match profile {
|
||||||
|
"low-power" => {
|
||||||
|
"echo low-power > /sys/firmware/acpi/platform_profile; for p in /sys/devices/system/cpu/cpufreq/policy*; do echo powersave > $p/scaling_governor; echo power > $p/energy_performance_preference; done"
|
||||||
|
}
|
||||||
|
"balanced" => {
|
||||||
|
"echo balanced > /sys/firmware/acpi/platform_profile; for p in /sys/devices/system/cpu/cpufreq/policy*; do echo powersave > $p/scaling_governor; echo balance_performance > $p/energy_performance_preference; done"
|
||||||
|
}
|
||||||
|
"performance" => {
|
||||||
|
"echo performance > /sys/firmware/acpi/platform_profile; for p in /sys/devices/system/cpu/cpufreq/policy*; do echo performance > $p/scaling_governor; done"
|
||||||
|
}
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
root(&["sh", "-c", script]);
|
||||||
|
|
||||||
|
refresh_power();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the light's file directly when the user may write it (a udev rule makes it 0666 on the
|
||||||
|
* desktop), else logind, which only does it for a session with a seat: a shell started
|
||||||
|
* over ssh has none, which is why logind alone failed
|
||||||
|
*/
|
||||||
|
pub fn set_keyboard(level: u32) {
|
||||||
|
let Some(light) = Power::read().keyboard.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let level = level.min(light.max);
|
||||||
|
|
||||||
|
let file = Path::new("/sys/class/leds").join(&light.name).join("brightness");
|
||||||
|
|
||||||
|
if fs::write(&file, level.to_string()).is_err() {
|
||||||
|
logind_brightness("leds", &light.name, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_power();
|
||||||
|
}
|
||||||
|
|
||||||
|
// the laptop screen the same way, at 1% or more; amane's Brightness reads it back within half a second
|
||||||
|
pub fn set_screen(percent: u8) {
|
||||||
|
let Some(folder) = fs::read_dir("/sys/class/backlight").ok().and_then(|mut entries| entries.next()).and_then(Result::ok).map(|entry| entry.path()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let highest: u64 = read(&folder.join("max_brightness")).parse().unwrap_or(0);
|
||||||
|
let level = highest * u64::from(percent.clamp(1, 100)) / 100;
|
||||||
|
|
||||||
|
if fs::write(folder.join("brightness"), level.to_string()).is_err() {
|
||||||
|
let name = folder.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
|
||||||
|
|
||||||
|
logind_brightness("backlight", &name, level as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logind_brightness(subsystem: &str, name: &str, level: u32) {
|
||||||
|
let arguments = [Argument::from(subsystem), Argument::from(name), Argument::from(level)];
|
||||||
|
|
||||||
|
Bus::system().call("org.freedesktop.login1", "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetBrightness", &arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the external gpu
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct Program {
|
||||||
|
pub name: String,
|
||||||
|
pub vram_kib: u64,
|
||||||
|
|
||||||
|
// its share of the gpu's time since the last look; none when the driver doesn't tell
|
||||||
|
pub busy: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Default)]
|
||||||
|
pub struct Gpu {
|
||||||
|
// empty: no external gpu
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
pub busy: u8,
|
||||||
|
pub vram_used: u64,
|
||||||
|
pub vram_total: u64,
|
||||||
|
pub celsius: Option<u32>,
|
||||||
|
pub watts: Option<f32>,
|
||||||
|
|
||||||
|
// the most video memory first
|
||||||
|
pub programs: Vec<Program>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Gpu {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 crate::pods::open_card() != Some(crate::pods::Pod::Monitor) {
|
||||||
|
thread::sleep(Duration::from_millis(polling::gpu_wait_ms()));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gpu = external(&mut names, &mut engines);
|
||||||
|
|
||||||
|
let stale = *Self::read() != gpu;
|
||||||
|
|
||||||
|
if stale {
|
||||||
|
*Self::write() = gpu;
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(polling::gpu_ms()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the first gpu the machine didn't boot with: on this laptop the one in the thunderbolt enclosure
|
||||||
|
fn external(names: &mut HashMap<String, String>, engines: &mut HashMap<u32, (u64, Instant)>) -> Gpu {
|
||||||
|
let Ok(entries) = fs::read_dir("/sys/class/drm") else {
|
||||||
|
return Gpu::default();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cards: Vec<PathBuf> = entries
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.file_name().and_then(|name| name.to_str()).and_then(|name| name.strip_prefix("card")).is_some_and(|number| number.parse::<u32>().is_ok()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
cards.sort();
|
||||||
|
|
||||||
|
let Some(device) = cards.into_iter().map(|card| card.join("device")).find(|device| read(&device.join("boot_vga")) == "0") else {
|
||||||
|
return Gpu::default();
|
||||||
|
};
|
||||||
|
|
||||||
|
// the pci address, like 0000:27:00.0
|
||||||
|
let address = fs::read_link(&device).ok().and_then(|link| link.file_name().map(|name| name.to_string_lossy().into_owned())).unwrap_or_default();
|
||||||
|
|
||||||
|
let name = names
|
||||||
|
.entry(address.clone())
|
||||||
|
.or_insert_with(|| {
|
||||||
|
let lspci = Command::new("lspci").args(["-mm", "-s", &address]).output().map(|output| String::from_utf8_lossy(&output.stdout).into_owned()).unwrap_or_default();
|
||||||
|
|
||||||
|
let name = parse::gpu_name(&lspci);
|
||||||
|
|
||||||
|
if name.is_empty() { String::from("External GPU") } else { name }
|
||||||
|
})
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let number = |file: &str| read(&device.join(file)).parse::<u64>().ok();
|
||||||
|
|
||||||
|
let hwmon = fs::read_dir(device.join("hwmon")).ok().and_then(|mut entries| entries.next()).and_then(Result::ok).map(|entry| entry.path());
|
||||||
|
|
||||||
|
let sensor = |file: &str| hwmon.as_ref().and_then(|folder| read(&folder.join(file)).parse::<u64>().ok());
|
||||||
|
|
||||||
|
Gpu {
|
||||||
|
name,
|
||||||
|
busy: number("gpu_busy_percent").unwrap_or(0).min(100) as u8,
|
||||||
|
vram_used: number("mem_info_vram_used").unwrap_or(0),
|
||||||
|
vram_total: number("mem_info_vram_total").unwrap_or(0),
|
||||||
|
celsius: sensor("temp1_input").map(|millidegrees| (millidegrees / 1000) as u32),
|
||||||
|
watts: sensor("power1_average").or_else(|| sensor("power1_input")).map(|microwatts| microwatts as f32 / 1_000_000.0),
|
||||||
|
programs: programs(&address, engines),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* every program with the gpu open, by name: its video memory, and its share of engine
|
||||||
|
* time since the last look. other users' processes can't be read, and needn't be
|
||||||
|
*/
|
||||||
|
fn programs(address: &str, engines: &mut HashMap<u32, (u64, Instant)>) -> Vec<Program> {
|
||||||
|
let Ok(processes) = fs::read_dir("/proc") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut found: BTreeMap<String, (u64, Option<f64>)> = BTreeMap::new();
|
||||||
|
let mut alive = BTreeSet::new();
|
||||||
|
|
||||||
|
for process in processes.flatten() {
|
||||||
|
let Some(pid) = process.file_name().to_str().and_then(|name| name.parse::<u32>().ok()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(files) = fs::read_dir(process.path().join("fd")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut clients = BTreeSet::new();
|
||||||
|
let mut vram = 0;
|
||||||
|
let mut engine: Option<u64> = None;
|
||||||
|
|
||||||
|
for file in files.flatten() {
|
||||||
|
let Ok(target) = fs::read_link(file.path()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !target.starts_with("/dev/dri") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(info) = fs::read_to_string(process.path().join("fdinfo").join(file.file_name())) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// one client shows up under every file that shares it
|
||||||
|
let Some(client) = parse::drm_client(&info).filter(|client| client.pdev == address) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !clients.insert(client.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
vram += client.vram_kib;
|
||||||
|
|
||||||
|
if let Some(nanoseconds) = client.engine_ns {
|
||||||
|
engine = Some(engine.unwrap_or(0) + nanoseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if clients.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
alive.insert(pid);
|
||||||
|
|
||||||
|
let busy = engine.and_then(|nanoseconds| {
|
||||||
|
let before = engines.insert(pid, (nanoseconds, Instant::now()));
|
||||||
|
|
||||||
|
before.map(|(was, at)| nanoseconds.saturating_sub(was) as f64 / at.elapsed().as_nanos().max(1) as f64 * 100.0)
|
||||||
|
});
|
||||||
|
|
||||||
|
let entry = found.entry(read(&process.path().join("comm"))).or_insert((0, None));
|
||||||
|
|
||||||
|
entry.0 += vram;
|
||||||
|
|
||||||
|
if let Some(busy) = busy {
|
||||||
|
entry.1 = Some(entry.1.unwrap_or(0.0) + busy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
engines.retain(|pid, _| alive.contains(pid));
|
||||||
|
|
||||||
|
let mut programs: Vec<Program> = found
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, (vram_kib, busy))| Program { name, vram_kib, busy: busy.map(|busy| busy.round().clamp(0.0, 100.0) as u8) })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
programs.sort_by(|one, other| other.vram_kib.cmp(&one.vram_kib).then_with(|| one.name.cmp(&other.name)));
|
||||||
|
|
||||||
|
programs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(path: &Path) -> String {
|
||||||
|
fs::read_to_string(path).unwrap_or_default().trim().to_string()
|
||||||
|
}
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
use std::cell::Cell;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Mutex, PoisonError};
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::{env, fs, thread};
|
||||||
|
|
||||||
|
use amane::{Animation, Blend, Color, Service};
|
||||||
|
|
||||||
|
use crate::{config, parse};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the shell's colours. they come from the wallpaper: skwd-wall runs matugen with
|
||||||
|
* matugen/amane.colors, which writes ~/.config/amane/theme. config.toml (config.rs) says
|
||||||
|
* whether the open media card takes the song cover's colours instead, how long a change
|
||||||
|
* cross-fades, and the colours used until there is a theme file. Theme also stands for
|
||||||
|
* config.toml: every view reads it, so a saved setting redraws the whole shell
|
||||||
|
*/
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub struct Colors {
|
||||||
|
pub accent: Color,
|
||||||
|
pub on_accent: Color,
|
||||||
|
pub accent_soft: Color,
|
||||||
|
pub flame: Color,
|
||||||
|
pub text: Color,
|
||||||
|
pub dim: Color,
|
||||||
|
pub outline: Color,
|
||||||
|
pub surface: Color,
|
||||||
|
pub surface_high: Color,
|
||||||
|
pub surface_low: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
// the colours without a theme file, from config.toml
|
||||||
|
fn fallback() -> Colors {
|
||||||
|
use config::appearance::fallback as colors;
|
||||||
|
|
||||||
|
Colors {
|
||||||
|
accent: colors::accent(),
|
||||||
|
on_accent: colors::on_accent(),
|
||||||
|
accent_soft: colors::accent_soft(),
|
||||||
|
flame: colors::flame(),
|
||||||
|
text: colors::text(),
|
||||||
|
dim: colors::dim(),
|
||||||
|
outline: colors::outline(),
|
||||||
|
surface: colors::surface(),
|
||||||
|
surface_high: colors::surface_high(),
|
||||||
|
surface_low: colors::surface_low(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Colors {
|
||||||
|
// the background gradient's three stops, light to dark
|
||||||
|
pub fn surfaces(&self) -> [Color; 3] {
|
||||||
|
[self.surface_high, self.surface, self.surface_low]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// so the colours can ride one Animation, all ten fading together
|
||||||
|
impl Blend for Colors {
|
||||||
|
fn blend(from: Self, to: Self, amount: f32) -> Self {
|
||||||
|
let mix = |from: Color, to: Color| Color::blend(from, to, amount);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
accent: mix(from.accent, to.accent),
|
||||||
|
on_accent: mix(from.on_accent, to.on_accent),
|
||||||
|
accent_soft: mix(from.accent_soft, to.accent_soft),
|
||||||
|
flame: mix(from.flame, to.flame),
|
||||||
|
text: mix(from.text, to.text),
|
||||||
|
dim: mix(from.dim, to.dim),
|
||||||
|
outline: mix(from.outline, to.outline),
|
||||||
|
surface: mix(from.surface, to.surface),
|
||||||
|
surface_high: mix(from.surface_high, to.surface_high),
|
||||||
|
surface_low: mix(from.surface_low, to.surface_low),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Theme {
|
||||||
|
colors: Animation<Colors>,
|
||||||
|
// where the colours are going; a reread that lands on them again starts no fade
|
||||||
|
target: Colors,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Theme {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { colors: Animation::new(fallback()), target: fallback() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* waits on the kernel instead of polling: amane::watch_file watches the folder, so
|
||||||
|
* matugen writing a new file and renaming it over the old one is seen too.
|
||||||
|
* config.toml on its own thread, the theme here. a changed config.toml redraws
|
||||||
|
* everything; the colours fade only if it changed them (its fallback colours)
|
||||||
|
*/
|
||||||
|
fn listen() {
|
||||||
|
reload(true);
|
||||||
|
|
||||||
|
thread::spawn(|| {
|
||||||
|
for _ in amane::watch_file(&config_file("config.toml").to_string_lossy()) {
|
||||||
|
// one save can arrive as several writes: let it finish, then read once
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
if config::reread() {
|
||||||
|
touch();
|
||||||
|
reload(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for _ in amane::watch_file(&config_file("theme").to_string_lossy()) {
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
reload(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redraws every view, for a setting changed in the config window
|
||||||
|
pub fn touch() {
|
||||||
|
let _ = Theme::write();
|
||||||
|
}
|
||||||
|
|
||||||
|
// the theme file as read last; none until the first read
|
||||||
|
static LAST: Mutex<Option<String>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the theme file, read anew; `again` also when it's unchanged, for new fallback colours.
|
||||||
|
* a deleted theme file sends no event: the defaults come back at the next start
|
||||||
|
*/
|
||||||
|
fn reload(again: bool) {
|
||||||
|
let theme = fs::read_to_string(config_file("theme")).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut last = LAST.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
|
||||||
|
if last.as_ref() == Some(&theme) && !again {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the colours at startup show at once, later changes cross-fade
|
||||||
|
apply(&theme, last.is_none());
|
||||||
|
|
||||||
|
*last = Some(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $XDG_CONFIG_HOME, or ~/.config
|
||||||
|
pub fn config_home() -> PathBuf {
|
||||||
|
env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~/.config/amane/<name>, or under $XDG_CONFIG_HOME
|
||||||
|
pub fn config_file(name: &str) -> PathBuf {
|
||||||
|
config_home().join("amane").join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(theme: &str, startup: bool) {
|
||||||
|
let mut colors = fallback();
|
||||||
|
|
||||||
|
// a missing or broken line keeps that colour's default
|
||||||
|
for (key, value) in parse::settings(theme) {
|
||||||
|
let Some([red, green, blue]) = parse::rgb(&value) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let color = Color::rgb(red, green, blue);
|
||||||
|
|
||||||
|
match key.as_str() {
|
||||||
|
"accent" => colors.accent = color,
|
||||||
|
"on_accent" => colors.on_accent = color,
|
||||||
|
"accent_soft" => colors.accent_soft = color,
|
||||||
|
"flame" => colors.flame = color,
|
||||||
|
"text" => colors.text = color,
|
||||||
|
"dim" => colors.dim = color,
|
||||||
|
"outline" => colors.outline = color,
|
||||||
|
"surface" => colors.surface = color,
|
||||||
|
"surface_high" => colors.surface_high = color,
|
||||||
|
"surface_low" => colors.surface_low = color,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !startup && Theme::read().target == colors {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fade = if startup { 0 } else { config::appearance::colors::transition_ms() };
|
||||||
|
|
||||||
|
let mut theme = Theme::write();
|
||||||
|
|
||||||
|
theme.target = colors;
|
||||||
|
|
||||||
|
// a change in the middle of a fade starts from the colours on screen now
|
||||||
|
let now = theme.colors.value();
|
||||||
|
|
||||||
|
theme.colors = Animation::new(now).duration(Duration::from_millis(fade));
|
||||||
|
theme.colors.to(colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the colours of the frame being drawn, set by the view that draws it
|
||||||
|
thread_local! {
|
||||||
|
static NOW: Cell<Colors> = Cell::new(fallback());
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* a view's first line: everything it draws this frame uses these colours (and
|
||||||
|
* reading Theme redraws it when they or config.toml change). says whether the media
|
||||||
|
* card takes the cover's
|
||||||
|
*/
|
||||||
|
pub fn begin() -> bool {
|
||||||
|
let theme = Theme::read();
|
||||||
|
|
||||||
|
NOW.set(theme.colors.value());
|
||||||
|
|
||||||
|
config::appearance::colors::cover_colors()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now() -> Colors {
|
||||||
|
NOW.get()
|
||||||
|
}
|
||||||
+883
@@ -0,0 +1,883 @@
|
|||||||
|
/*
|
||||||
|
* the system tray, right of the workspaces. tray apps (StatusNotifierItem) register
|
||||||
|
* with a watcher on the session bus; nothing on this desktop runs one, so the shell
|
||||||
|
* owns org.kde.StatusNotifierWatcher and serves it, and is its own host. each app's
|
||||||
|
* icon comes from its IconName (looked up in the icon themes) or its IconPixmap
|
||||||
|
* (written out as a png, see png.rs). a left click activates the app (or opens its
|
||||||
|
* menu if that's all it has), a right click opens its menu (com.canonical.dbusmenu)
|
||||||
|
* drawn here in the island's look, a middle click is the app's secondary action, a
|
||||||
|
* scroll its scroll. everything on the bus happens on threads, never while drawing
|
||||||
|
*/
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, PoisonError, mpsc};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
use std::{env, fs, thread};
|
||||||
|
|
||||||
|
use amane::{
|
||||||
|
Animation, Argument, Blend, Bus, Button, Center, Color, Column, Full, Horizontal, Image, InputArea, Layer,
|
||||||
|
LayerWindow, Margin, Monitor, Padding, Parent, Pointer, Rectangle, Row, Scroll, Service, SpaceBetween,
|
||||||
|
Start, Text, Value, Vertical, Weight, Widget, Zone,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::config::appearance::{motion, overlays, text};
|
||||||
|
use crate::config::island::pill as island_pill;
|
||||||
|
use crate::config::tray::{menu, pill};
|
||||||
|
use crate::glyph::{Glyph, icon};
|
||||||
|
use crate::island::{CLEAR, faded, veiled};
|
||||||
|
use crate::{edge, parse, png, surface, theme};
|
||||||
|
|
||||||
|
const WATCHER: &str = "org.kde.StatusNotifierWatcher";
|
||||||
|
const WATCHER_PATH: &str = "/StatusNotifierWatcher";
|
||||||
|
const ITEM: &str = "org.kde.StatusNotifierItem";
|
||||||
|
const MENU: &str = "com.canonical.dbusmenu";
|
||||||
|
const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
|
||||||
|
|
||||||
|
// the tray's pill (a slot per icon, like the workspaces' slots) and its menu are settings (config.rs: tray.*)
|
||||||
|
|
||||||
|
const INTROSPECTION: &str = r#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
|
||||||
|
<node>
|
||||||
|
<interface name="org.kde.StatusNotifierWatcher">
|
||||||
|
<method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method>
|
||||||
|
<method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method>
|
||||||
|
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
|
||||||
|
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
|
||||||
|
<property name="ProtocolVersion" type="i" access="read"/>
|
||||||
|
<signal name="StatusNotifierItemRegistered"><arg type="s"/></signal>
|
||||||
|
<signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal>
|
||||||
|
<signal name="StatusNotifierHostRegistered"/>
|
||||||
|
</interface>
|
||||||
|
</node>"#;
|
||||||
|
|
||||||
|
// one app in the tray
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct Item {
|
||||||
|
// what RegisteredStatusNotifierItems lists: its bus name and object path, together
|
||||||
|
pub key: String,
|
||||||
|
bus: String,
|
||||||
|
path: String,
|
||||||
|
|
||||||
|
// its unique name (":1.42"), which its signals come from
|
||||||
|
owner: String,
|
||||||
|
|
||||||
|
pub title: String,
|
||||||
|
pub icon: Option<PathBuf>,
|
||||||
|
|
||||||
|
// NeedsAttention: a ring around it; Passive: not shown
|
||||||
|
pub attention: bool,
|
||||||
|
passive: bool,
|
||||||
|
|
||||||
|
// its dbusmenu's object path, and whether a click should open that rather than activate
|
||||||
|
menu: String,
|
||||||
|
only_menu: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Tray {
|
||||||
|
pub items: Vec<Item>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for Tray {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listen() {
|
||||||
|
let bus = Bus::session();
|
||||||
|
|
||||||
|
// the calls are watched before the name is taken, so none is missed
|
||||||
|
let calls = bus.methods(WATCHER_PATH, WATCHER);
|
||||||
|
let properties = bus.methods(WATCHER_PATH, PROPERTIES);
|
||||||
|
let introspection = bus.methods(WATCHER_PATH, "org.freedesktop.DBus.Introspectable");
|
||||||
|
|
||||||
|
// another watcher runs (another bar): leave it be, and show nothing
|
||||||
|
if !bus.own(WATCHER) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierHostRegistered", &[]);
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
for call in properties {
|
||||||
|
let value = |name: &str| match name {
|
||||||
|
"RegisteredStatusNotifierItems" => Some(Argument::TextList(Tray::read().items.iter().map(|item| item.key.clone()).collect())),
|
||||||
|
"IsStatusNotifierHostRegistered" => Some(Argument::Bool(true)),
|
||||||
|
"ProtocolVersion" => Some(Argument::Int(0)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match call.name() {
|
||||||
|
"Get" => {
|
||||||
|
let name = call.arguments().get(1).map(|name| name.text().to_string()).unwrap_or_default();
|
||||||
|
|
||||||
|
match value(&name) {
|
||||||
|
Some(value) => call.reply(&[Argument::Variant(Box::new(value))]),
|
||||||
|
None => call.reply(&[]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"GetAll" => {
|
||||||
|
let all = ["RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|name| Some((name.to_string(), value(name)?)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
call.reply(&[Argument::Map(all)]);
|
||||||
|
}
|
||||||
|
_ => call.reply(&[]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
for call in introspection {
|
||||||
|
call.reply(&[Argument::from(INTROSPECTION)]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// an app that quits leaves the bus: its icon goes
|
||||||
|
thread::spawn(|| {
|
||||||
|
for signal in Bus::session().signals("org.freedesktop.DBus", "NameOwnerChanged") {
|
||||||
|
let arguments = signal.arguments();
|
||||||
|
let (name, gone) = (arguments.first().map_or("", Value::text), arguments.get(2).is_some_and(|owner| owner.text().is_empty()));
|
||||||
|
|
||||||
|
if gone && !name.is_empty() {
|
||||||
|
forget(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// an app that changed its icon, title or status says so
|
||||||
|
for change in ["NewIcon", "NewAttentionIcon", "NewStatus", "NewTitle", "NewToolTip", "NewMenu"] {
|
||||||
|
thread::spawn(move || {
|
||||||
|
for signal in Bus::session().signals(ITEM, change) {
|
||||||
|
let stale: Vec<Item> = Tray::read().items.iter().filter(|item| item.owner == signal.sender() && item.path == signal.path()).cloned().collect();
|
||||||
|
|
||||||
|
for item in stale {
|
||||||
|
refresh(&item.bus, &item.path, &item.owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for call in calls {
|
||||||
|
let name = call.name().to_string();
|
||||||
|
let service = call.arguments().first().map(|service| service.text().to_string()).unwrap_or_default();
|
||||||
|
|
||||||
|
// answered at once: the app waits for it
|
||||||
|
call.reply(&[]);
|
||||||
|
|
||||||
|
if name == "RegisterStatusNotifierItem" && !service.is_empty() {
|
||||||
|
thread::spawn(move || register(&service));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* an app's registration: a bus name (its item at /StatusNotifierItem), or, from
|
||||||
|
* libappindicator, only the object path. amane doesn't say who called, so for a path
|
||||||
|
* the newest connections are asked which one has that item
|
||||||
|
*/
|
||||||
|
fn register(service: &str) {
|
||||||
|
let bus = Bus::session();
|
||||||
|
|
||||||
|
let (name, path) = if service.starts_with('/') {
|
||||||
|
let Some(owner) = find_owner(&bus, service) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
(owner, service.to_string())
|
||||||
|
} else {
|
||||||
|
match service.split_once('/') {
|
||||||
|
Some((name, path)) => (name.to_string(), format!("/{path}")),
|
||||||
|
None => (service.to_string(), String::from("/StatusNotifierItem")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let owner = if name.starts_with(':') {
|
||||||
|
name.clone()
|
||||||
|
} else {
|
||||||
|
bus.call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetNameOwner", &[Argument::from(name.as_str())]).text().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
if owner.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(&name, &path, &owner);
|
||||||
|
|
||||||
|
let key = format!("{name}{path}");
|
||||||
|
|
||||||
|
bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierItemRegistered", &[Argument::from(key)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the connection that serves an item at this path. asked all at once, first answer
|
||||||
|
* wins: some connections never answer a call they don't know (the shell's own among
|
||||||
|
* them), and each of those would hold a one-by-one search for the bus's whole timeout
|
||||||
|
*/
|
||||||
|
fn find_owner(bus: &Bus, path: &str) -> Option<String> {
|
||||||
|
let names = bus.call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ListNames", &[]);
|
||||||
|
let (found, answers) = mpsc::channel();
|
||||||
|
|
||||||
|
for name in names.list().iter().map(Value::text).filter(|name| name.starts_with(':')) {
|
||||||
|
let (name, path, found) = (name.to_string(), path.to_string(), found.clone());
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let answer = Bus::session().call(&name, &path, PROPERTIES, "Get", &[Argument::from(ITEM), Argument::from("Id")]);
|
||||||
|
|
||||||
|
if !matches!(answer, Value::Nothing) {
|
||||||
|
let _ = found.send(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(found);
|
||||||
|
|
||||||
|
answers.recv_timeout(Duration::from_secs(5)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// reads an item again and puts it in the tray, or takes it out if it doesn't answer
|
||||||
|
fn refresh(name: &str, path: &str, owner: &str) {
|
||||||
|
let key = format!("{name}{path}");
|
||||||
|
let all = Bus::session().call(name, path, PROPERTIES, "GetAll", &[Argument::from(ITEM)]);
|
||||||
|
|
||||||
|
if !matches!(all, Value::Map(_)) {
|
||||||
|
Tray::write().items.retain(|item| item.key != key);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = |property: &str| all.get(property).text().to_string();
|
||||||
|
let status = text("Status");
|
||||||
|
let attention = status == "NeedsAttention";
|
||||||
|
|
||||||
|
// the attention icon while it wants attention, else the icon; a name before pixels
|
||||||
|
let (name_property, pixmap_property) = if attention && !text("AttentionIconName").is_empty() { ("AttentionIconName", "AttentionIconPixmap") } else { ("IconName", "IconPixmap") };
|
||||||
|
|
||||||
|
let icon = icon_file(&text(name_property), &text("IconThemePath")).or_else(|| pixmap_file(all.get(pixmap_property)));
|
||||||
|
|
||||||
|
// the tooltip's title is often the clearest name
|
||||||
|
let tooltip = all.get("ToolTip").list().get(2).map(|title| title.text().to_string()).unwrap_or_default();
|
||||||
|
let title = [text("Title"), tooltip, text("Id")].into_iter().find(|title| !title.is_empty()).unwrap_or_default();
|
||||||
|
|
||||||
|
let item = Item {
|
||||||
|
key: key.clone(),
|
||||||
|
bus: name.to_string(),
|
||||||
|
path: path.to_string(),
|
||||||
|
owner: owner.to_string(),
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
attention,
|
||||||
|
passive: status == "Passive",
|
||||||
|
menu: text("Menu"),
|
||||||
|
only_menu: all.get("ItemIsMenu").bool(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tray = Tray::write();
|
||||||
|
|
||||||
|
match tray.items.iter_mut().find(|known| known.key == key) {
|
||||||
|
Some(known) if *known == item => {}
|
||||||
|
Some(known) => *known = item,
|
||||||
|
None => tray.items.push(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forget(name: &str) {
|
||||||
|
let gone: Vec<String> = Tray::read().items.iter().filter(|item| item.owner == name || item.bus == name).map(|item| item.key.clone()).collect();
|
||||||
|
|
||||||
|
if gone.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tray::write().items.retain(|item| !gone.contains(&item.key));
|
||||||
|
|
||||||
|
let bus = Bus::session();
|
||||||
|
|
||||||
|
for key in gone {
|
||||||
|
bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierItemUnregistered", &[Argument::from(key)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// icons
|
||||||
|
|
||||||
|
/*
|
||||||
|
* an icon name as a file: the app's own theme folder first, then the icon themes
|
||||||
|
* (gtk's choice, hicolor, Adwaita), then /usr/share/pixmaps. themes are walked once
|
||||||
|
* per name, the answer kept
|
||||||
|
*/
|
||||||
|
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() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.starts_with('/') {
|
||||||
|
return Path::new(name).exists().then(|| PathBuf::from(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("{theme_path}\u{1f}{name}");
|
||||||
|
|
||||||
|
if let Some(found) = FOUND.lock().unwrap_or_else(PoisonError::into_inner).get_or_insert_with(HashMap::new).get(&key) {
|
||||||
|
return found.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let home = env::var("HOME").unwrap_or_default();
|
||||||
|
let bases = [format!("{home}/.local/share/icons"), format!("{home}/.icons"), String::from("/usr/share/icons")];
|
||||||
|
|
||||||
|
let mut themes: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
let gtk = fs::read_to_string(format!("{home}/.config/gtk-3.0/settings.ini")).unwrap_or_default();
|
||||||
|
|
||||||
|
if let Some(chosen) = gtk.lines().find_map(|line| line.trim().strip_prefix("gtk-icon-theme-name")).map(|rest| rest.trim_start_matches([' ', '=']).trim().to_string()) {
|
||||||
|
themes.push(chosen);
|
||||||
|
}
|
||||||
|
|
||||||
|
themes.extend(["hicolor", "AdwaitaLegacy", "Adwaita"].map(String::from));
|
||||||
|
|
||||||
|
let mut roots: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
if !theme_path.is_empty() {
|
||||||
|
roots.push(PathBuf::from(theme_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
for theme in &themes {
|
||||||
|
for base in &bases {
|
||||||
|
roots.push(Path::new(base).join(theme));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut found = roots.iter().find_map(|root| best_in(root, name));
|
||||||
|
|
||||||
|
if found.is_none() {
|
||||||
|
found = ["png", "svg", "xpm"].iter().map(|extension| PathBuf::from(format!("/usr/share/pixmaps/{name}.{extension}"))).find(|path| path.exists() && !path.ends_with(".xpm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
FOUND.lock().unwrap_or_else(PoisonError::into_inner).get_or_insert_with(HashMap::new).insert(key, found.clone());
|
||||||
|
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
// the best-fitting file for this name anywhere under a theme folder (see parse::icon_score)
|
||||||
|
fn best_in(root: &Path, name: &str) -> Option<PathBuf> {
|
||||||
|
let mut best: Option<(u32, PathBuf)> = None;
|
||||||
|
let mut folders = vec![(root.to_path_buf(), 0)];
|
||||||
|
|
||||||
|
while let Some((folder, depth)) = folders.pop() {
|
||||||
|
let Ok(entries) = fs::read_dir(&folder) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
if depth < 3 {
|
||||||
|
folders.push((path, depth + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stem = path.file_stem().and_then(|stem| stem.to_str());
|
||||||
|
let extension = path.extension().and_then(|extension| extension.to_str());
|
||||||
|
|
||||||
|
if stem == Some(name) && matches!(extension, Some("png" | "svg")) {
|
||||||
|
let score = parse::icon_score(&path.to_string_lossy());
|
||||||
|
|
||||||
|
if best.as_ref().is_none_or(|(known, _)| score < *known) {
|
||||||
|
best = Some((score, path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
best.map(|(_, path)| path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* IconPixmap as a png: the size nearest 32 px of those it sends, in the runtime folder,
|
||||||
|
* named after its pixels so a new icon is a new path (amane keeps images by path)
|
||||||
|
*/
|
||||||
|
fn pixmap_file(pixmaps: &Value) -> Option<PathBuf> {
|
||||||
|
let chosen = pixmaps.list().iter().filter(|pixmap| pixmap.list().len() == 3).min_by_key(|pixmap| (pixmap.list()[0].number() as i64 - 32).abs())?;
|
||||||
|
|
||||||
|
let (width, height) = (chosen.list()[0].number() as u32, chosen.list()[1].number() as u32);
|
||||||
|
let argb: Vec<u8> = chosen.list()[2].list().iter().map(|byte| byte.number() as u8).collect();
|
||||||
|
|
||||||
|
if width == 0 || height == 0 || argb.len() != (width * height * 4) as usize {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fnv-1a over the pixels
|
||||||
|
let hash = argb.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3));
|
||||||
|
|
||||||
|
let folder = Path::new(&env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| String::from("/tmp"))).join("amane-tray");
|
||||||
|
let path = folder.join(format!("{hash:016x}.png"));
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
fs::create_dir_all(&folder).ok()?;
|
||||||
|
fs::write(&path, png::encode(width, height, &png::argb_to_rgba(&argb))).ok()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// what a click, a middle click or a scroll asks of an app; all on threads
|
||||||
|
|
||||||
|
fn activate(item: &Item, method: &'static str) {
|
||||||
|
let item = item.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
Bus::session().call(&item.bus, &item.path, ITEM, method, &[Argument::Int(0), Argument::Int(0)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll(item: &Item, delta: i32) {
|
||||||
|
let item = item.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
Bus::session().call(&item.bus, &item.path, ITEM, "Scroll", &[Argument::Int(delta), Argument::from("vertical")]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// the menu
|
||||||
|
|
||||||
|
// one entry of an app's menu
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
struct Entry {
|
||||||
|
id: i32,
|
||||||
|
label: String,
|
||||||
|
enabled: bool,
|
||||||
|
separator: bool,
|
||||||
|
|
||||||
|
// a checkmark or radio, and whether it's on
|
||||||
|
toggle: Option<bool>,
|
||||||
|
submenu: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Open {
|
||||||
|
item: Item,
|
||||||
|
monitor: String,
|
||||||
|
|
||||||
|
// the menu's left edge, from the monitor's left
|
||||||
|
x: f32,
|
||||||
|
|
||||||
|
// the submenus entered, with their labels, for the way back
|
||||||
|
trail: Vec<(i32, String)>,
|
||||||
|
entries: Vec<Entry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TrayUi {
|
||||||
|
// icons and menu rows under the pointer
|
||||||
|
hover: HashMap<String, Animation>,
|
||||||
|
|
||||||
|
open: Option<Open>,
|
||||||
|
appear: Animation,
|
||||||
|
inside: bool,
|
||||||
|
|
||||||
|
// bumped when a menu opens or the pointer comes back, so an older close timer does nothing
|
||||||
|
moves: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for TrayUi {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { hover: HashMap::new(), open: None, appear: Animation::new(0.0), inside: false, moves: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// changes only through input and the apps' answers
|
||||||
|
fn listen() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hovering(spot: String) -> impl Fn(bool) + 'static {
|
||||||
|
move |inside| {
|
||||||
|
TrayUi::write()
|
||||||
|
.hover
|
||||||
|
.entry(spot.clone())
|
||||||
|
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms())))
|
||||||
|
.to(if inside { 1.0 } else { 0.0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the app's menu (or the submenu `parent`): AboutToShow lets it fill in what it fills
|
||||||
|
* in late, GetLayout gives the entries. (u revision, (i id, a{sv} properties, av children))
|
||||||
|
*/
|
||||||
|
fn layout(item: &Item, parent: i32) -> Vec<Entry> {
|
||||||
|
let bus = Bus::session();
|
||||||
|
|
||||||
|
bus.call(&item.bus, &item.menu, MENU, "AboutToShow", &[Argument::Int(parent)]);
|
||||||
|
|
||||||
|
let answer = bus.call(&item.bus, &item.menu, MENU, "GetLayout", &[Argument::Int(parent), Argument::Int(1), Argument::TextList(Vec::new())]);
|
||||||
|
|
||||||
|
let root = answer.list().get(1);
|
||||||
|
let children = root.and_then(|root| root.list().get(2)).map_or(&[][..], Value::list);
|
||||||
|
|
||||||
|
children
|
||||||
|
.iter()
|
||||||
|
.filter_map(|child| {
|
||||||
|
let fields = child.list();
|
||||||
|
let properties = fields.get(1)?;
|
||||||
|
|
||||||
|
// missing properties have their defaults: visible, enabled, a plain entry
|
||||||
|
let visible = !matches!(properties.get("visible"), Value::Bool(false));
|
||||||
|
|
||||||
|
if !visible {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let toggle_type = properties.get("toggle-type").text();
|
||||||
|
|
||||||
|
Some(Entry {
|
||||||
|
id: fields.first()?.number() as i32,
|
||||||
|
label: parse::strip_mnemonic(properties.get("label").text()),
|
||||||
|
enabled: !matches!(properties.get("enabled"), Value::Bool(false)),
|
||||||
|
separator: properties.get("type").text() == "separator",
|
||||||
|
toggle: (!toggle_type.is_empty()).then(|| properties.get("toggle-state").number() == 1.0),
|
||||||
|
submenu: properties.get("children-display").text() == "submenu",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// opens the app's menu under its icon, once it has answered
|
||||||
|
fn open_menu(item: &Item, monitor: &str, x: f32) {
|
||||||
|
let (item, monitor) = (item.clone(), monitor.to_string());
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
if item.menu.is_empty() {
|
||||||
|
Bus::session().call(&item.bus, &item.path, ITEM, "ContextMenu", &[Argument::Int(0), Argument::Int(0)]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = layout(&item, 0);
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ui = TrayUi::write();
|
||||||
|
|
||||||
|
ui.open = Some(Open { item, monitor, x, trail: Vec::new(), entries });
|
||||||
|
ui.appear = Animation::new(0.0).duration(Duration::from_millis(menu::appear_ms()));
|
||||||
|
ui.appear.to(1.0);
|
||||||
|
ui.moves += 1;
|
||||||
|
ui.hover.retain(|spot, _| !spot.starts_with("row:"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// into a submenu, or back out of one (`id` none)
|
||||||
|
fn enter(id: Option<(i32, String)>) {
|
||||||
|
let Some(mut open) = TrayUi::read().open.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
match id {
|
||||||
|
Some(step) => open.trail.push(step),
|
||||||
|
None => {
|
||||||
|
open.trail.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = open.trail.last().map_or(0, |(id, _)| *id);
|
||||||
|
|
||||||
|
open.entries = layout(&open.item, parent);
|
||||||
|
|
||||||
|
let mut ui = TrayUi::write();
|
||||||
|
|
||||||
|
ui.open = Some(open);
|
||||||
|
ui.hover.retain(|spot, _| !spot.starts_with("row:"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// a click on an entry: the app hears it, the menu closes
|
||||||
|
fn choose(item: &Item, id: i32) {
|
||||||
|
let item = item.clone();
|
||||||
|
|
||||||
|
close_menu();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |time| time.as_secs() as u32);
|
||||||
|
|
||||||
|
Bus::session().call(&item.bus, &item.menu, MENU, "Event", &[Argument::Int(id), Argument::from("clicked"), Argument::Variant(Box::new(Argument::Int(0))), Argument::Unsigned(now)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_menu() {
|
||||||
|
let mut ui = TrayUi::write();
|
||||||
|
|
||||||
|
ui.open = None;
|
||||||
|
ui.inside = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the pointer left the menu: it closes unless it comes back within a moment
|
||||||
|
fn leave_soon() {
|
||||||
|
let moves = {
|
||||||
|
let mut ui = TrayUi::write();
|
||||||
|
|
||||||
|
ui.inside = false;
|
||||||
|
ui.moves += 1;
|
||||||
|
ui.moves
|
||||||
|
};
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_millis(menu::close_ms()));
|
||||||
|
|
||||||
|
let gone = {
|
||||||
|
let ui = TrayUi::read();
|
||||||
|
|
||||||
|
ui.moves == moves && !ui.inside
|
||||||
|
};
|
||||||
|
|
||||||
|
if gone {
|
||||||
|
close_menu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the tray's pill for the workspaces' window: (the pill, its width), nothing without
|
||||||
|
* icons. `left` is where it starts, from the monitor's left, for its menus
|
||||||
|
*/
|
||||||
|
pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
|
||||||
|
let tray = Tray::read();
|
||||||
|
let ui = TrayUi::read();
|
||||||
|
|
||||||
|
let shown: Vec<&Item> = tray.items.iter().filter(|item| !item.passive).collect();
|
||||||
|
|
||||||
|
if shown.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let theme = theme::now();
|
||||||
|
let count = shown.len() as f32;
|
||||||
|
let width = 2.0 * pill::inset() + count * pill::slot() + (count - 1.0) * pill::slot_gap();
|
||||||
|
|
||||||
|
let mut slots: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
for (index, item) in shown.iter().enumerate() {
|
||||||
|
let spot = format!("icon:{}", item.key);
|
||||||
|
let lit = ui.hover.get(&spot).map_or(0.0, Animation::value);
|
||||||
|
let open = ui.open.as_ref().is_some_and(|open| open.item.key == item.key && open.monitor == monitor);
|
||||||
|
|
||||||
|
let picture = match &item.icon {
|
||||||
|
Some(path) => Rectangle::new().width(pill::icon()).height(pill::icon()).fill(Image::contain(path.clone())),
|
||||||
|
// no icon to find: its first letter
|
||||||
|
None => Rectangle::new()
|
||||||
|
.width(pill::icon())
|
||||||
|
.height(pill::icon())
|
||||||
|
.align_child(Center, Center)
|
||||||
|
.child(Text::new(item.title.chars().next().map_or(String::from("?"), |letter| letter.to_uppercase().collect())).size(text::body()).weight(Weight::Bold).color(theme.text)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let slot = Rectangle::new()
|
||||||
|
.width(pill::slot())
|
||||||
|
.height(pill::slot())
|
||||||
|
.radius(Full)
|
||||||
|
.fill(if open { faded(theme.accent, 90) } else { Color::blend(CLEAR, overlays::chip_hover(), lit) })
|
||||||
|
.scale(1.0 + 0.08 * lit)
|
||||||
|
.align_child(Center, Center)
|
||||||
|
.cursor(Pointer)
|
||||||
|
.on_hover(hovering(spot));
|
||||||
|
|
||||||
|
let slot = if item.attention { slot.border(1.5, theme.accent) } else { slot };
|
||||||
|
|
||||||
|
let x = left + pill::inset() + index as f32 * (pill::slot() + pill::slot_gap());
|
||||||
|
let (clicked, scrolled, monitor) = ((*item).clone(), (*item).clone(), monitor.to_string());
|
||||||
|
|
||||||
|
let slot = slot
|
||||||
|
.on_click(move |button| match button {
|
||||||
|
Button::Left if clicked.only_menu => open_menu(&clicked, &monitor, x),
|
||||||
|
Button::Left => activate(&clicked, "Activate"),
|
||||||
|
Button::Middle => activate(&clicked, "SecondaryActivate"),
|
||||||
|
Button::Right => open_menu(&clicked, &monitor, x),
|
||||||
|
})
|
||||||
|
.on_scroll(move |step: Scroll| scroll(&scrolled, if step.y > 0.0 { 1 } else { -1 }))
|
||||||
|
.child(picture);
|
||||||
|
|
||||||
|
slots.push(Box::new(slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = Rectangle::new().width(width).height(island_pill::height()).padding(pill::inset()).align_child(Start, Center).child(Row::new(slots).gap(pill::slot_gap()).align(Center));
|
||||||
|
|
||||||
|
let pill = surface::shell(width, island_pill::height(), island_pill::height() / 2.0, &theme.surfaces(), 0.0, content).on_hover(edge::over_bar);
|
||||||
|
|
||||||
|
Some((pill, width))
|
||||||
|
}
|
||||||
|
|
||||||
|
// an app's menu, under its icon on the monitor it was opened on
|
||||||
|
pub fn menu_view(monitor: &Monitor) -> LayerWindow {
|
||||||
|
theme::begin();
|
||||||
|
|
||||||
|
let ui = TrayUi::read();
|
||||||
|
|
||||||
|
let window = |width: f32, height: f32| {
|
||||||
|
LayerWindow::new()
|
||||||
|
.width(width)
|
||||||
|
.height(height)
|
||||||
|
.anchor_vertical(Vertical::Top)
|
||||||
|
.anchor_horizontal(Horizontal::Left)
|
||||||
|
.layer(Layer::Overlay)
|
||||||
|
.space(Zone::Ignore)
|
||||||
|
.namespace("magic-tray-menu")
|
||||||
|
};
|
||||||
|
|
||||||
|
// never hidden (see the island's view): a tiny, empty click-through window instead
|
||||||
|
let Some(open) = ui.open.as_ref().filter(|open| open.monitor == monitor.name) else {
|
||||||
|
return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||||
|
};
|
||||||
|
|
||||||
|
let theme = theme::now();
|
||||||
|
let colors = theme.surfaces();
|
||||||
|
let inner_w = menu::width() - 2.0 * menu::pad();
|
||||||
|
|
||||||
|
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
let mut height = 2.0 * menu::pad();
|
||||||
|
|
||||||
|
// the app's name, or the submenu's with the way back
|
||||||
|
let lit_back = ui.hover.get("row:back").map_or(0.0, Animation::value);
|
||||||
|
|
||||||
|
let heading: Box<dyn Widget> = match open.trail.last() {
|
||||||
|
Some((_, label)) => Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(inner_w)
|
||||||
|
.height(menu::row_height())
|
||||||
|
.radius(9.0)
|
||||||
|
.fill(Color::blend(CLEAR, overlays::chip_hover(), lit_back))
|
||||||
|
.padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 6.0 })
|
||||||
|
.align_child(Start, Center)
|
||||||
|
.cursor(Pointer)
|
||||||
|
.on_hover(hovering(String::from("row:back")))
|
||||||
|
.on_click(|_| enter(None))
|
||||||
|
.child(Row::new(vec![Box::new(icon(Glyph::Back, 12.0, theme.dim)) as Box<dyn Widget>, Box::new(Text::new(label.as_str()).size(text::body()).weight(Weight::Bold).color(theme.text).elide())]).gap(8.0).align(Center)),
|
||||||
|
),
|
||||||
|
None => Box::new(Rectangle::new().width(inner_w).height(menu::row_height()).padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 10.0 }).align_child(Start, Center).child(Text::new(open.item.title.as_str()).size(text::small()).weight(Weight::Bold).color(theme.dim).elide())),
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.push(heading);
|
||||||
|
height += menu::row_height();
|
||||||
|
|
||||||
|
for entry in open.entries.iter().take(menu::max_rows()) {
|
||||||
|
if entry.separator {
|
||||||
|
rows.push(Box::new(Rectangle::new().width(inner_w).height(menu::separator_height()).align_child(Center, Center).child(Rectangle::new().width(inner_w - 16.0).height(1.0).fill(faded(theme.outline, 160)))));
|
||||||
|
height += menu::separator_height();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let spot = format!("row:{}", entry.id);
|
||||||
|
let lit = if entry.enabled { ui.hover.get(&spot).map_or(0.0, Animation::value) } else { 0.0 };
|
||||||
|
let ink = if entry.enabled { Color::blend(theme.text, theme.flame, 0.25 * lit) } else { faded(theme.dim, 140) };
|
||||||
|
|
||||||
|
// a mark at the left for checkmarks and radios that are on, a chevron at the right for submenus
|
||||||
|
let mark: Box<dyn Widget> = match entry.toggle {
|
||||||
|
Some(true) => Box::new(Rectangle::new().width(14.0).height(14.0).align_child(Center, Center).child(Rectangle::new().width(7.0).height(7.0).radius(Full).fill(theme.accent))),
|
||||||
|
_ => Box::new(Rectangle::new().width(14.0).height(14.0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let more: Box<dyn Widget> = Box::new(Text::new(if entry.submenu { "›" } else { "" }).size(text::heading()).color(theme.dim));
|
||||||
|
|
||||||
|
let row = Rectangle::new()
|
||||||
|
.width(inner_w)
|
||||||
|
.height(menu::row_height())
|
||||||
|
.radius(9.0)
|
||||||
|
.fill(Color::blend(CLEAR, overlays::chip_hover(), lit))
|
||||||
|
.padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 6.0 })
|
||||||
|
.align_child(Start, Center)
|
||||||
|
.child(
|
||||||
|
Row::new(vec![
|
||||||
|
Box::new(Row::new(vec![mark, Box::new(Text::new(entry.label.as_str()).size(text::body()).color(ink).elide())]).gap(8.0).align(Center)) as Box<dyn Widget>,
|
||||||
|
more,
|
||||||
|
])
|
||||||
|
.justify(SpaceBetween)
|
||||||
|
.align(Center)
|
||||||
|
.width(inner_w - 14.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let row = if entry.enabled {
|
||||||
|
let (item, id, label, submenu) = (open.item.clone(), entry.id, entry.label.clone(), entry.submenu);
|
||||||
|
|
||||||
|
row.cursor(Pointer).on_hover(hovering(spot)).on_click(move |_| if submenu { enter(Some((id, label.clone()))) } else { choose(&item, id) })
|
||||||
|
} else {
|
||||||
|
row
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.push(Box::new(row));
|
||||||
|
height += menu::row_height();
|
||||||
|
}
|
||||||
|
|
||||||
|
let appear = ui.appear.value();
|
||||||
|
|
||||||
|
let content = Rectangle::new().width(menu::width()).height(height).padding(menu::pad()).child(Column::new(rows));
|
||||||
|
|
||||||
|
// raised a little over the bar
|
||||||
|
let card = surface::shell(menu::width(), height, 16.0, &colors, 0.125, veiled(content, menu::width(), height, 16.0, appear, &colors))
|
||||||
|
.translate(0.0, -6.0 * (1.0 - appear))
|
||||||
|
.on_hover(|inside| {
|
||||||
|
if inside {
|
||||||
|
let mut ui = TrayUi::write();
|
||||||
|
|
||||||
|
ui.inside = true;
|
||||||
|
ui.moves += 1;
|
||||||
|
} else {
|
||||||
|
leave_soon();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let left = (open.x - 8.0 - island_pill::shadow()).max(0.0);
|
||||||
|
let top = island_pill::top_gap() + island_pill::height() + 6.0 - island_pill::shadow();
|
||||||
|
|
||||||
|
let region = InputArea { x: island_pill::shadow() as i32, y: island_pill::shadow() as i32, width: menu::width() as i32, height: height.ceil() as i32 };
|
||||||
|
|
||||||
|
window(menu::width() + 2.0 * island_pill::shadow(), height + 2.0 * island_pill::shadow())
|
||||||
|
.margin(Margin { top: top.max(0.0) as i32, right: 0, bottom: 0, left: left as i32 })
|
||||||
|
.input_region(vec![region])
|
||||||
|
.child(Rectangle::new().width(Parent).height(Parent).padding(island_pill::shadow()).align_child(Start, Start).child(card))
|
||||||
|
}
|
||||||
|
|
||||||
|
// for `amane ipc call tray ...`: list the items; open an item's menu (as a right click), choose or enter an entry, go back, close
|
||||||
|
pub fn command(words: &[String]) -> String {
|
||||||
|
let words: Vec<&str> = words.iter().map(String::as_str).collect();
|
||||||
|
|
||||||
|
match words.as_slice() {
|
||||||
|
["list"] | [] => Tray::read().items.iter().map(|item| format!("{}\t{}\t{}{}", item.key, item.title, item.icon.as_ref().map_or(String::from("-"), |path| path.display().to_string()), if item.passive { "\tpassive" } else { "" })).collect::<Vec<_>>().join("\n"),
|
||||||
|
["menu"] => TrayUi::read().open.as_ref().map_or(String::from("closed"), |open| open.entries.iter().map(|entry| if entry.separator { String::from("---") } else { format!("{} {}{}{}", entry.id, entry.label, if entry.toggle == Some(true) { " [on]" } else { "" }, if entry.submenu { " >" } else { "" }) }).collect::<Vec<_>>().join("\n")),
|
||||||
|
["open", index] | ["activate", index] => {
|
||||||
|
let Some(item) = index.parse::<usize>().ok().and_then(|index| Tray::read().items.iter().filter(|item| !item.passive).nth(index).cloned()) else {
|
||||||
|
return String::from("no such item");
|
||||||
|
};
|
||||||
|
|
||||||
|
if words[0] == "open" {
|
||||||
|
open_menu(&item, &crate::island::focused_monitor().unwrap_or_default(), 200.0);
|
||||||
|
} else {
|
||||||
|
activate(&item, "Activate");
|
||||||
|
}
|
||||||
|
|
||||||
|
String::from("ok")
|
||||||
|
}
|
||||||
|
["choose", id] | ["enter", id] => {
|
||||||
|
let Some((item, entry)) = TrayUi::read().open.as_ref().and_then(|open| Some((open.item.clone(), open.entries.iter().find(|entry| entry.id.to_string() == *id)?.clone()))) else {
|
||||||
|
return String::from("no such entry");
|
||||||
|
};
|
||||||
|
|
||||||
|
if entry.submenu { enter(Some((entry.id, entry.label))) } else { choose(&item, entry.id) }
|
||||||
|
|
||||||
|
String::from("ok")
|
||||||
|
}
|
||||||
|
["back"] => {
|
||||||
|
enter(None);
|
||||||
|
|
||||||
|
String::from("ok")
|
||||||
|
}
|
||||||
|
["close"] => {
|
||||||
|
close_menu();
|
||||||
|
|
||||||
|
String::from("ok")
|
||||||
|
}
|
||||||
|
_ => String::from("usage: tray list|menu|open N|activate N|choose ID|enter ID|back|close"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use amane::{
|
||||||
|
Animation, Blend, Center, Color, Full, Horizontal, InputArea, Layer, LayerWindow, Monitor, Padding, Parent, Pointer,
|
||||||
|
Rectangle, Row, Service, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::config::appearance::{motion, overlays, text};
|
||||||
|
use crate::config::island::pill as island_pill;
|
||||||
|
use crate::config::workspaces::pill;
|
||||||
|
use crate::edge::{self, Edge, Part};
|
||||||
|
use crate::island::{CLEAR, faded, mix};
|
||||||
|
use crate::{surface, theme, tray};
|
||||||
|
|
||||||
|
// one round slot per workspace, inside the pill's inset, the pill pill::left() px from the monitor's edge,
|
||||||
|
// the tray pill::tray_gap() after it (config.rs: workspaces.*)
|
||||||
|
|
||||||
|
// each hovered workspace's highlight by workspace id, fading between 0 and 1
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Hover(HashMap<i64, Animation>);
|
||||||
|
|
||||||
|
impl Service for Hover {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// changes only through input
|
||||||
|
fn listen() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* a pill in the top left corner of every monitor with that monitor's hyprland workspaces,
|
||||||
|
* in the wallpaper's colours like the island. the focused workspace takes the accent, the
|
||||||
|
* one shown on another monitor a softer accent, empty ones dim; a click switches to it.
|
||||||
|
* Workspaces follows hyprland's events, so nothing polls
|
||||||
|
*/
|
||||||
|
pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||||
|
theme::begin();
|
||||||
|
|
||||||
|
let workspaces = Workspaces::read();
|
||||||
|
let hover = Hover::read();
|
||||||
|
|
||||||
|
// special workspaces (scratchpads) aren't places to switch to
|
||||||
|
let here: Vec<&Workspace> = workspaces
|
||||||
|
.list()
|
||||||
|
.iter()
|
||||||
|
.filter(|workspace| workspace.output() == Some(monitor.name.as_str()))
|
||||||
|
.filter(|workspace| !workspace.name().is_some_and(|name| name.starts_with("special")))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* hidden on a fullscreen monitor until wanted, or for a moment on a workspace change (edge.rs);
|
||||||
|
* the tray only goes with the whole bar. and how far each workspace has grown into the row:
|
||||||
|
* one hyprland just made for a switch grows over the slide
|
||||||
|
*/
|
||||||
|
let (slide, tray_slide, switched, peeking, grown) = {
|
||||||
|
let edge = Edge::read();
|
||||||
|
let shown = edge.shown(&monitor.name);
|
||||||
|
let grown: Vec<f32> = here.iter().map(|workspace| edge.grown(workspace.id())).collect();
|
||||||
|
|
||||||
|
(edge::hidden_by(edge.part(Part::Workspaces, &monitor.name), 0.0, edge::hide_h()), edge::hidden_by(shown, 0.0, edge::hide_h()), edge.switched(&monitor.name), shown == 0.0, grown)
|
||||||
|
};
|
||||||
|
|
||||||
|
let step = pill::slot() + pill::slot_gap();
|
||||||
|
|
||||||
|
// ease in and out, with a slight stretch in the middle like a drop of ink
|
||||||
|
let eased = |t: f32| t * t * (3.0 - 2.0 * t);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* hyprland removes an empty workspace the moment it's left. its slot stays for the
|
||||||
|
* slide, fading and closing up, so the row and the pill narrow with the disc's move
|
||||||
|
* instead of jumping first: (where it was, its label, how much of it is left, 1 to 0)
|
||||||
|
*/
|
||||||
|
let gone = switched.filter(|(from, _)| *from > 0 && here.iter().all(|workspace| workspace.id() != *from)).map(|(from, t)| {
|
||||||
|
let at = here.iter().filter(|workspace| i64::from(workspace.index()) < from).count();
|
||||||
|
|
||||||
|
(at, from.to_string(), 1.0 - eased(t))
|
||||||
|
});
|
||||||
|
|
||||||
|
let left = gone.as_ref().map_or(0.0, |(_, _, left)| *left);
|
||||||
|
|
||||||
|
// each slot's x along the row: a new workspace takes its room as it grows, the ghost gives its room back as it closes
|
||||||
|
let sizes: Vec<f32> = grown.iter().map(|t| eased(*t)).collect();
|
||||||
|
let mut xs: Vec<f32> = Vec::with_capacity(here.len());
|
||||||
|
let (mut x, mut ghost_x) = (0.0, 0.0);
|
||||||
|
|
||||||
|
for index in 0..=here.len() {
|
||||||
|
if gone.as_ref().is_some_and(|(at, _, _)| *at == index) {
|
||||||
|
ghost_x = x;
|
||||||
|
x += left * step;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(size) = sizes.get(index) {
|
||||||
|
xs.push(x);
|
||||||
|
x += size * step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = 2.0 * pill::inset() + (x - pill::slot_gap()).max(0.0);
|
||||||
|
|
||||||
|
// while slots grow or close the window keeps the room of all of them whole, so it isn't resized every frame
|
||||||
|
let changing = gone.is_some() || sizes.iter().any(|size| *size < 1.0);
|
||||||
|
let whole = here.len() as f32 + if gone.is_some() { 1.0 } else { 0.0 };
|
||||||
|
let room = if changing { 2.0 * pill::inset() + (whole * step - pill::slot_gap()).max(0.0) } else { width };
|
||||||
|
|
||||||
|
let window = |window_w: f32| {
|
||||||
|
LayerWindow::new()
|
||||||
|
.width(window_w)
|
||||||
|
.height(island_pill::top_gap() + island_pill::height() + island_pill::shadow())
|
||||||
|
.anchor_vertical(Vertical::Top)
|
||||||
|
.anchor_horizontal(Horizontal::Left)
|
||||||
|
// Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them
|
||||||
|
.layer(Layer::Overlay)
|
||||||
|
.space(Zone::Ignore)
|
||||||
|
.namespace("magic-workspaces")
|
||||||
|
};
|
||||||
|
|
||||||
|
// empty and click-through rather than hidden: see the island's view for why
|
||||||
|
if here.is_empty() || slide <= -edge::hide_h() {
|
||||||
|
return window(pill::left() + width + island_pill::shadow()).click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// the tray's pill right of this one (tray.rs), when an app has an icon there; a peek at a workspace change shows the workspaces alone
|
||||||
|
let tray = if peeking { None } else { tray::pill(&monitor.name, pill::left() + width + pill::tray_gap()) };
|
||||||
|
let tray_w = tray.as_ref().map_or(0.0, |(_, tray_w)| pill::tray_gap() + tray_w);
|
||||||
|
|
||||||
|
let window = window(pill::left() + room + tray_w + island_pill::shadow());
|
||||||
|
|
||||||
|
let mut slots: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* the focused workspace's accent is one disc under the row, so a change slides it from
|
||||||
|
* the workspace before to this one (edge.rs times it); the slots themselves stay clear.
|
||||||
|
* slots are placed by x in one stack, a growing one centred in the room it has so far
|
||||||
|
*/
|
||||||
|
let theme = theme::now();
|
||||||
|
let slot_x = |index: usize| xs[index] - (1.0 - sizes[index]) * pill::slot() / 2.0;
|
||||||
|
|
||||||
|
let focused_at = here.iter().position(|workspace| workspace.focused());
|
||||||
|
let came_from = switched.and_then(|(from, t)| match &gone {
|
||||||
|
Some(_) => Some((ghost_x, t)),
|
||||||
|
None => here.iter().position(|workspace| workspace.id() == from).map(|at| (slot_x(at), t)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let highlight_x = match (focused_at, came_from) {
|
||||||
|
(Some(to), Some((from_x, t))) => Some(mix(from_x, slot_x(to), eased(t))),
|
||||||
|
(Some(to), None) => Some(slot_x(to)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let stretch = came_from.map_or(0.0, |(_, t)| (t * std::f32::consts::PI).sin() * 10.0);
|
||||||
|
|
||||||
|
if switched.is_some() || changing {
|
||||||
|
amane::request_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// the ghost: its number shrinking into the gap it leaves
|
||||||
|
if let Some((_, label, left)) = &gone {
|
||||||
|
slots.push(Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(pill::slot())
|
||||||
|
.height(pill::slot())
|
||||||
|
.align_child(Center, Center)
|
||||||
|
.scale(*left)
|
||||||
|
.translate(ghost_x - (1.0 - left) * pill::slot() / 2.0, 0.0)
|
||||||
|
.child(Text::new(label.as_str()).size(text::body()).color(Color::blend(CLEAR, theme.dim, *left))),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (at, workspace) in here.iter().enumerate() {
|
||||||
|
let id = workspace.id();
|
||||||
|
let lit = hover.0.get(&id).map_or(0.0, Animation::value);
|
||||||
|
|
||||||
|
// hyprland numbers its workspaces; a name-only one shows its first letter
|
||||||
|
let label = match workspace.name() {
|
||||||
|
Some(name) if workspace.index() == 0 => name.chars().next().map_or(String::new(), |letter| letter.to_uppercase().collect()),
|
||||||
|
_ => workspace.index().to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (fill, color, weight) = if workspace.focused() {
|
||||||
|
// the label turns as the disc arrives
|
||||||
|
let arrived = switched.map_or(1.0, |(_, t)| t);
|
||||||
|
|
||||||
|
(CLEAR, Color::blend(theme.text, theme.on_accent, arrived), Weight::Bold)
|
||||||
|
} else if workspace.active() {
|
||||||
|
(faded(theme.accent, 70), theme.accent, Weight::Bold)
|
||||||
|
} else {
|
||||||
|
let resting = if workspace.windows() > 0 { theme.text } else { theme.dim };
|
||||||
|
|
||||||
|
(Color::blend(CLEAR, overlays::chip_hover(), lit), Color::blend(resting, theme.text, lit), Weight::Regular)
|
||||||
|
};
|
||||||
|
|
||||||
|
slots.push(Box::new(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(pill::slot())
|
||||||
|
.height(pill::slot())
|
||||||
|
.radius(Full)
|
||||||
|
.fill(fill)
|
||||||
|
.align_child(Center, Center)
|
||||||
|
.scale(sizes[at] * (1.0 + 0.08 * lit))
|
||||||
|
.cursor(Pointer)
|
||||||
|
.on_hover(move |inside| hovering(id, inside))
|
||||||
|
.on_click(move |_| focus(id))
|
||||||
|
.translate(slot_x(at), 0.0)
|
||||||
|
.child(Text::new(label).size(text::body()).weight(weight).color(color)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let row_w = width - 2.0 * pill::inset();
|
||||||
|
|
||||||
|
// the disc under the focused slot, a little wider while it's on its way, then the slots over it
|
||||||
|
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(x) = highlight_x {
|
||||||
|
layers.push(Box::new(Rectangle::new().width(pill::slot() + stretch).height(pill::slot()).radius(Full).fill(theme.accent).translate(x - stretch / 2.0, 0.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
layers.extend(slots);
|
||||||
|
|
||||||
|
let content = Rectangle::new().width(width).height(island_pill::height()).padding(pill::inset()).align_child(Start, Center).child(Stack::new(layers).width(row_w).height(pill::slot()));
|
||||||
|
|
||||||
|
let pill = surface::shell(width, island_pill::height(), island_pill::height() / 2.0, &theme.surfaces(), 0.0, content).on_hover(edge::over_bar);
|
||||||
|
|
||||||
|
// clicks next to the pills reach the windows underneath
|
||||||
|
let mut regions = vec![InputArea { x: pill::left() as i32, y: island_pill::top_gap() as i32, width: width.ceil() as i32, height: island_pill::height() as i32 }];
|
||||||
|
|
||||||
|
let mut pills: Vec<Box<dyn Widget>> = vec![Box::new(pill.translate(0.0, slide))];
|
||||||
|
|
||||||
|
// a peek keeps the workspaces in while the bar leaves: the tray slides away on its own
|
||||||
|
if let Some((tray, tray_pill_w)) = tray {
|
||||||
|
regions.push(InputArea { x: (pill::left() + width + pill::tray_gap()) as i32, y: island_pill::top_gap() as i32, width: tray_pill_w.ceil() as i32, height: island_pill::height() as i32 });
|
||||||
|
pills.push(Box::new(tray.translate(0.0, tray_slide)));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.input_region(regions).child(
|
||||||
|
Rectangle::new()
|
||||||
|
.width(Parent)
|
||||||
|
.height(Parent)
|
||||||
|
.padding(Padding { top: island_pill::top_gap(), right: 0.0, bottom: 0.0, left: pill::left() })
|
||||||
|
.align_child(Start, Start)
|
||||||
|
.child(Row::new(pills).gap(pill::tray_gap()).align(Start)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* switches to a workspace. amane's Workspaces::focus sends `dispatch workspace N`,
|
||||||
|
* which hyprland 0.56 with a lua config turns into hl.dispatch(workspace N) and
|
||||||
|
* rejects; this is the lua form the user's own keybinds use. on its own thread,
|
||||||
|
* so hyprctl never holds up drawing. named workspaces (negative ids) aren't
|
||||||
|
* switched: their lua form is unverified
|
||||||
|
*/
|
||||||
|
fn focus(id: i64) {
|
||||||
|
if id < 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let _ = Command::new("hyprctl").arg("dispatch").arg(format!("hl.dsp.focus({{ workspace = {id} }})")).output();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* one workspace lit at a time: amane matches hover targets by their place in the frame,
|
||||||
|
* so a workspace that closes under the pointer never hears it leave
|
||||||
|
*/
|
||||||
|
fn hovering(id: i64, inside: bool) {
|
||||||
|
let mut hover = Hover::write();
|
||||||
|
|
||||||
|
if inside {
|
||||||
|
for (other, highlight) in &mut hover.0 {
|
||||||
|
if *other != id {
|
||||||
|
highlight.to(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hover
|
||||||
|
.0
|
||||||
|
.entry(id)
|
||||||
|
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms())))
|
||||||
|
.to(if inside { 1.0 } else { 0.0 });
|
||||||
|
}
|
||||||
+10
-4
@@ -7,7 +7,8 @@
|
|||||||
# start | stop run / stop the compiled shell, with AMANE_FRAMES=1 logging
|
# start | stop run / stop the compiled shell, with AMANE_FRAMES=1 logging
|
||||||
# log [N] last N lines of the shell's log (default 30)
|
# log [N] last N lines of the shell's log (default 30)
|
||||||
# fps [SECONDS] frames drawn over SECONDS (default 3), with average gap and gpu wait
|
# fps [SECONDS] frames drawn over SECONDS (default 3), with average gap and gpu wait
|
||||||
# anim HANDLER 3x `amane ipc call HANDLER open|close`: frames, gaps over 20 ms, every gap
|
# anim HANDLER 3x `amane ipc call HANDLER open|close`: frames, gaps of 20-500 ms (stutter; longer
|
||||||
|
# gaps are other windows resting), every gap
|
||||||
# ipc ARGS... amane ipc call ARGS...
|
# ipc ARGS... amane ipc call ARGS...
|
||||||
# shot NAMESPACE FILE [PAD] screenshot the layer window with that namespace (Hyprland) into local FILE
|
# shot NAMESPACE FILE [PAD] screenshot the layer window with that namespace (Hyprland) into local FILE
|
||||||
# players start N | stop run N fake MPRIS players (fake_mpris.py, next to this script)
|
# players start N | stop run N fake MPRIS players (fake_mpris.py, next to this script)
|
||||||
@@ -27,7 +28,7 @@ session='uid=$(id -u)
|
|||||||
export XDG_RUNTIME_DIR=/run/user/$uid
|
export XDG_RUNTIME_DIR=/run/user/$uid
|
||||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
|
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
|
||||||
export WAYLAND_DISPLAY=$(ls "$XDG_RUNTIME_DIR" | grep -m1 -x "wayland-[0-9]*")
|
export WAYLAND_DISPLAY=$(ls "$XDG_RUNTIME_DIR" | grep -m1 -x "wayland-[0-9]*")
|
||||||
export HYPRLAND_INSTANCE_SIGNATURE=$(ls -t "$XDG_RUNTIME_DIR/hypr" 2>/dev/null | head -1)
|
export HYPRLAND_INSTANCE_SIGNATURE=$(cd "$XDG_RUNTIME_DIR/hypr" 2>/dev/null && for d in $(ls -t); do [ -S "$d/.socket.sock" ] && echo "$d" && break; done)
|
||||||
export PATH=$HOME/.cargo/bin:$PATH
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
mkdir -p '"$work"' && cd '"$work"'
|
mkdir -p '"$work"' && cd '"$work"'
|
||||||
'
|
'
|
||||||
@@ -52,7 +53,12 @@ build)
|
|||||||
;;
|
;;
|
||||||
start)
|
start)
|
||||||
# newer CLIs save ~/.cache/amane/amane-shell; older ones only leave cargo's output
|
# newer CLIs save ~/.cache/amane/amane-shell; older ones only leave cargo's output
|
||||||
remote 'test -f shell.pid && kill $(cat shell.pid) 2>/dev/null; sleep 0.5
|
# the old shell can take a few seconds to let go of amane's socket; wait for it, or the new one panics
|
||||||
|
remote 'test -f shell.pid && kill $(cat shell.pid) 2>/dev/null
|
||||||
|
for i in $(seq 50); do pgrep -F shell.pid > /dev/null 2>&1 || break; sleep 0.1; done
|
||||||
|
# a shell this script did not start belongs to the user: leave it, and say so
|
||||||
|
other=$(pgrep -x amane-shell)
|
||||||
|
if [ -n "$other" ]; then echo "not started: another amane-shell runs (pid $other), not started by this script"; exit 1; fi
|
||||||
binary=~/.cache/amane/amane-shell
|
binary=~/.cache/amane/amane-shell
|
||||||
test -x "$binary" || binary=~/.cache/amane/project/target/debug/amane-shell
|
test -x "$binary" || binary=~/.cache/amane/project/target/debug/amane-shell
|
||||||
AMANE_FRAMES=1 setsid -f sh -c "echo \$\$ > shell.pid; exec $binary" > shell.log 2>&1
|
AMANE_FRAMES=1 setsid -f sh -c "echo \$\$ > shell.pid; exec $binary" > shell.log 2>&1
|
||||||
@@ -76,7 +82,7 @@ anim)
|
|||||||
a=\$(wc -l < shell.log); amane ipc call $handler \$action > /dev/null; sleep 1
|
a=\$(wc -l < shell.log); amane ipc call $handler \$action > /dev/null; sleep 1
|
||||||
tail -n +\$((a + 1)) shell.log | grep '^frame' | awk -v act=\$action '
|
tail -n +\$((a + 1)) shell.log | grep '^frame' | awk -v act=\$action '
|
||||||
{gap[n++]=\$5; gpu+=\$11}
|
{gap[n++]=\$5; gpu+=\$11}
|
||||||
END {slow=0; for (i=1;i<n;i++) if (gap[i]>20) slow++;
|
END {slow=0; for (i=1;i<n;i++) if (gap[i]>20 && gap[i]<500) slow++;
|
||||||
printf \"%-5s frames %2d over 20 ms %2d gpu wait %5.2f ms gaps:\", act, n, slow, gpu/n;
|
printf \"%-5s frames %2d over 20 ms %2d gpu wait %5.2f ms gaps:\", act, n, slow, gpu/n;
|
||||||
for (i=1;i<n && i<26;i++) printf \" %.0f\", gap[i]; print \"\"}'
|
for (i=1;i<n && i<26;i++) printf \" %.0f\", gap[i]; print \"\"}'
|
||||||
done; done"
|
done; done"
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fake StatusNotifierItem for tray tests.
|
||||||
|
|
||||||
|
usage: fake_tray.py name|path [ICON_NAME]
|
||||||
|
name: owns org.kde.StatusNotifierItem-<pid>-1, item at /StatusNotifierItem, IconName
|
||||||
|
path: registers its object path only (libappindicator style), IconPixmap pixels
|
||||||
|
Every call it receives is printed (Activate, ContextMenu, Scroll, menu Event...).
|
||||||
|
A menu with a checkmark, a separator, a submenu and a disabled entry.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from gi.repository import Gio, GLib
|
||||||
|
|
||||||
|
MODE = sys.argv[1] if len(sys.argv) > 1 else "name"
|
||||||
|
ICON = sys.argv[2] if len(sys.argv) > 2 else "audio-volume-high"
|
||||||
|
PATH = "/StatusNotifierItem" if MODE == "name" else "/org/ayatana/NotificationItem/fake_tray"
|
||||||
|
NAME = f"org.kde.StatusNotifierItem-{os.getpid()}-1"
|
||||||
|
|
||||||
|
ITEM_XML = """<node><interface name="org.kde.StatusNotifierItem">
|
||||||
|
<property name="Category" type="s" access="read"/><property name="Id" type="s" access="read"/>
|
||||||
|
<property name="Title" type="s" access="read"/><property name="Status" type="s" access="read"/>
|
||||||
|
<property name="IconName" type="s" access="read"/><property name="IconPixmap" type="a(iiay)" access="read"/>
|
||||||
|
<property name="IconThemePath" type="s" access="read"/><property name="ItemIsMenu" type="b" access="read"/>
|
||||||
|
<property name="Menu" type="o" access="read"/>
|
||||||
|
<method name="Activate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
|
||||||
|
<method name="SecondaryActivate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
|
||||||
|
<method name="ContextMenu"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
|
||||||
|
<method name="Scroll"><arg type="i" direction="in"/><arg type="s" direction="in"/></method>
|
||||||
|
<signal name="NewIcon"/><signal name="NewStatus"><arg type="s"/></signal>
|
||||||
|
</interface></node>"""
|
||||||
|
|
||||||
|
MENU_XML = """<node><interface name="com.canonical.dbusmenu">
|
||||||
|
<method name="GetLayout"><arg type="i" direction="in"/><arg type="i" direction="in"/><arg type="as" direction="in"/>
|
||||||
|
<arg type="u" direction="out"/><arg type="(ia{sv}av)" direction="out"/></method>
|
||||||
|
<method name="AboutToShow"><arg type="i" direction="in"/><arg type="b" direction="out"/></method>
|
||||||
|
<method name="Event"><arg type="i" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="in"/><arg type="u" direction="in"/></method>
|
||||||
|
</interface></node>"""
|
||||||
|
|
||||||
|
TITLE = "Fake Tray " + MODE
|
||||||
|
muted = [True]
|
||||||
|
|
||||||
|
|
||||||
|
def entry(id, props, children=()):
|
||||||
|
return GLib.Variant("(ia{sv}av)", (id, props, [GLib.Variant("v", child) for child in children]))
|
||||||
|
|
||||||
|
|
||||||
|
def menu(parent):
|
||||||
|
sub = [
|
||||||
|
entry(31, {"label": GLib.Variant("s", "Sub _A")}),
|
||||||
|
entry(32, {"label": GLib.Variant("s", "Sub B")}),
|
||||||
|
]
|
||||||
|
top = [
|
||||||
|
entry(1, {"label": GLib.Variant("s", "_Open Window")}),
|
||||||
|
entry(2, {"type": GLib.Variant("s", "separator")}),
|
||||||
|
entry(3, {"label": GLib.Variant("s", "Mute"), "toggle-type": GLib.Variant("s", "checkmark"), "toggle-state": GLib.Variant("i", 1 if muted[0] else 0)}),
|
||||||
|
entry(4, {"label": GLib.Variant("s", "More"), "children-display": GLib.Variant("s", "submenu")}, sub),
|
||||||
|
entry(5, {"label": GLib.Variant("s", "Disabled thing"), "enabled": GLib.Variant("b", False)}),
|
||||||
|
entry(6, {"label": GLib.Variant("s", "Hidden"), "visible": GLib.Variant("b", False)}),
|
||||||
|
entry(7, {"label": GLib.Variant("s", "Quit")}),
|
||||||
|
]
|
||||||
|
children = sub if parent == 4 else top
|
||||||
|
return GLib.Variant("(u(ia{sv}av))", (1, (parent, {"children-display": GLib.Variant("s", "submenu")}, [GLib.Variant("v", child) for child in children])))
|
||||||
|
|
||||||
|
|
||||||
|
def pixmap():
|
||||||
|
size = 24
|
||||||
|
data = bytearray()
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
inside = (x - 11.5) ** 2 + (y - 11.5) ** 2 < 100
|
||||||
|
data += bytes([255 if inside else 0, 60, 200, 120]) if inside else bytes([0, 0, 0, 0])
|
||||||
|
return [(size, size, bytes(data))]
|
||||||
|
|
||||||
|
|
||||||
|
def item_property(conn, sender, path, iface, name):
|
||||||
|
print("get", name, flush=True)
|
||||||
|
values = {
|
||||||
|
"Category": GLib.Variant("s", "ApplicationStatus"),
|
||||||
|
"Id": GLib.Variant("s", "fake_tray_" + MODE),
|
||||||
|
"Title": GLib.Variant("s", TITLE),
|
||||||
|
"Status": GLib.Variant("s", "Active"),
|
||||||
|
"IconName": GLib.Variant("s", ICON if MODE == "name" else ""),
|
||||||
|
"IconPixmap": GLib.Variant("a(iiay)", pixmap() if MODE == "path" else []),
|
||||||
|
"IconThemePath": GLib.Variant("s", ""),
|
||||||
|
"ItemIsMenu": GLib.Variant("b", False),
|
||||||
|
"Menu": GLib.Variant("o", "/MenuBar"),
|
||||||
|
}
|
||||||
|
return values.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def item_call(conn, sender, path, iface, method, params, invocation):
|
||||||
|
print("item", method, params.unpack(), flush=True)
|
||||||
|
invocation.return_value(None)
|
||||||
|
|
||||||
|
|
||||||
|
def menu_call(conn, sender, path, iface, method, params, invocation):
|
||||||
|
args = params.unpack()
|
||||||
|
print("menu", method, args, flush=True)
|
||||||
|
if method == "GetLayout":
|
||||||
|
invocation.return_value(menu(args[0]))
|
||||||
|
elif method == "AboutToShow":
|
||||||
|
invocation.return_value(GLib.Variant("(b)", (False,)))
|
||||||
|
else:
|
||||||
|
if method == "Event" and args[0] == 3:
|
||||||
|
muted[0] = not muted[0]
|
||||||
|
if method == "Event" and args[0] == 7:
|
||||||
|
GLib.idle_add(loop.quit)
|
||||||
|
invocation.return_value(None)
|
||||||
|
|
||||||
|
|
||||||
|
bus = Gio.bus_get_sync(Gio.BusType.SESSION)
|
||||||
|
bus.register_object(PATH, Gio.DBusNodeInfo.new_for_xml(ITEM_XML).interfaces[0], item_call, item_property, None)
|
||||||
|
bus.register_object("/MenuBar", Gio.DBusNodeInfo.new_for_xml(MENU_XML).interfaces[0], menu_call, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def register(*_):
|
||||||
|
service = NAME if MODE == "name" else PATH
|
||||||
|
try:
|
||||||
|
bus.call_sync("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher", "org.kde.StatusNotifierWatcher",
|
||||||
|
"RegisterStatusNotifierItem", GLib.Variant("(s)", (service,)), None, 0, 3000, None)
|
||||||
|
print("registered", service, flush=True)
|
||||||
|
except GLib.Error as error:
|
||||||
|
print("no watcher:", error.message, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if MODE == "name":
|
||||||
|
Gio.bus_own_name_on_connection(bus, NAME, 0, None, None)
|
||||||
|
|
||||||
|
# like the real libraries: register again whenever a watcher appears
|
||||||
|
Gio.bus_watch_name_on_connection(bus, "org.kde.StatusNotifierWatcher", 0, register, None)
|
||||||
|
|
||||||
|
loop = GLib.MainLoop()
|
||||||
|
loop.run()
|
||||||
@@ -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