Compare commits
29
Commits
dfe3867529
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
303764732b | ||
|
|
184da49c2f | ||
|
|
e963c944e8 | ||
|
|
c24671e981 | ||
|
|
90130add06 | ||
|
|
1b8fc66130 | ||
|
|
f79662bd5b | ||
|
|
e0d8a5479c | ||
|
|
344d318959 | ||
|
|
801eab9ab4 | ||
|
|
c0fff44bcf | ||
|
|
f55314f511 | ||
|
|
3de3709871 | ||
|
|
0457830aba | ||
|
|
689b53be0f | ||
|
|
f8c17025ce | ||
|
|
aadf765d87 | ||
|
|
756f38be4e | ||
|
|
7cb2368b23 | ||
|
|
8cbd211da0 | ||
|
|
e2ef1a9d52 | ||
|
|
d59a5bc81c | ||
|
|
e8741b1cb3 | ||
|
|
bbe184ab97 | ||
|
|
6399844731 | ||
|
|
5d322b940d | ||
|
|
7d8525e95c | ||
|
|
3b9929aff4 | ||
|
|
4fbfad3ce3 |
@@ -1,200 +1,162 @@
|
||||
# AGENTS.md
|
||||
|
||||
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-13.
|
||||
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 and docs:** `/root/amane` (`docs/src/*.md`, source in `src/`). The desktop has its own copy at `~/git/amane`.
|
||||
- **Amane library:** `/root/amane` (docs in `docs/src/*.md`). The desktop builds against its own, different copy at `~/git/amane`; type-check against a copy of that one (see Develop).
|
||||
- **Hermes skill `amane`** (`~/.hermes/skills/software-development/amane/`): distilled Amane docs plus lessons. `references/recipe-media-island.md` and `references/remote-visual-testing.md` cover this project; `scripts/` holds copies of `tools/` (keep them in sync).
|
||||
- **Desktop:** `ssh -o BatchMode=yes fabian@192.168.2.169`. Hyprland 0.56.2 with a **Lua** config (`~/.config/hypr/*.lua`; API stubs in `/usr/share/hypr/stubs/hl.meta.lua`). Intel Xe iGPU (Raptor Lake). Monitors: eDP-1 1920×1200 at 1920,620; HDMI-A-1 1920×1080 at 0,620; DP-2 1920×1080 at 3840,0; HDMI-A-4 at 5760,0 only when connected (on 2026-10-13 three were connected, all 1920 wide). `~/.config/amane` on the desktop is a git checkout of this repo.
|
||||
- **Desktop daemons the control centre uses:** OpenRC services `iwd` (Wi-Fi), `bluetooth` (bluez), `openvpn.<name>` (VPN); doas rule `permit nopass fabian` for `rc-service`, `sh` and `cat`; `wl-copy`, `ip`, `lspci`. iwd and bluetoothd are often stopped; the shell then greys their tiles.
|
||||
- **Desktop:** `ssh -o BatchMode=yes fabian@192.168.2.169`. Hyprland 0.56.2 with a **Lua** config (`~/.config/hypr/*.lua`; API stubs in `/usr/share/hypr/stubs/hl.meta.lua`). Intel Xe iGPU (Raptor Lake) plus an eGPU (RX 6600). Monitors: eDP-1 1920×1200 at 1920,620; HDMI-A-1 1920×1080 at 0,620; DP-2 1920×1080 at 3840,0; HDMI-A-4 at 5760,0 only when connected. `~/.config/amane` on the desktop is a git checkout of this repo.
|
||||
- **Desktop daemons the shell uses:** OpenRC services `iwd`, `bluetooth`, `openvpn.<name>`, `ollama` (stopped by default; `gemma4:e4b`, listens on 0.0.0.0:11434, runs on the eGPU); doas rule `permit nopass fabian` for `rc-service`, `sh` and `cat`; gnome-keyring as the Secret Service (`secret-tool`); `wl-copy`, `ip`, `lspci`, `ghostty`, `btm`. iwd and bluetoothd are often stopped; the shell then greys their tiles.
|
||||
|
||||
## Ground rules from the user
|
||||
|
||||
- **Branch per big change, ask before merging.** New branch for each big change; merge into `master` only after explicit approval. "In main branch" or "merge directly to main" means commit straight to master. Small follow-ups in the same request can share a branch. **Push only when the user asks.**
|
||||
- **Branch per big change, ask before merging.** Merge into `master` only after explicit approval. "In main branch" or "merge directly to main" means commit straight to master. Small follow-ups in the same request can share a branch. **Push only when the user asks.**
|
||||
- **Never touch the user's real players' sound** (Cider, Firefox). Before any `island preset|output` call or volume change, check `amane ipc call island status` names a fake test player. Test audio goes to the inaudible null sinks.
|
||||
- **The user's own amane:** they sometimes run it from a terminal. Don't kill it without asking; `tools/amane-remote.sh start` refuses when it finds a shell it didn't start.
|
||||
- **Desktop access is SSH only.** Don't probe the network, SSH keys or other hosts. If SSH fails, report it and wait (the user once had to restart sshd).
|
||||
- **The user's own amane:** their session starts one at login. Don't kill it without asking; `tools/amane-remote.sh start` refuses when it finds a shell it didn't start.
|
||||
- **Desktop access is SSH only.** Don't probe the network, SSH keys or other hosts. If SSH fails, report it and wait. The user may be at the desktop while you test: if workspaces change or cards close by themselves, stop the on-screen tests.
|
||||
- **Leave the desktop clean** after testing: fake players and null sinks stopped, shell running without the frame log (see "After testing").
|
||||
- **Edit files with the patch/write tools, not scripts that rewrite files.** The user installs missing tools themselves. Scripts take instance values (hosts, names) as arguments.
|
||||
- **D-Bus only for reading status.** Actions go through the CLIs: `iwctl` to join, scan and disconnect, `bluetoothctl` to pair and connect (the user had a D-Bus agent version reverted, `afe963d`). The one exception, chosen by the user: the shell serves `org.kde.StatusNotifierWatcher` and calls tray apps' dbusmenus for the tray. Ask before serving anything else on the bus.
|
||||
- **Never wake iwd by accident:** any D-Bus call to `net.connman.iwd` starts it (it then joins Wi-Fi and adds a route). Check `org.freedesktop.DBus.NameHasOwner` first; only talk to it while it has an owner.
|
||||
- **Root only through `doas -n` with fixed command lines** (`rc-service <svc> start|stop`, `cat /var/lib/iwd/<profile>.psk`). Strings from other programs (SSIDs, device names, D-Bus metadata) go to `Command` args, never into `amane::spawn`/`output` (`sh -c`). Passwords go to `wl-copy` on stdin, never as an argument; iwctl's `--passphrase` is visible in `ps` for about a second, which the user accepted.
|
||||
- Hermes approval prompts are switched off; edits to AGENTS.md may still prompt (`security.protected_instruction_files`), and the user approves AGENTS.md rewrites first.
|
||||
- **No hardcoded tuning values:** every size, timing, text size, colour or interval is a setting in `config.rs` (see Settings).
|
||||
- **Blur strength is Hyprland's only** (`decoration.blur.*`; the user keeps size 1 and the layer rule's `ignore_alpha = 0.5`). The shell never sets it.
|
||||
- **D-Bus mostly for reading status.** Actions go through the CLIs: `iwctl`, `bluetoothctl` (a D-Bus agent version was reverted, `afe963d`). The shell serves only what the user chose: `org.kde.StatusNotifierWatcher` (tray) and `org.freedesktop.Notifications` (Amane's own server; dunst is commented out in `~/.config/hypr/autocmds.lua`, backup `autocmds.lua.before-amane-notifications`). Ask before serving anything else.
|
||||
- **Never wake iwd by accident:** any D-Bus call to `net.connman.iwd` starts it. Check `NameHasOwner` first.
|
||||
- **Root only through `doas -n` with fixed command lines.** Strings from other programs go to `Command` args, never into `amane::spawn`/`output` (`sh -c`). Secrets go on stdin: `wl-copy`, `secret-tool store`, curl's `-K -` for the calendar. Never put a password in a file, an argument or a log.
|
||||
- **Local AI only:** the AI card talks to Ollama on the desktop, never a cloud service. Phone stats are out for now.
|
||||
- Edits to AGENTS.md prompt for approval (`security.protected_instruction_files`); a timed-out prompt is a no: don't retry by another path. Keep this file near 20k characters, Hermes's load limit.
|
||||
- Report honestly what was and wasn't checked on the real desktop.
|
||||
|
||||
## Current state
|
||||
|
||||
**master** (pushed): the island (pill, card, CD ring, EQ, output and player switching, hover animations, instant transport buttons), an idle pill when no player is open, one island per monitor (card on one monitor, live bars only on the focused one), a workspace pill per monitor, and wallpaper colours through matugen. Merged from `control-centre` on 2026-10-13, then extended on master:
|
||||
**master** (pushed): the island (pill, card, CD ring, EQ, output and player switching, hover animations), an idle pill without players, one island per monitor (live bars only on the focused one), a workspace pill per monitor, wallpaper colours through matugen, and:
|
||||
|
||||
- **Control centre** (top right): a status pill (Wi-Fi or hotspot, VPN, Bluetooth, Ethernet, battery; icons of stopped services hidden) that opens a panel: tiles for Wi-Fi, Bluetooth, VPN, Hotspot, Ethernet; volume with the output, brightness, keyboard light, performance mode, external-GPU programs. The panel morphs into its menus as one shape: Wi-Fi and Bluetooth maps (devices floating round this computer, click to join/pair, right-click a saved network for its password, click the centre to search again), VPN list, sound outputs, Ethernet details with copyable addresses. The maps reach from the right edge to the island's pill, less 16 px. Popups (`magic-prompt`) ask for passwords, PINs and passkeys and say why something failed.
|
||||
- **Control centre** (top right): status pill opening a panel with tiles (Wi-Fi, Bluetooth, VPN, Hotspot, Ethernet), volume, brightness, keyboard light, performance mode, eGPU programs, Settings (gear). The panel morphs into its menus as one shape: Wi-Fi and Bluetooth maps, VPN list, sound outputs, Ethernet details. Popups (`magic-prompt`) ask for passwords, PINs and passkeys.
|
||||
- **System tray** right of the workspace pill.
|
||||
- **Bar hiding:** only on monitors showing a fullscreen window; the pointer resting on the top edge brings it back. Events bring one part in for a moment (a peek): the workspace pill alone on a workspace change, the island on a song change or a new player. A peek starts from where the part is, so switching from a shown bar to a fullscreen workspace keeps the workspace pill still (no dip) while the tray leaves on its own.
|
||||
- **Reserved space:** windows that aren't fullscreen stay below the bar (44 px at the top of every monitor).
|
||||
- **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.
|
||||
|
||||
On 2026-10-13 the user was asked to try the mouse-only parts (tray clicks, top-edge hover reveal, Copy buttons, PIN pairing) and answered "All works"; the agent itself only drove them over IPC.
|
||||
**Branch `side-pills`** (pushed, not merged; the user tries it first): two one-icon pills left of the island (AI, System) and two right (Home, Notifications); see Architecture and README "Side pills".
|
||||
|
||||
**Open design branches** (pushed, not merged; the user is reviewing them). They branched from `036b326`, 13 commits before master's control-centre work; merging one will need a rebase (conflicts not checked yet):
|
||||
**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.
|
||||
|
||||
| Branch | What it is |
|
||||
|---|---|
|
||||
| `glass-base` | `glass` (0.05 to 1, default 1 = solid) in `shell.conf`; new `src/surface.rs` with `shell()`, which draws the island, idle pill and workspace pill; with glass below 1 the open/close fade uses `.opacity()` (a veil can't hide content on see-through glass). README "Glass" section with the Hyprland layer rule. |
|
||||
| `design-frosted` | glass-base plus an Apple-style surface: default glass 0.62, thin tint, hairline white rim (accent when hovered), white sheen along the top third, soft low black shadow instead of the accent glow. |
|
||||
| `design-liquid` | glass-base plus clear glass: default 0.4, denser toward the bottom right, specular gradient (bright top left, faint bottom right), 1.5 px light rim warming to `flame` when hovered, keeps the accent glow. |
|
||||
|
||||
Each branch has review images in `docs/design/`. A `blur` value was added to `shell.conf` on all three and then reverted: **the user wants the blur strength set only in Hyprland** (`~/.config/hypr/looks.lua` has `decoration.blur.size = 3`). When the user picks a design, merge `glass-base` plus that design into master.
|
||||
|
||||
Measured 2026-10-09 with the silent "Synth Test" player playing, 4 monitors (Amane GPU time per frame, CPU of amane-shell):
|
||||
|
||||
| | Pill | Open card | Open/close |
|
||||
|---|---|---|---|
|
||||
| master (solid) | 1.6 ms, 12% | 3.3 ms, 25% | 2.5 ms |
|
||||
| glass-base at 0.5 | 1.7 ms, 13.5% | 3.0 ms, 25.5% | 2.4 ms |
|
||||
| design-liquid at 0.4 | 2.2 ms, 16% | 3.3 ms, 23.5% | 2.9 ms |
|
||||
| design-frosted at 0.62 | 2.9 ms, 22% | 3.3 ms, 24% | 2.9 ms |
|
||||
|
||||
Hyprland's CPU stayed at about 9.5% in every run, blur rule on or off. All builds, master included, had 25–40 stutter frames (20–500 ms gaps) per 3 opens and closes, against 0–1 measured earlier with fewer monitors; the cause wasn't found (Cider and Firefox were running).
|
||||
|
||||
**On the desktop right now:** the agent-started shell runs a master build (`11646ca`; pid in `~/.cache/amane-remote-test/shell.pid`, no frame log). `~/.config/amane` is a checkout at `036b326` with master's `src/` deployed over it, so `git status` shows changed files. `shell.conf` has `glass = 0.70` (set by the user). amane is not autostarted by Hyprland: after a reboot the user starts it.
|
||||
|
||||
**Theme pipeline:** Skwd-wall v2's matugen integration `amane` renders `~/.config/amane/matugen/amane.colors` into `~/.config/amane/theme` (Skwd scheme: `scheme-fidelity`). `~/.config/hypr/windowrules.lua` has the glass layer rule (`^magic-.*$`, blur, `ignore_alpha = 0.5`).
|
||||
**Theme pipeline:** Skwd-wall v2's matugen integration renders `~/.config/amane/matugen/amane.colors` into `~/.config/amane/theme`.
|
||||
|
||||
## Constraints of Amane itself
|
||||
|
||||
- Only `amane` and the Rust standard library: `amane compile` generates `Cargo.toml`, so no crates. External tools run as processes (`std::process::Command`, `amane::output`).
|
||||
- Only `amane` and the Rust standard library: `amane compile` generates `Cargo.toml`, so no crates. External tools run as processes (`Command`, `amane::output`); HTTP is curl.
|
||||
- Amane redraws the whole window every frame, so every effect is paid at the frame rate.
|
||||
- Amane can only blur what the shell draws itself; frosting the desktop behind the shell needs a Hyprland layer rule.
|
||||
- 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
|
||||
|
||||
| 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 `menu`, time-bar `scrub`, `hover` (per-`Spot` animations), `opened_on` / `pointer_on` (monitors), `playing_now` (a clicked play/pause until the player answers). Also the view and the `island` IPC handler (`command`). |
|
||||
| `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`. |
|
||||
| `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. On start it `pkill -KILL`s cava left by an earlier shell (cava ignores SIGTERM). |
|
||||
| `art.rs` `Art` | Service, 250 ms poll | Cover URL → local file (curl for https), copied to `palette-cover`, which `Palette` (opened in `main.rs`, 8 colours) watches. Only used for `cover_colors`. |
|
||||
| `theme.rs` `Theme` | Service, `amane::watch_file` | 10 colours from `~/.config/amane/theme` and `shell.conf` (`cover_colors`, `transition_ms`; `glass` on the glass branches), faded together on one `Animation<Colors>`. Every view starts with `theme::begin()` (subscribes, returns `cover_colors`) and draws with `theme::now()`. No theme file: the original pink on purple. `watch_file` watches the folder, so matugen's rename-over saves count (55–68 ms to redraw). |
|
||||
| `workspaces.rs` | view + input-only `Hover` | Workspace pill per monitor (namespace `magic-workspaces`, top left): `Workspaces` filtered by `output() == monitor.name`, special workspaces skipped. Click runs `hyprctl dispatch 'hl.dsp.focus({ workspace = N })'` on a thread. |
|
||||
| `parse.rs` | plain functions | pactl/cava/proc parsing, filter-chain config, `pw-cli` params, EQ store, middle ellipsis, `key = value` settings, `#rrggbb`; for the control centre also iwctl/bluetoothctl output (`plain_lines`, `bluez_reason`, `pairing_event`), iwd profile names and psk files, Wi-Fi bars, link speeds, `hyprctl monitors/clients`, `ip` addresses and routes, tray icon scoring and menu mnemonics. Unit-tested on its own. |
|
||||
| `control.rs` `Control` | view + input-only Service | The control centre (`magic-control`, top right) and its menu window (`magic-control-menu`, no shape of its own: the panel morphs to the menu's size, then the menu's content fades in). `Menu` (Wifi, Bluetooth, Vpn, Sound, Ethernet), `menu_content(menu, map_w, ..)` for both windows, `map_width(monitor_w)`, the `control` IPC handler. |
|
||||
| `system.rs` `Links`, `Power`, `Gpu` | Services, threads | iwd (D-Bus reads, only while it has an owner), openvpn services, bluez service state, wired cards from sysfs, battery, brightness, keyboard light, power profile, eGPU programs from fdinfo. Actions: `join_wifi`, `scan_wifi`, `disconnect_wifi` (iwctl), `scan_bluetooth`, `rc-service` via doas, `saved_password`, `wired_details`. |
|
||||
| `prompt.rs` `Prompt` | view + input-only Service | The popup (`magic-prompt`): password, PIN, passkey, confirm, show a code, saved password (Copy), failure reason. Runs the `bluetoothctl` pairing session. Only popups with a text field take the keyboard. `prompt::copy(text, done)` is the one wl-copy. |
|
||||
| `map.rs` | widget functions | The Wi-Fi/Bluetooth maps: nodes on an ellipse round the centre bubble (nearer = stronger), floating, lines (wobbling, running out while connecting, solid with a pulse), search rings. `AREA_H` 480, `NODES` 8 (a 9th makes labels meet at 822 px wide; the sizes were picked by simulating the drift). |
|
||||
| `glyph.rs` | shapes | Every icon as `Shape`s; maps draw all of theirs into ONE canvas (`glyph_shapes`), as each `Canvas` has a large fixed GPU cost. |
|
||||
| `edge.rs` `Edge` | Service, Hyprland sockets | Reads `.socket2.sock` events (fullscreen, workspace, focus, windows) and asks `.socket.sock` for `monitors`/`clients`; no polling. Hides the bar on fullscreen monitors (`edge.part(Part, monitor)`, `hidden_by`), the 1 px `magic-edge` strip that brings it back, peeks and the island's pop (`edge.pop`), the workspace highlight slide (`switched`), and `spacer`, the `magic-reserve` strip that reserves 44 px for the bar. |
|
||||
| `tray.rs` `Tray`, `TrayUi` | Services, bus threads | Owns `org.kde.StatusNotifierWatcher` (only if no other watcher runs) and is its host; items by bus name or path (path-only owners found by asking all connections at once); icons from themes or `IconPixmap`; dbusmenu menus in `magic-tray-menu`; the `tray` IPC handler. |
|
||||
| `png.rs` | plain functions | Stored-deflate PNG writer for pixmap tray icons (amane draws images from files). |
|
||||
| `config.rs` | settings | Every setting declared once in `settings! { part { group { name: type = default, range [choices]; "help" } } }`: a typed accessor plus an `OPTIONS` entry for the window. `load()`/`save()` of `config.toml`, `set`/`reset`; `[hyprland.*]` tables hold Hyprland overrides. |
|
||||
| `settings.rs` | view, normal `Window` | The config window: a page per part and per Hyprland category, search, a row per setting (switch, slider + field, text with swatch, chips), reset per row. Esc closes. `config` IPC. |
|
||||
| `hypr.rs` `Hypr` | Service, worker | Hyprland's options from `hyprctl descriptions -j` and one `--batch` of `j/getoption`. `set`/`reset` apply with `hl.config`, rewrite `~/.config/hypr/amane.lua` and `config.toml`. |
|
||||
| `surface.rs` | widget function | `shell(w, h, radius, colors, lift, content)`: the chosen glass design for every surface. |
|
||||
| `island.rs` `Island` | input-only Service | The island: `open: Animation`, player selection, menus, scrub, hover spots, `width_on(monitor)` (for the pods), `island` IPC. `expand()` closes a pod card. |
|
||||
| `pods.rs` `Pods` | view + input-only Service | Side pills: windows `magic-pods-left|right` per monitor beside the island, moving out as it opens; a pill morphs into its card (one timeline, `pills.layout.open_ms`). One card open across the shell. `header`, `chip`, `button`, `rule` helpers for the cards. `pods` IPC (`open|toggle <pod> [monitor]`, `close`, `ask`, `processes`, `notices …`, `status`). |
|
||||
| `ai.rs` `Ai` | Service | Ollama `/api/chat` via curl, streamed line by line (`parse::Chunk`); starts `ollama` via doas when it doesn't answer. Stop and New chat bump `round`, so a late line from an old answer is dropped. |
|
||||
| `stats.rs` `Stats` | Service, thread | CPU (`/proc/stat`), GPU clock (xe/i915 sysfs), memory, network, disk sectors of physical disks, hottest sensor per hwmon. All graphs in one canvas. Processes runs `ghostty --class=amane.monitor -e btm` via `hl.exec_cmd` on `special:monitor`, then toggles it. |
|
||||
| `home.rs` `Clock`, `Forecast`, `Agenda` | Services, threads | Avatar (`~/.face`, else a glyph), clock, open-meteo weather (Munich by default), month grid, CalDAV (iCloud by default): discovery, `REPORT` with `expand`, `parse::ics_events`. Sign-in stores an app-specific password with `secret-tool` (`service amane-calendar account <id>`). |
|
||||
| `notices.rs` `Notices` | Service, poll 250 ms | On top of Amane's `Notifications` server: popups (`magic-notifications`, top right of the focused monitor, timeouts by urgency, paused on hover), pictures found once per notification (`tray::icon_file`), unread count, the history card (cross = `dismiss`, Clear all, Mute = `notifications.popups.enabled`). A popup click without a default action only hides it; the server's `click` would delete it. The server itself never expires notifications. |
|
||||
| `sound.rs` `Sound` | Service, poll | `pactl` sinks and sink-inputs → each player's streams, volume, mute, output, equalizer. Curves in `~/.config/amane/island-eq`. |
|
||||
| `spectrum.rs` `Spectrum` | Service | `pw-record --target <stream> \| cava` → levels while the shown player plays; kills stale cava with `-KILL`. |
|
||||
| `art.rs` `Art` | Service, poll | Cover → `palette-cover` for `cover_colors`. |
|
||||
| `theme.rs` `Theme` | Service, `watch_file` | 10 colours from `~/.config/amane/theme` faded on one `Animation`; also watches `config.toml`. Views start with `theme::begin()`, draw with `theme::now()`; `theme::touch()` redraws all. |
|
||||
| `workspaces.rs` | view + `Hover` | Workspace pill per monitor; slots by x in one `Stack` under one sliding disc; ghost and growing slots. |
|
||||
| `parse.rs` | plain functions | All parsing, unit-tested: pactl/cava/proc, EQ store, colours, iwctl/bluetoothctl, `hyprctl` JSON, TOML (`Value`), JSON, Hyprland values, `same_app`, Ollama chunks, /proc stats, weather, WebDAV XML, iCalendar, civil dates, notification body markup. |
|
||||
| `control.rs`, `map.rs`, `prompt.rs`, `system.rs` | views, Services | Control centre and its menus, the maps (one canvas each), the popups, the network/power/GPU services. |
|
||||
| `glyph.rs` | shapes | Every icon as `Shape`s; draw many into ONE canvas (`glyph_shapes`), as each `Canvas` has a large fixed GPU cost. |
|
||||
| `edge.rs` `Edge` | Service, Hyprland sockets | socket2 events, no polling: bar hiding, `magic-edge`, peeks, workspace slide (`switched`, `created`), `hide_h()`, `magic-reserve`. |
|
||||
| `tray.rs`, `png.rs` | Services | StatusNotifierWatcher and host, icons from themes or pixmaps, dbusmenu menus. |
|
||||
|
||||
The equalizer: one `setsid -f pipewire -c eq-<key>.conf` per player with `libpipewire-module-filter-chain`, 10 biquads (low shelf 31 Hz, peaking 63 Hz–8 kHz, high shelf 16 kHz). The player's streams move 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 first changes, or it has a saved non-flat curve.
|
||||
### The island view
|
||||
|
||||
### The view (island.rs `view`)
|
||||
- One `LayerWindow` per monitor (`magic-island`, `Layer::Overlay`), 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` per monitor (`window_per_monitor`, 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.
|
||||
- No player: `idle()`, a static click-through pill with a drawn note and "Nothing playing".
|
||||
- Monitors: `tap(monitor)` opens the card where it was clicked, `expand()` (IPC) on the focused monitor; `open_on` restarts the animation when the card moves. Other monitors render `phases(0.0)`, their pill. Only the focused window and the one with the card read `Spectrum` (reading a Service is what subscribes a window), so the rest stay idle.
|
||||
- `phases(open)` splits 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). At most one face is built per frame. `veiled()` fades a face with a backdrop-coloured veil instead of `.opacity()`.
|
||||
- Background: the wallpaper surfaces as one 150° three-stop gradient (`backdrop()`); with `cover_colors` the card's background blends to `backdrop_colors(Palette)` (dominant, `colors()[1]`, `background()`, darkened by `deepen()` to 0.22 / 0.15 / 0.07 brightness) as it opens. White-alpha overlays (`CHIP`, `TRACK`, `CHIP_HOVER`, `CLEAR`) stay unthemed.
|
||||
- Hover: `hovering(spot)` returns the `on_hover` closure easing that `Spot` to 0/1 over 220 ms; the view reads `island.lit(spot)`. Amane matches hover targets by their 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. Effects are colour blends and `.scale()`/`.translate()`; transport buttons go bold (`bold_icon`: the glyph's own outline, 0–2 px).
|
||||
- Transport: play/pause flips at once through `playing_now`; after any transport click `refresh_soon()` re-reads Media at 150 and 600 ms (Media polls once a second). `island play-pause` over IPC takes the same path.
|
||||
- `pill_levels()`: 7 bars, bar *i* = loudest of spectrum slice *i*, ×1.4, linear, drawn up and down from the middle. `ring()`: 40 candles, levels from `mirrored()`, 3 paths (glow, body, flame tips).
|
||||
- Moving the pointer off the open card collapses it.
|
||||
## Settings
|
||||
|
||||
- **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 }`.
|
||||
|
||||
## Hyprland 0.56 (Lua config) pitfalls
|
||||
|
||||
- **Never return `visible(false)` from a layer view.** Hyprland configures a layer surface before its first commit, so a hidden-at-start window gets a size, Amane logs "`<name>` has no child to draw" and the window stays blank forever. With nothing to show, return `window.click_through().child(Rectangle::new().width(0.0).height(0.0))`.
|
||||
- **`Workspaces::focus` does nothing:** it sends `dispatch workspace N`, which the Lua config rejects. Use `hl.dsp.focus({ workspace = N })`. Test dispatches harmlessly on the already-focused workspace.
|
||||
- `hyprctl eval` / `hyprctl repl` run Lua in one shared state; globals (e.g. a layer rule kept in a variable) vanish when Hyprland reloads its config.
|
||||
- Layer-rule `match.namespace` is a full match: `^magic-` matches nothing, `^magic-.*$` works. Layer rules can only switch blur on; blur strength (`decoration.blur.size`, `passes`) is global.
|
||||
- `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead instances; the live one has `.socket.sock` (`amane-remote.sh` picks it). A shell started with a dead signature gets an empty `Workspaces` list.
|
||||
- `hyprctl layers` lists hidden layer windows too (alpha 0); filter on `alpha > 0`.
|
||||
- **Reserved space and fullscreen:** a layer with `Zone::Reserve` reserves its whole height (amane's `measure_thickness`), and only on a window anchored to one edge (or an edge plus both sides), so corner-anchored or card-sized windows can't reserve. Hyprland keeps tiled and maximised (fullscreen 1) windows out of the reserved strip and lets fullscreen 2/3 cover it. Bar windows must keep `Zone::Ignore`, otherwise they get pushed down below the strip. Check with `hyprctl monitors` (`reserved: 0 44 0 0`) and a tiled window's `at:`.
|
||||
- **Hyprland state comes from its sockets, not polling:** `.socket2.sock` streams events, `.socket.sock` answers `monitors`/`clients`. Fullscreen 2 and 3 hide the bar; 1 (maximised) keeps it.
|
||||
- A peek (workspace or island) only shows on the **focused** monitor, and only matters on a fullscreen one; test with a workspace hop between two fullscreen workspaces there, then restore the focus.
|
||||
- **Never return `visible(false)` from a layer view**: a hidden-at-start layer window stays blank forever. Return `window.click_through().child(Rectangle::new().width(0.0).height(0.0))`.
|
||||
- **`Workspaces::focus` does nothing.** Use `hl.dsp.focus({ workspace = N })`. Special workspaces: `hl.exec_cmd(cmd, { workspace = "special:x silent" })`, `hl.dsp.workspace.toggle_special("x")`.
|
||||
- `hyprctl eval` / `repl` share one Lua state; globals vanish on a config reload.
|
||||
- Layer-rule `match.namespace` is a full match: `^magic-.*$`.
|
||||
- `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead instances; the live one has `.socket.sock`.
|
||||
- `hyprctl layers` lists hidden layer windows too (alpha 0).
|
||||
- **Reserved space:** `Zone::Reserve` reserves the whole window height, only on a window anchored to one edge. Check with `hyprctl monitors` (`reserved: 0 44 0 0`).
|
||||
- A peek only shows on the **focused** monitor. A monitor whose workspace has a fullscreen window hides the bar, pods included (an open pod card stays).
|
||||
|
||||
## Performance rules
|
||||
|
||||
- **Measure before and after every visual change**, baseline first, with the same player and monitor. The GPU-busy counter (`gt0/gtidle/idle_residency_ms`) reads 100% even with the shell paused, so compare Amane's per-frame `gpu` time and `top` CPU instead.
|
||||
- Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in.
|
||||
- `.clip()` and `.opacity(<1)` each add a GPU layer (a clip over the whole card once took the iGPU from 47 to 9 fps). A shadow is two layers per frame: halos only while hovered.
|
||||
- Gradients cost: a gradient background plus 10 gradient fills halved the frame rate; one backdrop gradient is affordable. A veil over a gradient must be the same gradient at the same size (Amane places gradients by each rectangle's own bounds).
|
||||
- Draw repeated marks (candles, bars) as one `Path` per look.
|
||||
- Streaming data needs `amane::request_frame()` in the view while levels are live; a Service write alone got about 11 redraws a second from Hyprland.
|
||||
- `Rectangle::child` holds one child; stack layers with `Stack`.
|
||||
- **Measure before and after every visual change**, same player and monitor: Amane's per-frame `gpu` time and `top` CPU.
|
||||
- Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in. Don't resize a layer window every frame: size it for the whole animation.
|
||||
- `.clip()` and `.opacity(<1)` each add a GPU layer; a shadow is two. Halos only while hovered.
|
||||
- One backdrop gradient is affordable, many aren't.
|
||||
- Draw repeated marks as one `Path` per look; many icons or graphs in one `Canvas`.
|
||||
- Streaming data needs `amane::request_frame()` while it's live. Slow work (icon lookups, curl, sysfs walks) belongs on Service threads, never in a view.
|
||||
|
||||
## Develop
|
||||
|
||||
1. Type-check on the dev host without the desktop, in a scratch crate outside the repo (don't commit a `Cargo.toml`). Scratch at `/root/.hermes/cache/scratch/amane-check/` (pruned after 24 h idle; recreate it):
|
||||
|
||||
```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` there. The first build takes minutes.
|
||||
2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (10 tests).
|
||||
1. Type-check in a scratch crate outside the repo (don't commit a `Cargo.toml`), at `/root/.hermes/cache/scratch/amane-check/` (pruned after 24 h idle): `[package] name = "amane-shell"`, edition 2024, `[[bin]] path = '/root/amane-config/src/main.rs'`, `amane = { path = '<copy of the desktop's ~/git/amane>' }`, an empty `[workspace]`. Then `cargo clippy`. Fetch the copy with `ssh … 'tar cf - --exclude=target --exclude=.git -C ~/git amane'`.
|
||||
2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (21 tests).
|
||||
|
||||
## Testing on the desktop
|
||||
|
||||
`tools/amane-remote.sh HOST COMMAND`; the full list is at the top of the script.
|
||||
`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
|
||||
H=fabian@192.168.2.169
|
||||
tools/amane-remote.sh $H deploy src # back up the remote src once, then replace it
|
||||
tools/amane-remote.sh $H build # amane compile
|
||||
tools/amane-remote.sh $H start # stop the shell it started earlier, start one WITH frame logging
|
||||
tools/amane-remote.sh $H start # start a shell WITH frame logging
|
||||
tools/amane-remote.sh $H fps 3 # frames, average gap, gpu wait
|
||||
tools/amane-remote.sh $H anim island # open/close 3x; stutter = 20-500 ms gaps
|
||||
tools/amane-remote.sh $H shot magic-island out.png
|
||||
tools/amane-remote.sh $H sinks start # null sinks island_test_a/_b
|
||||
tools/amane-remote.sh $H players start 4 # fake MPRIS players; "Synth Test" plays a synth into island_test_a
|
||||
tools/amane-remote.sh $H ipc island status
|
||||
tools/amane-remote.sh $H players start 4 # fake MPRIS players
|
||||
tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical session's environment
|
||||
```
|
||||
|
||||
- Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound; stopping the fake players lets the island fall back to the user's player.
|
||||
- `ipc island open` opens on the **focused** monitor (the user's, if they're at the desk). Read the geometry from `hyprctl layers -j` on that monitor right before each `grim` capture. The user's pointer can open or collapse the island mid-test.
|
||||
- With one window per monitor the frame log mixes windows; resting ones show ~1000 ms gaps.
|
||||
- Design screenshots: set `glass` in the remote `shell.conf` (applies live), wait ~1 s, capture pill, workspace pill and the card (`ipc island open`, wait ~2.5 s).
|
||||
- Hover can't be tested with `hyprctl dispatch movecursor` (no motion events reach the surface); real motion needs a virtual pointer and moves the user's mouse, so ask first.
|
||||
- Before trusting timings, check `uptime`: a background Gentoo build once made every build look broken.
|
||||
- A fresh shell once exited with "lost the gpu (Device is lost)" (Mesa warns Xe is experimental); starting again worked.
|
||||
- To tune a visualiser, sample real levels read-only (`pw-record --target <stream> | cava`, `timeout -s KILL` on each stage; a plain `timeout` on the pipeline orphans cava) first. Typical music sits at 0.15–0.35.
|
||||
- In `pkill -f` patterns over SSH, use `[p]ython3 ...` so the pattern doesn't match the remote shell itself (a plain `pgrep -f fake_tray.py` once killed the test script).
|
||||
- **Control centre and tray without a mouse:** `ipc control open|close|menu <wifi|bluetooth|vpn|sound>|ethernet|wifi search|status`, `ipc tray list|menu|open N|activate N|choose ID|enter ID|back|close`. `control status` ends with `prompt=<what the popup shows>`. A fake tray app: `python3 tools/fake_tray.py name|path` (by bus name, or path-only like libappindicator; it prints every call it gets). Kill it by pid.
|
||||
- **Maps with many nodes:** with Wi-Fi off the maps are empty. Deploy a scratch copy of `src/` whose `map::map` injects fake nodes (never commit it), then redeploy the real `src`. Pick sizes by simulating the drift in Python, not from single screenshots.
|
||||
- **Measure gaps and alignment from pixels**, not by asking a vision model: `ffmpeg -i shot.png -vf crop=1920:1:0:Y -f rawvideo -pix_fmt rgb24 row.raw` and look for the border pixels.
|
||||
- To see the island and the control centre together, open them on a monitor without a fullscreen window (focus one of its workspaces first, restore after).
|
||||
- Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound.
|
||||
- `ipc island open` and `ipc pods open X` act on the **focused** monitor (pods take a monitor name too). Read geometry from `hyprctl layers -j` right before each `grim` capture, on a monitor without a fullscreen window.
|
||||
- **Animations:** capture a strip in a loop (`grim -g "3840,0 300x60"`) while dispatching the change, then tile the frames with ffmpeg.
|
||||
- Keys: `wtype -k Escape`. Hover can't be faked with `movecursor`; real motion moves the user's mouse, so ask first.
|
||||
- Without a mouse: `ipc control open|close|menu …|status`, `ipc tray list|menu|open N|…`, `ipc pods …`. Notifications: `notify-send -a App -i icon -u critical -A key=Label …`, then `ipc pods notices list`.
|
||||
- Measure gaps and alignment from pixels (`ffmpeg … crop=… -f rawvideo`), not by asking a vision model.
|
||||
- Check `uptime` before trusting timings. In `pkill -f` patterns over SSH use `[p]ython3 ...`.
|
||||
|
||||
### After testing
|
||||
|
||||
```sh
|
||||
tools/amane-remote.sh $H players stop
|
||||
tools/amane-remote.sh $H sinks stop
|
||||
tools/amane-remote.sh $H sh 'P=$(pgrep -f "^/usr/lib/python-exec/python3[.0-9]*/python3 fake_tray"); [ -n "$P" ] && kill $P' # fake tray apps
|
||||
tools/amane-remote.sh $H sh 'kill $(cat shell.pid); for i in $(seq 50); do pgrep -F shell.pid > /dev/null || break; sleep 0.1; done
|
||||
rm -f shell.log; setsid -f sh -c "echo \$\$ > shell.pid; exec ~/.cache/amane/amane-shell" > /dev/null 2>&1'
|
||||
rm -f shell.log; setsid -f sh -c "echo \$\$ > shell.pid; exec ~/.cache/amane/amane-shell 2> shell.err" > /dev/null 2>&1'
|
||||
```
|
||||
|
||||
## Open threads
|
||||
|
||||
- The user picks a glass design; then rebase `glass-base` plus that design onto master (they predate the control centre, whose windows draw their own surfaces), merge, and update README/AGENTS.md.
|
||||
- **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.
|
||||
- The Amane library could fix the two Hyprland 0.56 issues upstream (handle a configure on a hidden layer window; use the Lua dispatch in `Workspaces::focus`). Not started.
|
||||
- `island-eq` is tracked in git but rewritten live by the island, so the desktop checkout always shows it changed; untracking it was proposed, no answer yet.
|
||||
- Leftovers offered for deletion, no answer yet: `src.before-test/` on the desktop, seven merged local branches on the dev host. New since: the merged local `control-centre` branch, and old screenshots in the desktop's `~/.cache/amane-remote-test/` (`dimtest.png`, `grab.png`, `shot.png`, `ws-after.png`, `shots/`).
|
||||
- On a monitor narrower than about 1500 px the maps stop at 600 px (`MIN_MAP_W`) and run under the island.
|
||||
- The open/close stutter rise (25–40 slow frames per 3 opens and closes on every build) is unexplained.
|
||||
- Upstream Amane fixes for the two Hyprland 0.56 issues: not started.
|
||||
- `island-eq` is tracked but rewritten live; untracking it was proposed, no answer. Leftovers offered for deletion, no answer: `src.before-test/` and old screenshots on the desktop, merged and superseded local branches.
|
||||
- On a monitor narrower than about 1500 px the maps run under the island.
|
||||
- The open/close stutter rise (25–40 slow frames per 3 opens and closes) is unexplained.
|
||||
- See README "Known limits".
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card.
|
||||
|
||||
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing".
|
||||
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing". It opens too, into the last song played (kept across restarts) with Play, which starts its player and asks it to play, and chips that start the players of `island.idle.players` (desktop file names, `gtk-launch`).
|
||||
- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next.
|
||||
- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays.
|
||||
- **Output switcher:** moves only the shown player's audio to another device.
|
||||
@@ -32,7 +32,7 @@ The panel has:
|
||||
- *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 `extra_output` in `shell.conf` 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.
|
||||
- **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.
|
||||
@@ -74,7 +74,7 @@ iwd turns the Wi-Fi device into an access point, so the laptop isn't a Wi-Fi cli
|
||||
DNSList=<your router, or a public resolver>
|
||||
```
|
||||
|
||||
3. `hotspot = <Name>` in `shell.conf` (applies live).
|
||||
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
|
||||
@@ -109,16 +109,73 @@ Register the template in Skwd-wall as a matugen integration:
|
||||
|
||||
The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`).
|
||||
|
||||
`shell.conf` (read live, no restart):
|
||||
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).
|
||||
|
||||
```
|
||||
cover_colors = false # true: the open media card and equalizer take the song cover's colours as background
|
||||
transition_ms = 400 # how long a colour change cross-fades; 0 switches instantly
|
||||
hotspot = MyHotspot # the iwd access point profile the control centre's hotspot starts
|
||||
extra_output = NR515 # a second Sound slider for the output whose name contains this
|
||||
## Side pills
|
||||
|
||||
Two small one-icon pills sit left of the island and two right of it, on every monitor, each in its own colour (`pills.colors.*`; Home's follows the sky). A click grows a pill into a big card in the middle of the top of the screen; meanwhile the row becomes a carousel: the other pills and the island's pill line up two left and two right of the card in their usual order (around Home: AI and System left, the island and the bell right), and clicking one switches straight to its card (the island's pill closes the card, then opens the island). A closing card shrinks back into its own pill's place. Only one card is open across the shell. Esc, the cross or a click elsewhere closes it.
|
||||
|
||||
- **AI** (sparkle): a chat with 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).
|
||||
|
||||
## Lock screen
|
||||
|
||||
`amane ipc call lock` locks the session (ext-session-lock; bind it to a key or an idle daemon yourself). Every monitor shows the wallpaper blurred and darkened, the date with Munich's weather now, a big clock, Hyprland's splash message, what plays with its cover, how far it is and previous/play/next (they work while locked), chips for the next calendar event, the new notifications and the battery, and at the bottom `~/.face`, your name and the password field. The monitors share one field. Enter checks the password with PAM as `login` does; a wrong one lights the field's rim and says so. Each part can be switched off and every size is a setting (`lock.look.*`, `lock.show.*`, the Lock screen page).
|
||||
|
||||
The picture is `lock.look.wallpaper`, or else the wallpaper the colours came from: `matugen/amane.colors` writes its path into the theme file (`wallpaper=`), so it's there after the next wallpaper change. Without either, the colours' gradient. Amane reads PNG and JPEG only.
|
||||
|
||||
It redraws for the music's progress, the clock and typing, so it's cheap to draw: the backdrop (the gradient, and the veil over the picture, darker toward the corners, `lock.look.vignette`) is one shader, `src/lock.wgsl`, and every icon goes into one canvas. Measured in a preview on one 1080p monitor: 0.8 ms per frame to submit and present (2.4 ms before), the GPU's render engine half as busy.
|
||||
|
||||
Killing the shell does not unlock: Hyprland keeps the session locked and shows its own error screen. Keep a second TTY logged in when trying changes to the lock screen.
|
||||
|
||||
## Settings
|
||||
|
||||
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), `liquid` (clear glass, denser toward the bottom right, a light-catching edge, the accent's glow) or `glass` (after Apple's Liquid Glass: a thin tint and an edge like the rim of a lens, brightest where the light falls in at the top left, a glint where it leaves at the bottom right, a thin specular line and a faint warm and cool fringe). Each design's opacity, rim and light are settings of their own (`appearance.solid|frosted|liquid|glass.*`); the look itself is `src/surface.rs`, drawn by one shader (`src/surface.wgsl`: the shadow, the fill and the sheen or lit edge in one pass; the rim stays a border). `glass` has its own, `src/glass.wgsl`, drawn twice: the shadow and tint under the content, the lit edge over it, so a photo on a card lies behind the glass. Unlike Apple's, it doesn't bend the desktop at the rim: a shell's shader never sees what's behind its window, Hyprland's blur is all it has. On see-through glass a face fades with `.opacity()` instead of a veil, a GPU layer only while it fades.
|
||||
|
||||
Screenshots of each design, from when they were made: `docs/design/` (`base-solid-card.png`, `base-glass-0.5-card.png` for solid below 1, `frosted-0.62-card.png`, `liquid-0.4-card.png`, and a board for each glass design: the card three times side by side, with the pills below).
|
||||
|
||||
### Performance
|
||||
|
||||
Amane draws rectangles, borders, letters and pictures as cheap quads, and gradients, shadows and `Canvas` paths with vello, which renders over the whole window and then lays the result on top. vello keeps a result between frames only when its shapes didn't change and hold no gradient, so anything drawn with it on every frame costs a pass over the whole window. What redraws every frame is therefore kept off vello: the surfaces are a shader, the pill's bars are rounded rectangles, the candle ring and the equalizer's curve are shaders (`src/ring.wgsl`, `src/curve.wgsl`). A shader's file is written to `$XDG_RUNTIME_DIR/magic-island/` when it is first drawn. A shader must not contain the word `time`, or amane redraws its window on every frame.
|
||||
|
||||
`tools/perf.sh PID [SECONDS]` (on the desktop) prints a process's CPU (and its children's), GPU per engine (DRM fdinfo) and memory.
|
||||
|
||||
Amane can only blur what the shell draws, so the frosting comes from Hyprland: a layer rule for the shell's windows (the namespace match is a full match). `ignore_alpha` leaves pixels at or below that opacity unblurred, which keeps the clear margin around the pills sharp; keep it below the design's thinnest part, or that part shows the desktop unblurred (liquid at its default 0.4 is 0.22 in its top left corner; glass is its `opacity` all over, 0.32 by default).
|
||||
|
||||
```lua
|
||||
hl.layer_rule({
|
||||
name = "amane-glass",
|
||||
match = { namespace = "^magic-.*$" },
|
||||
blur = true,
|
||||
ignore_alpha = 0.2,
|
||||
})
|
||||
```
|
||||
|
||||
Keep comments on lines of their own in the real file: a `#` after a value becomes part of it.
|
||||
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
|
||||
|
||||
@@ -137,12 +194,17 @@ src/tray.rs the system tray: StatusNotifierWatcher and host, icon lookup,
|
||||
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, shell.conf, the cross-fade
|
||||
src/theme.rs the shell's colours: the matugen theme file, the cross-fade; watches config.toml
|
||||
src/lock.rs the lock screen (amane's App::lock), `lock` IPC
|
||||
src/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs as values for surface.wgsl, glass for glass.wgsl
|
||||
src/*.wgsl shaders: surface (every surface), glass (the glass design), ring (the candles), curve (the equalizer's line), lock (the lock screen's backdrop)
|
||||
src/config.rs every setting, declared once (typed accessors + the window's list), config.toml read and written
|
||||
src/hypr.rs Hyprland's options for the config window: hyprctl descriptions/getoption, hl.config, amane.lua
|
||||
src/settings.rs the config window, `config` IPC
|
||||
src/parse.rs pure text parsing with unit tests, no amane dependency
|
||||
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme
|
||||
shell.conf settings: cover_colors, transition_ms, hotspot, extra_output
|
||||
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq)
|
||||
tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), see AGENTS.md
|
||||
tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), perf.sh (CPU, GPU, memory of a process), see AGENTS.md
|
||||
```
|
||||
|
||||
## Requirements
|
||||
@@ -153,11 +215,12 @@ On the desktop that runs the shell:
|
||||
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
|
||||
- `cava`, `curl`
|
||||
- the Fira Code font
|
||||
- PAM (`/etc/pam.d/login`), for the lock screen's password
|
||||
- for the control centre: `iwd`, openrc `openvpn.<name>` services, `pciutils` (`lspci`), `wl-clipboard` (`wl-copy`, for copying a saved password or an address), `iproute2` (`ip`, for the Ethernet details), optionally `bluez` (with `bluetoothctl`), and a doas rule without a password for `rc-service`, `sh` and `cat` (see [Control centre](#control-centre))
|
||||
|
||||
## Install and run
|
||||
|
||||
The shell lives in `~/.config/amane/`. Clone this repo there (or copy its `src/`, `matugen/` and `shell.conf`), 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
|
||||
amane compile
|
||||
@@ -176,6 +239,15 @@ amane ipc call island menu output # open a dropdown: output | player | clos
|
||||
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 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>
|
||||
amane ipc call lock # lock the session
|
||||
```
|
||||
|
||||
Example Hyprland keybind:
|
||||
@@ -186,8 +258,11 @@ bind = SUPER, M, exec, amane ipc call island toggle
|
||||
|
||||
## 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>`.
|
||||
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout.
|
||||
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`, the shaders (`*.wgsl`). On tmpfs, gone at logout.
|
||||
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
|
||||
|
||||
## Known limits
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 328 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 880 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 381 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -10,3 +10,5 @@ outline={{colors.outline_variant.default.hex}}
|
||||
surface={{colors.surface_container.default.hex}}
|
||||
surface_high={{colors.surface_container_high.default.hex}}
|
||||
surface_low={{colors.surface_container_lowest.default.hex}}
|
||||
# the picture the colours came from, for the lock screen
|
||||
wallpaper={{image}}
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
# amane shell settings, picked up the moment this file is saved: no restart needed.
|
||||
# the colours themselves come from the wallpaper (matugen/amane.colors, written to ./theme)
|
||||
|
||||
# true: the open media card and equalizer take the song cover's colours as their background
|
||||
# false: the whole shell uses the wallpaper's colours
|
||||
cover_colors = false
|
||||
|
||||
# how long a colour change cross-fades, in milliseconds; 0 switches instantly
|
||||
transition_ms = 400
|
||||
|
||||
# the control centre's hotspot: the name of an iwd access point profile, /var/lib/iwd/ap/<name>.ap
|
||||
# (see README "Control centre"); leave it out and the hotspot tile says so
|
||||
# hotspot = MyHotspot
|
||||
|
||||
# a second slider under Sound in the control centre, for the output whose name contains this
|
||||
extra_output = NR515
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
+11
-1
@@ -7,6 +7,7 @@ use std::{env, fs, thread};
|
||||
use amane::{Media, Service};
|
||||
|
||||
use crate::island::{self, Island};
|
||||
use crate::config::island::timing;
|
||||
use crate::parse;
|
||||
|
||||
/*
|
||||
@@ -41,8 +42,17 @@ impl Service for Art {
|
||||
*Self::write() = Self { url, path };
|
||||
}
|
||||
|
||||
// the song playing, kept for the card shown while nothing plays
|
||||
let (url, path) = {
|
||||
let art = Self::read();
|
||||
|
||||
(art.url.clone(), art.path.clone())
|
||||
};
|
||||
|
||||
crate::last::watch(&url, &path);
|
||||
|
||||
// 4 checks a second: after next/previous the new cover follows the title quickly
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
thread::sleep(Duration::from_millis(timing::cover_check_ms()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1118
File diff suppressed because it is too large
Load Diff
+226
-328
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);
|
||||
}
|
||||
+51
-29
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* 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 DWELL_MS; everywhere else they're always there. some
|
||||
* 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
|
||||
@@ -22,23 +22,14 @@ use std::{env, thread};
|
||||
|
||||
use amane::{Animation, Full, Layer, LayerWindow, Media, Monitor, Parent, Rectangle, Service, Vertical, Zone};
|
||||
|
||||
use crate::island::{COMPACT_H, SHADOW, TOP_GAP};
|
||||
use crate::config::bar::{hiding, peeks};
|
||||
use crate::config::island::pill;
|
||||
use crate::parse;
|
||||
|
||||
const DWELL_MS: u64 = 300;
|
||||
const HIDE_MS: u64 = 700;
|
||||
const SLIDE_MS: u64 = 220;
|
||||
|
||||
// how long a peek stays
|
||||
const PEEK_WORKSPACES_MS: u64 = 1600;
|
||||
const PEEK_ISLAND_MS: u64 = 3500;
|
||||
|
||||
// the island's pop on a new song, and the workspaces' highlight sliding over, in seconds
|
||||
const POP_S: f32 = 0.7;
|
||||
const SWITCH_S: f32 = 0.35;
|
||||
|
||||
// how far a pill slides up to be out of sight, glow and all
|
||||
pub const HIDE_H: f32 = TOP_GAP + COMPACT_H + SHADOW;
|
||||
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)]
|
||||
@@ -65,6 +56,9 @@ pub struct Edge {
|
||||
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,
|
||||
@@ -93,6 +87,16 @@ impl Service for Edge {
|
||||
|
||||
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"
|
||||
@@ -120,13 +124,13 @@ impl Edge {
|
||||
self.shown(monitor).max(peek)
|
||||
}
|
||||
|
||||
// 0 to 1 and back over POP_S after a new song; the view asks for frames while it's above 0
|
||||
// 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() / POP_S;
|
||||
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 }
|
||||
}
|
||||
@@ -134,11 +138,16 @@ impl Edge {
|
||||
// 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() / SWITCH_S;
|
||||
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)
|
||||
}
|
||||
@@ -153,7 +162,7 @@ fn socket(name: &str) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
// a question to hyprland, as `hyprctl <request>` would ask it; empty if it can't be asked
|
||||
fn hyprctl(request: &str) -> String {
|
||||
pub fn hyprctl(request: &str) -> String {
|
||||
let Some(mut stream) = socket(".socket.sock").and_then(|path| UnixStream::connect(path).ok()) else {
|
||||
return String::new();
|
||||
};
|
||||
@@ -251,8 +260,8 @@ fn peek(edge: &mut Edge, part: Part, monitor: &str) {
|
||||
entry.1 = began;
|
||||
|
||||
let hold = match part {
|
||||
Part::Island => PEEK_ISLAND_MS,
|
||||
Part::Workspaces => PEEK_WORKSPACES_MS,
|
||||
Part::Island => peeks::island_ms(),
|
||||
Part::Workspaces => peeks::workspaces_ms(),
|
||||
};
|
||||
|
||||
thread::spawn(move || {
|
||||
@@ -282,7 +291,7 @@ fn watch_media() {
|
||||
let mut last: Option<(Vec<String>, String)> = None;
|
||||
|
||||
loop {
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
thread::sleep(Duration::from_millis(peeks::media_check_ms()));
|
||||
|
||||
let (players, song) = {
|
||||
let media = Media::read();
|
||||
@@ -301,10 +310,15 @@ fn watch_media() {
|
||||
};
|
||||
|
||||
if let Some((known, before)) = &last {
|
||||
let new_player = players.iter().any(|name| !known.contains(name));
|
||||
let new_player = players.iter().find(|name| !known.contains(name));
|
||||
let new_song = !before.is_empty() && song != *before;
|
||||
|
||||
if new_player || new_song {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -313,6 +327,14 @@ fn watch_media() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -362,7 +384,7 @@ fn at_edge(monitor: String) -> impl Fn(bool) + 'static {
|
||||
|
||||
// still resting there after the dwell: this monitor's bar comes back
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(DWELL_MS));
|
||||
thread::sleep(Duration::from_millis(hiding::dwell_ms()));
|
||||
|
||||
let mut edge = Edge::write();
|
||||
|
||||
@@ -375,10 +397,10 @@ fn at_edge(monitor: String) -> impl Fn(bool) + 'static {
|
||||
}
|
||||
}
|
||||
|
||||
// gone again after HIDE_MS off the bar and the edge, unless the pointer came back meanwhile; peeks with it
|
||||
// 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(HIDE_MS));
|
||||
thread::sleep(Duration::from_millis(hiding::hide_ms()));
|
||||
|
||||
let mut edge = Edge::write();
|
||||
|
||||
@@ -395,7 +417,7 @@ fn hide_soon(moves: u32) {
|
||||
}
|
||||
|
||||
fn sliding(at: f32) -> Animation {
|
||||
Animation::new(at).duration(Duration::from_millis(SLIDE_MS))
|
||||
Animation::new(at).duration(Duration::from_millis(hiding::slide_ms()))
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -428,7 +450,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
pub fn spacer(_: &Monitor) -> LayerWindow {
|
||||
LayerWindow::new()
|
||||
.width(Full)
|
||||
.height(TOP_GAP + COMPACT_H)
|
||||
.height(pill::top_gap() + pill::height())
|
||||
.anchor_vertical(Vertical::Top)
|
||||
.layer(Layer::Background)
|
||||
.space(Zone::Reserve)
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
// glass after Apple's Liquid Glass, as far as a surface that can't see what's behind it
|
||||
// gets: a thin, lightly tinted middle over Hyprland's blur, and an edge drawn like the rim
|
||||
// of a lens: a band that brightens toward the rim where the light falls in (top left) and
|
||||
// where it leaves again (bottom right), a thin specular line on the rim itself, and a faint
|
||||
// warm and cool fringe. surface.rs fills the values:
|
||||
// 0: shape width, height, corner radius, margin (room around the shape for the shadow)
|
||||
// 1: shadow colour, its alpha already scaled by the shadow's strength
|
||||
// 2: shadow deviation, its x and y offset, lift (0 to 1, hovered or raised)
|
||||
// 3: width of the edge band in pixels, light, rim and fringe strengths (0 to 1)
|
||||
// 4-6: the tint, top left to bottom right, plain colours with the middle's opacity as alpha
|
||||
// 7.x: the layer: 1 the shadow and the tint, under the content; 2 the lit edge and the rim,
|
||||
// over it (a photo on the card then lies behind the glass); 0 both
|
||||
|
||||
fn erf7(x: f32) -> f32 {
|
||||
let y = clamp(x * 1.1283791671, -100.0, 100.0);
|
||||
let yy = y * y;
|
||||
let z = y + (0.24295 + (0.03395 + 0.0104 * yy) * yy) * (y * yy);
|
||||
|
||||
return z / sqrt(1.0 + z * z);
|
||||
}
|
||||
|
||||
// vello's blurred rounded rectangle at p, from its centre, as in surface.wgsl
|
||||
fn shadow(p: vec2<f32>, w: f32, h: f32, radius: f32, deviation: f32) -> f32 {
|
||||
let s = max(deviation, 1e-5);
|
||||
let inv = 1.0 / s;
|
||||
let min_edge = min(w, h);
|
||||
let radius_max = 0.5 * min_edge;
|
||||
let r0 = min(length(vec2<f32>(radius, s * 1.15)), radius_max);
|
||||
let r1 = min(length(vec2<f32>(radius, s * 2.0)), radius_max);
|
||||
let exponent = 2.0 * r1 / r0;
|
||||
|
||||
let delta = 1.25 * s * (exp(-pow(0.5 * inv * w, 2.0)) - exp(-pow(0.5 * inv * h, 2.0)));
|
||||
let width = w + min(delta, 0.0);
|
||||
let height = h - max(delta, 0.0);
|
||||
|
||||
let scale = 0.5 * erf7(inv * 0.5 * (max(width, height) - 0.5 * radius));
|
||||
|
||||
let y0 = abs(p.y) - (height * 0.5 - r1);
|
||||
let x0 = abs(p.x) - (width * 0.5 - r1);
|
||||
let d_pos = pow(pow(max(x0, 0.0), exponent) + pow(max(y0, 0.0), exponent), 1.0 / exponent);
|
||||
let d = d_pos + min(max(x0, y0), 0.0) - r1;
|
||||
|
||||
return scale * (erf7(inv * (min_edge + d)) - erf7(inv * d));
|
||||
}
|
||||
|
||||
// signed distance to a rounded rectangle centred on 0, negative inside
|
||||
fn rounded(p: vec2<f32>, half: vec2<f32>, radius: f32) -> f32 {
|
||||
let r = min(radius, min(half.x, half.y));
|
||||
let q = abs(p) - half + vec2<f32>(r);
|
||||
|
||||
return length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - r;
|
||||
}
|
||||
|
||||
// the way out of the shape at p: across a side, or out from a corner's centre
|
||||
fn outward(p: vec2<f32>, half: vec2<f32>, radius: f32) -> vec2<f32> {
|
||||
let r = min(radius, min(half.x, half.y));
|
||||
let q = abs(p) - half + vec2<f32>(r);
|
||||
let side = select(vec2<f32>(-1.0), vec2<f32>(1.0), p >= vec2<f32>(0.0));
|
||||
|
||||
if q.x > 0.0 && q.y > 0.0 {
|
||||
return normalize(q) * side;
|
||||
}
|
||||
|
||||
return select(vec2<f32>(0.0, side.y), vec2<f32>(side.x, 0.0), q.x > q.y);
|
||||
}
|
||||
|
||||
// premultiplied colours, one over the other
|
||||
fn over(top: vec4<f32>, under: vec4<f32>) -> vec4<f32> {
|
||||
return top + under * (1.0 - top.a);
|
||||
}
|
||||
|
||||
// amane blends a plain colour over the window
|
||||
fn plain(color: vec4<f32>) -> vec4<f32> {
|
||||
if color.a <= 0.0 {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
|
||||
return vec4<f32>(color.rgb / color.a, color.a);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||
let shape = values[0];
|
||||
let extent = shape.xy;
|
||||
let centre = extent / 2.0;
|
||||
let lift = values[2].w;
|
||||
let edge = max(values[3].x, 1.0);
|
||||
let light = values[3].y * (1.0 + 0.35 * lift);
|
||||
let rim = values[3].z * (1.0 + 0.35 * lift);
|
||||
let fringe = values[3].w;
|
||||
|
||||
let layer = u32(values[7].x);
|
||||
|
||||
let p = uv * size - vec2<f32>(shape.w);
|
||||
let pixel = max(fwidth(p.x), 1e-4);
|
||||
let from_centre = p - centre;
|
||||
|
||||
let shade = select(shadow(from_centre - values[2].yz, extent.x, extent.y, shape.z, values[2].x) * values[1].a, 0.0, layer == 2u);
|
||||
let under = vec4<f32>(values[1].rgb * shade, shade);
|
||||
|
||||
let d = rounded(from_centre, centre, shape.z);
|
||||
let inside = clamp(0.5 - d / pixel, 0.0, 1.0);
|
||||
|
||||
if inside <= 0.0 {
|
||||
return plain(under);
|
||||
}
|
||||
|
||||
// the tint, a gradient over the shape from its top left to its bottom right
|
||||
let t = clamp(dot(p / max(extent, vec2<f32>(1.0)), vec2<f32>(0.5)), 0.0, 1.0);
|
||||
let tint = select(mix(values[5], values[6], (t - 0.5) * 2.0), mix(values[4], values[5], t * 2.0), t < 0.5);
|
||||
|
||||
var glass = select(vec4<f32>(tint.rgb * tint.a, tint.a), vec4<f32>(0.0), layer == 2u);
|
||||
|
||||
if layer == 1u {
|
||||
return plain(over(glass * inside, under));
|
||||
}
|
||||
|
||||
// the lens: 1 on the rim, 0 deeper in than `edge`, steepest at the rim like a bevel
|
||||
let band = pow(1.0 - smoothstep(0.0, edge, max(-d, 0.0)), 2.0);
|
||||
|
||||
// which way this part of the rim faces: toward the light (top left) is 1, away is -1
|
||||
let facing = dot(outward(from_centre, centre, shape.z), vec2<f32>(-0.7071, -0.7071));
|
||||
|
||||
// a faint warm fringe toward the light and a cool one away from it
|
||||
let hue = mix(vec3<f32>(0.75, 0.85, 1.0), vec3<f32>(1.0, 0.9, 0.78), facing * 0.5 + 0.5);
|
||||
let tone = mix(vec3<f32>(1.0), hue, fringe);
|
||||
|
||||
// brightest where the light comes in, a weaker glint where it goes out, a little all round
|
||||
let caught = clamp(light * band * (0.9 * max(facing, 0.0) + 0.45 * max(-facing, 0.0) + 0.12), 0.0, 1.0);
|
||||
|
||||
glass = over(vec4<f32>(tone * caught, caught), glass);
|
||||
|
||||
// the specular line on the rim itself, about 1.5 px wide
|
||||
let line = clamp(clamp((d + 1.5) / pixel, 0.0, 1.0) * rim * (0.35 + 0.65 * abs(facing)), 0.0, 1.0);
|
||||
|
||||
glass = over(vec4<f32>(tone * line, line), glass);
|
||||
|
||||
return plain(over(glass * inside, under));
|
||||
}
|
||||
+200
@@ -12,6 +12,7 @@ pub enum Glyph {
|
||||
Muted,
|
||||
Bluetooth,
|
||||
Chevron,
|
||||
ChevronUp,
|
||||
// pointing left, for a menu's way back
|
||||
Back,
|
||||
Note,
|
||||
@@ -30,6 +31,37 @@ pub enum Glyph {
|
||||
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 {
|
||||
@@ -107,6 +139,7 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
|
||||
.cap(Cap::Round)
|
||||
],
|
||||
Glyph::Chevron => shapes![Path::new().move_to(x(0.12), y(0.32)).line_to(x(0.5), y(0.7)).line_to(x(0.88), y(0.32)).stroke(1.5, color).cap(Cap::Round)],
|
||||
Glyph::ChevronUp => shapes![Path::new().move_to(x(0.12), y(0.68)).line_to(x(0.5), y(0.3)).line_to(x(0.88), y(0.68)).stroke(1.5, color).cap(Cap::Round)],
|
||||
Glyph::Back => shapes![Path::new().move_to(x(0.64), y(0.14)).line_to(x(0.3), y(0.5)).line_to(x(0.64), y(0.86)).stroke(thin.max(1.6), color).cap(Cap::Round)],
|
||||
// an eighth note: round head, stem, a curved flag
|
||||
Glyph::Note => shapes![
|
||||
@@ -220,5 +253,172 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
|
||||
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)
|
||||
}
|
||||
|
||||
+1935
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);
|
||||
}
|
||||
}
|
||||
+362
-330
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))
|
||||
}
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use amane::{Canvas, Center, Color, Column, Full, Image, LayerWindow, Lock, Media, MediaPlayer, Monitor, Pointer, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget, children, window_size};
|
||||
|
||||
use crate::art::Art;
|
||||
use crate::config::appearance::{font, overlays, text};
|
||||
use crate::config::lock::{look, show};
|
||||
use crate::config::pills::layout;
|
||||
use crate::glyph::{Glyph, glyph_shapes};
|
||||
use crate::home::{self, Clock};
|
||||
use crate::island::{self, CLEAR, Island, faded};
|
||||
use crate::system::Power;
|
||||
use crate::{control, edge, notices, parse, surface, theme};
|
||||
|
||||
/*
|
||||
* the lock screen, amane's App::lock (ext-session-lock; pam checks the password the way the
|
||||
* `login` service does): the wallpaper blurred under a veil; the date with the weather now,
|
||||
* a big clock and hyprland's splash; the music with its controls, then chips for the next
|
||||
* event, the new notifications and the battery; at the bottom the avatar, the name and the
|
||||
* password. every monitor shows it, all sharing the one password field.
|
||||
* `amane ipc call lock` locks. killing the shell doesn't unlock: hyprland keeps the session
|
||||
* locked (README, Lock screen)
|
||||
*
|
||||
* it redraws for the music's progress, the clock and the password, so it's drawn cheaply:
|
||||
* the backdrop is one shader (lock.wgsl) instead of a gradient vello paints over the whole
|
||||
* screen on every frame, the glass is surface.rs's shader, and every icon goes into one
|
||||
* canvas over it all, as each canvas is a vello pass of its own. so that where each icon
|
||||
* lands is known, every part has a fixed size
|
||||
*/
|
||||
|
||||
// what the lock screen looks up once per lock, before it shows
|
||||
#[derive(Default)]
|
||||
pub struct Shown {
|
||||
splash: String,
|
||||
picture: Option<String>,
|
||||
}
|
||||
|
||||
impl Service for Shown {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// set by command() only
|
||||
fn listen() {}
|
||||
}
|
||||
|
||||
// the password field's name; every monitor's lock screen shows the same one
|
||||
const FIELD: &str = "lock-password";
|
||||
|
||||
static SHADER: LazyLock<PathBuf> = LazyLock::new(|| surface::shader_file("lock.wgsl", include_str!("lock.wgsl")));
|
||||
|
||||
// the icons of one frame, drawn together into one canvas over the screen
|
||||
type Icons = Vec<Box<dyn Shape>>;
|
||||
|
||||
// a chip's parts, how wide they are, and the icon the canvas draws at their start
|
||||
type Chip = (Vec<Box<dyn Widget>>, f32, Option<(Glyph, f32, Color)>);
|
||||
|
||||
// `amane ipc call lock`: the splash and the picture are looked up first, so the screen shows them from its first frame
|
||||
pub fn command(words: &[String]) -> String {
|
||||
if !words.is_empty() {
|
||||
return String::from("usage: lock");
|
||||
}
|
||||
|
||||
let splash = edge::hyprctl("splash").trim().to_string();
|
||||
|
||||
*Shown::write() = Shown { splash, picture: picture() };
|
||||
|
||||
Lock::start();
|
||||
|
||||
String::from("locked")
|
||||
}
|
||||
|
||||
// the picture: the setting's, else the wallpaper matugen named in the theme file; only a file that's there
|
||||
fn picture() -> Option<String> {
|
||||
let set = look::wallpaper();
|
||||
|
||||
let wanted = if set.trim().is_empty() {
|
||||
let theme = fs::read_to_string(theme::config_file("theme")).unwrap_or_default();
|
||||
|
||||
parse::settings(&theme).into_iter().find(|(key, _)| key == "wallpaper").map(|(_, value)| value)?
|
||||
} else {
|
||||
set
|
||||
};
|
||||
|
||||
let path = home::expanded(wanted.trim());
|
||||
|
||||
path.is_file().then(|| path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
pub fn view(_: &Monitor) -> LayerWindow {
|
||||
theme::begin();
|
||||
|
||||
// a lock screen always covers its whole monitor, whatever size it asks for
|
||||
let window = LayerWindow::new().width(1.0).height(1.0);
|
||||
let (width, height) = window_size();
|
||||
|
||||
// the compositor hasn't said the size yet
|
||||
if width <= 0.0 || height <= 0.0 {
|
||||
return window.child(Rectangle::new().width(0.0).height(0.0));
|
||||
}
|
||||
|
||||
window.child(screen(width, height))
|
||||
}
|
||||
|
||||
// about how wide words are at `size`, as the chips size themselves
|
||||
fn words_width(words: &str, size: f32) -> f32 {
|
||||
words.chars().count() as f32 * size * font::letter_width()
|
||||
}
|
||||
|
||||
// words in a box `width` wide, from its left, elided past it
|
||||
fn words(words: &str, size: f32, color: Color, bold: bool, width: f32) -> Rectangle {
|
||||
let text = Text::new(words).size(size).color(color).elide();
|
||||
let text = if bold { text.weight(Weight::Bold) } else { text };
|
||||
|
||||
Rectangle::new().width(width).height(size * 1.4).align_child(Start, Center).child(text)
|
||||
}
|
||||
|
||||
// empty room `width` wide, where an icon of the canvas goes or just space
|
||||
fn room(width: f32) -> Rectangle {
|
||||
Rectangle::new().width(width).height(1.0)
|
||||
}
|
||||
|
||||
// `glyph` centred on (x, y)
|
||||
fn place(icons: &mut Icons, glyph: Glyph, size: f32, color: Color, (x, y): (f32, f32)) {
|
||||
icons.extend(glyph_shapes(glyph, size, color, 0.0, (x - size / 2.0, y - size / 2.0)));
|
||||
}
|
||||
|
||||
fn screen(width: f32, height: f32) -> Stack {
|
||||
let clock = Clock::read();
|
||||
let shown = Shown::read();
|
||||
let gap = look::gap();
|
||||
|
||||
let mut icons: Icons = Vec::new();
|
||||
|
||||
// the top part downward from look::top, the bottom part upward from look::bottom
|
||||
let upper_y = look::top() + gap;
|
||||
let (upper, upper_h) = upper(&clock, &shown, width, upper_y, &mut icons);
|
||||
let middle = middle(clock.now(), width, upper_y + upper_h + gap, &mut icons);
|
||||
|
||||
let lower_y = height - look::bottom() - lower_height();
|
||||
let lower = lower(&clock, width, lower_y, &mut icons);
|
||||
|
||||
let top = Column::new(children![Rectangle::new().width(1.0).height(look::top()), upper, middle]).gap(gap).align(Center);
|
||||
let bottom = Column::new(children![lower, Rectangle::new().width(1.0).height(look::bottom())]).align(Center);
|
||||
|
||||
let content = Column::new(children![top, bottom]).width(width).height(height).justify(SpaceBetween).align(Center);
|
||||
|
||||
let mut layers = backdrop(width, height, shown.picture.as_deref());
|
||||
|
||||
layers.push(Box::new(content));
|
||||
|
||||
if !icons.is_empty() {
|
||||
layers.push(Box::new(Canvas::new().width(width).height(height).shapes(icons)));
|
||||
}
|
||||
|
||||
Stack::new(layers).width(width).height(height)
|
||||
}
|
||||
|
||||
// the wallpaper colours' gradient, the picture blurred over it, and a veil over that darker at the edges: lock.wgsl
|
||||
fn backdrop(width: f32, height: f32, picture: Option<&str>) -> Vec<Box<dyn Widget>> {
|
||||
let [high, middle, low] = theme::now().surfaces();
|
||||
|
||||
let shade = |layer: f32| {
|
||||
let values = vec![[width, height, 160.0, layer], surface::plain(high), surface::plain(middle), surface::plain(low), [look::dim(), look::vignette(), 0.0, 0.0]];
|
||||
|
||||
Box::new(Rectangle::new().width(width).height(height).shader(SHADER.as_path()).shader_values(values)) as Box<dyn Widget>
|
||||
};
|
||||
|
||||
let Some(path) = picture else {
|
||||
return vec![shade(0.0)];
|
||||
};
|
||||
|
||||
let blur = look::blur() as u32;
|
||||
|
||||
// blurred: a small copy, blurred once when decoded and stretched over the screen, costs nothing per frame
|
||||
let image = if blur == 0 {
|
||||
Image::cover(path).thumbnail(width.ceil() as u32, height.ceil() as u32)
|
||||
} else {
|
||||
let copy = look::copy_width() as u32;
|
||||
|
||||
Image::cover(path).thumbnail(copy, (copy as f32 * height / width).ceil() as u32).blurred(blur)
|
||||
};
|
||||
|
||||
// the gradient shows while the picture decodes
|
||||
vec![shade(1.0), Box::new(Rectangle::new().width(width).height(height).fill(image)), shade(2.0)]
|
||||
}
|
||||
|
||||
// the date with the weather now, the clock, hyprland's splash, from `y` down; and how tall
|
||||
fn upper(clock: &Clock, shown: &Shown, width: f32, y: f32, icons: &mut Icons) -> (Rectangle, f32) {
|
||||
let theme = theme::now();
|
||||
let size = look::date_size();
|
||||
let now = clock.now();
|
||||
let inner = look::gap() * 0.4;
|
||||
|
||||
// the date, and the weather's icon and words after it
|
||||
let date = if now > 0 { home::date_words(now) } else { String::new() };
|
||||
let date_w = words_width(&date, size);
|
||||
let date_h = size * 1.4;
|
||||
|
||||
let mut row: Vec<Box<dyn Widget>> = vec![Box::new(words(&date, size, theme.text, true, date_w))];
|
||||
let mut row_w = date_w;
|
||||
|
||||
if show::weather()
|
||||
&& let Some((glyph, said)) = home::weather_now()
|
||||
{
|
||||
let (space, icon, after) = (size * 0.6, size * 1.15, size * 0.3);
|
||||
let said_w = words_width(&said, size);
|
||||
let total = date_w + space + icon + after + said_w;
|
||||
|
||||
place(icons, glyph, icon, theme.text, ((width - total) / 2.0 + date_w + space + icon / 2.0, y + date_h / 2.0));
|
||||
|
||||
row.extend(children![room(space), room(icon), room(after), words(&said, size, theme.text, false, said_w)]);
|
||||
row_w = total;
|
||||
}
|
||||
|
||||
let seconds = now.rem_euclid(86_400);
|
||||
let time = if now > 0 { format!("{:02}:{:02}", seconds / 3_600, seconds % 3_600 / 60) } else { String::new() };
|
||||
|
||||
let digits = Text::new(time).size(look::clock_size()).weight(look::clock_weight().min(1000) as u16).color(theme.text).tight();
|
||||
let family = look::clock_font();
|
||||
let digits = if family.trim().is_empty() { digits } else { digits.font(family.trim()) };
|
||||
let clock_h = digits.height_in(width);
|
||||
|
||||
let mut parts: Vec<Box<dyn Widget>> = vec![
|
||||
Box::new(Rectangle::new().width(row_w).height(date_h).child(Row::new(row).align(Center))),
|
||||
Box::new(Rectangle::new().width(width).height(clock_h).align_child(Center, Center).child(digits)),
|
||||
];
|
||||
|
||||
let mut height = date_h + inner + clock_h;
|
||||
|
||||
if show::splash() && !shown.splash.is_empty() {
|
||||
let size = size * 0.7;
|
||||
let said = format!("“{}”", shown.splash);
|
||||
let said_w = (words_width(&said, size) + size).min(width * 0.7);
|
||||
|
||||
parts.push(Box::new(words(&said, size, theme.dim, false, said_w)));
|
||||
height += inner + size * 1.4;
|
||||
}
|
||||
|
||||
(Rectangle::new().width(width).height(height).align_child(Center, Start).child(Column::new(parts).gap(inner).align(Center)), height)
|
||||
}
|
||||
|
||||
// the music, then the chips, from `y` down
|
||||
fn middle(now: i64, width: f32, y: f32, icons: &mut Icons) -> Column {
|
||||
let gap = look::gap() * 0.75;
|
||||
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
|
||||
let mut y = y;
|
||||
|
||||
if show::music()
|
||||
&& let Some(card) = music(width, y, icons)
|
||||
{
|
||||
parts.push(Box::new(card));
|
||||
y += look::card_height() + gap;
|
||||
}
|
||||
|
||||
if let Some(chips) = chips(now, width, y, icons) {
|
||||
parts.push(Box::new(chips));
|
||||
}
|
||||
|
||||
Column::new(parts).gap(gap).align(Center)
|
||||
}
|
||||
|
||||
// the shown player's song at `y`: its cover, title, artist and how far it is, and its buttons
|
||||
fn music(width: f32, y: f32, icons: &mut Icons) -> Option<Rectangle> {
|
||||
let media = Media::read();
|
||||
let island = Island::read();
|
||||
let art = Art::read();
|
||||
let theme = theme::now();
|
||||
|
||||
let player = island::shown(&media, island.selected.as_deref())?;
|
||||
|
||||
let (card_w, card_h) = (look::card_width(), look::card_height());
|
||||
let gap = look::gap() * 0.75;
|
||||
let cover = look::cover().min(card_h - 8.0);
|
||||
let pad = (card_h - cover) / 2.0;
|
||||
|
||||
// where the card's row starts, and the height every part of it is centred on
|
||||
let left = (width - card_w) / 2.0 + pad;
|
||||
let middle = y + card_h / 2.0;
|
||||
|
||||
let picture = Rectangle::new().width(cover).height(cover).radius(cover * 0.18);
|
||||
|
||||
let picture = if art.path.is_empty() {
|
||||
place(icons, Glyph::Note, cover * 0.45, theme.accent, (left + cover / 2.0, middle));
|
||||
|
||||
picture.fill(theme.accent_soft)
|
||||
} else {
|
||||
let pixels = (cover * 2.0).ceil() as u32;
|
||||
|
||||
picture.fill(Image::cover(art.path.clone()).thumbnail(pixels, pixels))
|
||||
};
|
||||
|
||||
let (small, big) = (text::title() * 1.9, text::title() * 1.9 * 1.25);
|
||||
let buttons_w = if show::controls() { 2.0 * small + big + 8.0 + gap } else { 0.0 };
|
||||
let words_w = (card_w - 2.0 * pad - cover - gap - buttons_w).max(0.0);
|
||||
|
||||
let title = if player.title().is_empty() { player.identity() } else { player.title() };
|
||||
|
||||
let length = player.length().as_secs_f32();
|
||||
let done = if length > 0.0 { (player.position().as_secs_f32() / length).clamp(0.0, 1.0) } else { 0.0 };
|
||||
|
||||
let track = Rectangle::new().width(words_w).height(4.0).radius(Full).fill(overlays::track()).align_child(Start, Center).child(Rectangle::new().width((words_w * done).max(4.0)).height(4.0).radius(Full).fill(theme.text));
|
||||
|
||||
let said = Column::new(children![words(title, text::title(), theme.text, true, words_w), words(player.artist(), text::body(), theme.dim, false, words_w), track]).gap(6.0);
|
||||
|
||||
let mut items: Vec<Box<dyn Widget>> = vec![Box::new(picture), Box::new(said)];
|
||||
|
||||
if show::controls() {
|
||||
items.push(Box::new(controls(player, (left + cover + gap + words_w + gap, middle), small, big, icons)));
|
||||
}
|
||||
|
||||
let content = Rectangle::new().width(card_w).height(card_h).padding(pad).align_child(Start, Center).child(Row::new(items).gap(gap).align(Center));
|
||||
|
||||
Some(surface::shell(card_w, card_h, layout::radius().min(card_h / 2.0), &theme.surfaces(), 0.0, content))
|
||||
}
|
||||
|
||||
// previous, play or pause, next, from (x, the middle's y), their icons into the canvas
|
||||
fn controls(player: &MediaPlayer, (x, y): (f32, f32), small: f32, big: f32, icons: &mut Icons) -> Row {
|
||||
let theme = theme::now();
|
||||
|
||||
let middle = if player.playing() { Glyph::Pause } else { Glyph::Play };
|
||||
|
||||
for (glyph, size, centre) in [(Glyph::Previous, small, x + small / 2.0), (middle, big, x + small + 4.0 + big / 2.0), (Glyph::Next, small, x + small + 4.0 + big + 4.0 + small / 2.0)] {
|
||||
place(icons, glyph, size * 0.42, theme.text, (centre, y));
|
||||
}
|
||||
|
||||
let button = |size: f32, fill: Color, pressed: Box<dyn Fn()>| Rectangle::new().width(size).height(size).radius(Full).fill(fill).cursor(Pointer).on_click(move |_| pressed());
|
||||
|
||||
let (previous, toggle, next) = (player.clone(), player.clone(), player.clone());
|
||||
|
||||
Row::new(children![
|
||||
button(small, CLEAR, Box::new(move || previous.previous())),
|
||||
button(big, overlays::chip(), Box::new(move || toggle.play_pause())),
|
||||
button(small, CLEAR, Box::new(move || next.next())),
|
||||
])
|
||||
.gap(4.0)
|
||||
.align(Center)
|
||||
}
|
||||
|
||||
// a glass chip around `parts`, which are `inner` wide
|
||||
fn chip(parts: Vec<Box<dyn Widget>>, inner: f32) -> Rectangle {
|
||||
let height = look::chip_height();
|
||||
let width = inner + height;
|
||||
|
||||
let content = Rectangle::new().width(width).height(height).align_child(Center, Center).child(Row::new(parts).gap(8.0).align(Center));
|
||||
|
||||
surface::shell(width, height, height / 2.0, &theme::now().surfaces(), 0.0, content)
|
||||
}
|
||||
|
||||
// the next event, the new notifications, the battery at `y`: each its own chip, when there is one
|
||||
fn chips(now: i64, width: f32, y: f32, icons: &mut Icons) -> Option<Row> {
|
||||
let theme = theme::now();
|
||||
let (size, height) = (text::body(), look::chip_height());
|
||||
let icon = size * 1.3;
|
||||
let spacing = look::gap() * 0.6;
|
||||
|
||||
// each chip's parts and inner width, and an icon at the start of its parts
|
||||
let mut wanted: Vec<Chip> = Vec::new();
|
||||
|
||||
if show::event()
|
||||
&& let Some((when, what, color)) = home::next_event(now)
|
||||
{
|
||||
let said = format!("{when} · {what}");
|
||||
let said_w = words_width(&said, size).min(look::card_width() * 0.8);
|
||||
let dot = Rectangle::new().width(9.0).height(9.0).radius(Full).fill(color.unwrap_or(theme.accent));
|
||||
|
||||
wanted.push((children![dot, words(&said, size, theme.text, false, said_w)], 9.0 + 8.0 + said_w, None));
|
||||
}
|
||||
|
||||
let unread = notices::unread();
|
||||
|
||||
if show::notifications() && unread > 0 {
|
||||
let said = if unread == 1 { String::from("1 new notification") } else { format!("{unread} new notifications") };
|
||||
let said_w = words_width(&said, size).min(look::card_width());
|
||||
|
||||
wanted.push((children![room(icon), words(&said, size, theme.text, false, said_w)], icon + 8.0 + said_w, Some((Glyph::Bell, icon, theme.text))));
|
||||
}
|
||||
|
||||
if show::battery()
|
||||
&& let Some(charge) = &Power::read().battery
|
||||
{
|
||||
let bolt = (charge.status == "Charging").then_some((Glyph::Bolt, 11.0, theme.accent));
|
||||
|
||||
wanted.push((children![control::battery_with(charge, false)], control::battery_width(charge), bolt));
|
||||
}
|
||||
|
||||
if wanted.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let total: f32 = wanted.iter().map(|(_, inner, _)| inner + height).sum::<f32>() + spacing * (wanted.len() - 1) as f32;
|
||||
let mut x = (width - total) / 2.0;
|
||||
|
||||
let mut row: Vec<Box<dyn Widget>> = Vec::new();
|
||||
|
||||
for (parts, inner, icon) in wanted {
|
||||
// a chip's parts start half its height in
|
||||
if let Some((glyph, size, color)) = icon {
|
||||
place(icons, glyph, size, color, (x + height / 2.0 + size / 2.0, y + height / 2.0));
|
||||
}
|
||||
|
||||
row.push(Box::new(chip(parts, inner)));
|
||||
x += inner + height + spacing;
|
||||
}
|
||||
|
||||
Some(Row::new(row).gap(spacing).align(Center))
|
||||
}
|
||||
|
||||
fn name_height() -> f32 {
|
||||
text::title() * 1.4
|
||||
}
|
||||
|
||||
fn note_height() -> f32 {
|
||||
text::body() * 1.6
|
||||
}
|
||||
|
||||
// how tall the avatar, the name, the password and the note are together
|
||||
fn lower_height() -> f32 {
|
||||
let gap = look::gap() * 0.6;
|
||||
|
||||
look::avatar_size() + gap + name_height() + gap + look::field_height() + gap + note_height()
|
||||
}
|
||||
|
||||
// the avatar, the name, the password and what became of the last one, from `y` down
|
||||
fn lower(clock: &Clock, width: f32, y: f32, icons: &mut Icons) -> Column {
|
||||
let theme = theme::now();
|
||||
let lock = Lock::read();
|
||||
let gap = look::gap() * 0.6;
|
||||
let size = look::avatar_size();
|
||||
|
||||
let avatar = Rectangle::new().width(size).height(size).radius(Full).border(2.0, faded(theme.text, 90));
|
||||
|
||||
let avatar = match clock.avatar() {
|
||||
Some(path) => {
|
||||
let pixels = (size * 2.0).ceil() as u32;
|
||||
|
||||
avatar.fill(Image::cover(path).thumbnail(pixels, pixels))
|
||||
}
|
||||
None => {
|
||||
place(icons, Glyph::Person, size * 0.54, theme.accent, (width / 2.0, y + size / 2.0));
|
||||
|
||||
avatar.fill(faded(theme.accent, 60))
|
||||
}
|
||||
};
|
||||
|
||||
let name = Rectangle::new().width(width).height(name_height()).align_child(Center, Center).child(Text::new(home::name()).size(text::title()).weight(Weight::Bold).color(theme.text));
|
||||
|
||||
let (field_w, field_h) = (look::field_width(), look::field_height());
|
||||
let letters = text::popup();
|
||||
let icon = letters * 1.1;
|
||||
let start = field_h * 0.45;
|
||||
|
||||
let field_y = y + size + gap + name_height() + gap;
|
||||
|
||||
place(icons, Glyph::Lock, icon, theme.dim, ((width - field_w) / 2.0 + start + icon / 2.0, field_y + field_h / 2.0));
|
||||
|
||||
let input = TextInput::new(FIELD).password().focused().placeholder("Enter password").size(letters).color(theme.text).on_submit(submit);
|
||||
|
||||
// the lock icon's room, then the input 10 px after it
|
||||
let inside = Row::new(children![room(start), room(icon), room(10.0), Rectangle::new().width(field_w - 2.0 * start - icon - 10.0).height(field_h).align_child(Start, Center).child(input)]).align(Center);
|
||||
|
||||
// a wrong password lights the field's rim
|
||||
let lift = if lock.failed() { 1.0 } else { 0.0 };
|
||||
let field = surface::shell(field_w, field_h, field_h / 2.0, &theme.surfaces(), lift, Rectangle::new().width(field_w).height(field_h).align_child(Start, Center).child(inside));
|
||||
|
||||
let (note, color) = if lock.checking() {
|
||||
("Checking…", theme.dim)
|
||||
} else if lock.failed() {
|
||||
("Wrong password, try again", look::wrong_color())
|
||||
} else {
|
||||
("", theme.dim)
|
||||
};
|
||||
|
||||
let note = Rectangle::new().width(field_w).height(note_height()).align_child(Center, Center).child(Text::new(note).size(text::body()).color(color));
|
||||
|
||||
Column::new(children![avatar, name, field, note]).gap(gap).align(Center)
|
||||
}
|
||||
|
||||
// Enter: the field empties at once, pam gets the password on its own thread
|
||||
fn submit(password: String) {
|
||||
TextInput::set_text(FIELD, "");
|
||||
|
||||
if !password.is_empty() {
|
||||
Lock::unlock(&password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// the lock screen's backdrop in one pass: the wallpaper colours' gradient, and over the picture
|
||||
// a veil that darkens it, more toward the edges. drawn as amane's gradient it was a vello pass
|
||||
// over the whole screen on every frame (vello keeps no gradient between frames). lock.rs fills
|
||||
// the values:
|
||||
// 0: width, height, the gradient's angle in degrees (0 runs bottom to top, 90 left to right), layer
|
||||
// 1-3: the gradient's colours at 0, 0.55 and 1, plain
|
||||
// 4: the veil's darkness and the vignette's strength, 0 to 1
|
||||
// layer 1 is the gradient (under the picture), 2 the veil (over it), 0 both (no picture)
|
||||
|
||||
@fragment
|
||||
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||
let extent = values[0].xy;
|
||||
let angle = radians(values[0].z);
|
||||
let layer = u32(values[0].w);
|
||||
|
||||
// where this pixel lies along the gradient, 0 at its start corner and 1 at its end
|
||||
let direction = vec2<f32>(sin(angle), -cos(angle));
|
||||
let reach = abs(direction.x) * extent.x + abs(direction.y) * extent.y;
|
||||
let t = clamp(dot((uv - 0.5) * extent, direction) / max(reach, 1.0) + 0.5, 0.0, 1.0);
|
||||
|
||||
let gradient = select(mix(values[2], values[3], (t - 0.55) / 0.45), mix(values[1], values[2], t / 0.55), t < 0.55);
|
||||
|
||||
// the veil, darker toward the corners: 0 in the middle, 1 in a corner
|
||||
let corner = length((uv - 0.5) * 2.0) / 1.4142;
|
||||
let dark = clamp(values[4].x + (1.0 - values[4].x) * values[4].y * smoothstep(0.3, 1.0, corner), 0.0, 1.0);
|
||||
|
||||
if layer == 1u {
|
||||
return vec4<f32>(gradient.rgb, 1.0);
|
||||
}
|
||||
|
||||
if layer == 2u {
|
||||
return vec4<f32>(0.0, 0.0, 0.0, dark);
|
||||
}
|
||||
|
||||
return vec4<f32>(gradient.rgb * (1.0 - dark), 1.0);
|
||||
}
|
||||
+26
-3
@@ -1,13 +1,24 @@
|
||||
mod ai;
|
||||
mod art;
|
||||
mod config;
|
||||
mod control;
|
||||
mod edge;
|
||||
mod glyph;
|
||||
mod home;
|
||||
mod hypr;
|
||||
mod island;
|
||||
mod last;
|
||||
mod lock;
|
||||
mod map;
|
||||
mod notices;
|
||||
mod parse;
|
||||
mod pods;
|
||||
mod prompt;
|
||||
mod settings;
|
||||
mod sound;
|
||||
mod spectrum;
|
||||
mod stats;
|
||||
mod surface;
|
||||
mod system;
|
||||
mod png;
|
||||
mod theme;
|
||||
@@ -17,25 +28,37 @@ mod workspaces;
|
||||
use amane::{App, Palette, Service};
|
||||
|
||||
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
|
||||
Palette::write().open(&art::palette_file(), 8);
|
||||
|
||||
// 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
|
||||
// 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("Fira Code")
|
||||
.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)
|
||||
.ipc("lock", lock::command)
|
||||
.lock(lock::view)
|
||||
.window_per_monitor(edge::spacer)
|
||||
.window_per_monitor(edge::view)
|
||||
.window_per_monitor(island::view)
|
||||
// the small pills beside the island, and their cards in the middle
|
||||
.window_per_monitor(pods::row_view)
|
||||
.window_per_monitor(pods::card_view)
|
||||
.window_per_monitor(workspaces::view)
|
||||
.window_per_monitor(control::view)
|
||||
.window_per_monitor(control::menu_view)
|
||||
.window_per_monitor(tray::menu_view)
|
||||
// last, so the popup lies over the control centre
|
||||
// last, so the popups lie over the control centre
|
||||
.window_per_monitor(notices::popups)
|
||||
.window_per_monitor(prompt::view)
|
||||
.run();
|
||||
}
|
||||
|
||||
+35
-51
@@ -11,34 +11,18 @@ 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::{CHIP_OPEN, faded};
|
||||
use crate::island::faded;
|
||||
use crate::theme;
|
||||
|
||||
// the map's own height, under the menu's header
|
||||
pub const AREA_H: f32 = 480.0;
|
||||
// the map's sizes and timings are settings (config.rs: control.maps.*)
|
||||
|
||||
const BUBBLE: f32 = 40.0;
|
||||
const NODE_R: f32 = 22.0;
|
||||
const ICON: f32 = 18.0;
|
||||
const LABEL_W: f32 = 140.0;
|
||||
const LABEL_H: f32 = 30.0;
|
||||
|
||||
// how far each one floats from its place, either way
|
||||
const DRIFT_X: f32 = 26.0;
|
||||
const DRIFT_Y: f32 = 16.0;
|
||||
|
||||
// at most this many around the bubble (the strongest come first): more don't fit at this size without labels meeting
|
||||
pub const NODES: usize = 8;
|
||||
|
||||
// the wobbling line of what isn't connected
|
||||
const IDLE_LINE: Color = Color::rgba(205, 205, 212, 85);
|
||||
|
||||
// a click on the bubble shows its rings at least until all three have run out once; a ring takes RING_S
|
||||
const RING_S: f32 = 1.6;
|
||||
const RING_GAP_S: f32 = 0.5;
|
||||
pub const RIPPLE_S: f32 = 2.0 * RING_GAP_S + RING_S;
|
||||
const RING_REACH: f32 = 230.0;
|
||||
// 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 {
|
||||
@@ -87,13 +71,13 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
|
||||
let t = clock();
|
||||
|
||||
let (cx, cy) = (width / 2.0, height / 2.0);
|
||||
let bubble = BUBBLE * (1.0 + 0.08 * centre.lit) + (t * 1.3).sin() * 1.5;
|
||||
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 - LABEL_W / 2.0 - DRIFT_X - 6.0, cy - NODE_R - LABEL_H - DRIFT_Y - 9.0);
|
||||
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) = (BUBBLE + NODE_R + 150.0, BUBBLE + NODE_R + 80.0);
|
||||
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;
|
||||
|
||||
@@ -121,8 +105,8 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
|
||||
|
||||
// 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) * DRIFT_X,
|
||||
((t * 0.27 + phase * 2.3).cos() * 0.7 + (t * 0.47 + phase * 1.3).cos() * 0.3) * DRIFT_Y,
|
||||
((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;
|
||||
@@ -133,7 +117,7 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
|
||||
let (ux, uy) = (dx / length, dy / length);
|
||||
|
||||
let start = (cx + ux * bubble, cy + uy * bubble);
|
||||
let span = (length - bubble - NODE_R).max(0.0);
|
||||
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);
|
||||
|
||||
@@ -180,21 +164,21 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
|
||||
|
||||
let (fill, mark) = match node.link {
|
||||
Link::Connected => (theme.accent, theme.on_accent),
|
||||
Link::Connecting => (Color::blend(CHIP_OPEN, theme.accent, 0.45), theme.text),
|
||||
Link::Idle => (CHIP_OPEN, theme.text),
|
||||
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 = NODE_R * (1.0 + 0.12 * lit);
|
||||
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, ICON, mark, 0.0, (x - ICON / 2.0, y - ICON / 2.0)));
|
||||
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 * NODE_R).height(2.0 * NODE_R).radius(Full).translate(x - NODE_R, y - NODE_R).on_hover(node.on_hover);
|
||||
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),
|
||||
@@ -206,13 +190,13 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
|
||||
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 - NODE_R { y - NODE_R - 5.0 - LABEL_H } else { y + NODE_R + 5.0 };
|
||||
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(LABEL_W).height(LABEL_H).align_child(Center, Start).translate(x - LABEL_W / 2.0, label_y).child(
|
||||
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(11.5).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(10.0).color(theme.dim).elide(), 13.0)),
|
||||
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),
|
||||
),
|
||||
@@ -220,7 +204,7 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
|
||||
}
|
||||
|
||||
let mut lines: Vec<Box<dyn Shape>> = shapes![
|
||||
idle.stroke(1.5, IDLE_LINE).cap(Cap::Round),
|
||||
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),
|
||||
@@ -229,17 +213,17 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
|
||||
// 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 * RING_GAP_S;
|
||||
let age = since - ring as f32 * maps::ring_gap_s();
|
||||
|
||||
if age < 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let grown = (age / RING_S).fract();
|
||||
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 * RING_REACH).stroke(2.0, faded(theme.accent, (150.0 * (1.0 - grown)) as u8)),
|
||||
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)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -255,18 +239,18 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
|
||||
// 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(LABEL_W * 2.0)
|
||||
.width(maps::label_width() * 2.0)
|
||||
.height(16.0)
|
||||
.align_child(Center, Center)
|
||||
.translate(cx - LABEL_W, cy + BUBBLE + 12.0)
|
||||
.child(Text::new(centre.host).size(12.0).weight(Weight::Bold).color(theme.text)),
|
||||
.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(LABEL_W * 3.0).height(16.0).align_child(Center, Center).translate(cx - LABEL_W * 1.5, cy + BUBBLE + 32.0).child(Text::new(note).size(11.0).color(theme.dim)),
|
||||
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)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -275,10 +259,10 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
|
||||
|
||||
layers.push(Box::new(
|
||||
Rectangle::new()
|
||||
.width(2.0 * BUBBLE)
|
||||
.height(2.0 * BUBBLE)
|
||||
.width(2.0 * maps::bubble())
|
||||
.height(2.0 * maps::bubble())
|
||||
.radius(Full)
|
||||
.translate(cx - BUBBLE, cy - BUBBLE)
|
||||
.translate(cx - maps::bubble(), cy - maps::bubble())
|
||||
.cursor(Pointer)
|
||||
.on_hover(centre.on_hover)
|
||||
.on_click(move |_| on_click()),
|
||||
@@ -290,5 +274,5 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
|
||||
}
|
||||
|
||||
fn centred(text: Text, height: f32) -> Rectangle {
|
||||
Rectangle::new().width(LABEL_W).height(height).align_child(Center, Center).child(text)
|
||||
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))
|
||||
}
|
||||
+2202
-8
File diff suppressed because it is too large
Load Diff
+774
@@ -0,0 +1,774 @@
|
||||
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::pill as island_pill;
|
||||
use crate::config::notifications::history;
|
||||
use crate::config::pills::{self as settings, colors as hues, layout};
|
||||
use crate::edge::{self, Edge};
|
||||
use crate::glyph::{Glyph, icon};
|
||||
use crate::island::{self, CLEAR, mix, phases, veiled};
|
||||
use crate::parse::Sky;
|
||||
use crate::{ai, home, notices, stats, surface, theme};
|
||||
|
||||
/*
|
||||
* four small one-icon pills beside the island, each opening into a big card in the
|
||||
* middle of the top of the screen. while a card is open the row becomes a carousel:
|
||||
* every other pill and the island's pill line up left and right of the card in their
|
||||
* usual order ([AI][System] island [Home][Bell]), and clicking one switches to its card.
|
||||
* a closing card shrinks back into its own pill's place.
|
||||
*
|
||||
* one window per monitor (magic-pods), as wide as the monitor and anchored to its left
|
||||
* edge, so it never moves: only its height changes, at the top edge. while a card is
|
||||
* open it also draws the island's pill (island::pill_face), which the island's own
|
||||
* window leaves out meanwhile (holds_island); the two overlap for a moment at each
|
||||
* hand-over, at the same place, so the pill never blinks
|
||||
*/
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Pod {
|
||||
Ai,
|
||||
Monitor,
|
||||
Home,
|
||||
Notifications,
|
||||
}
|
||||
|
||||
// the row, left to right
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Item {
|
||||
Pod(Pod),
|
||||
Island,
|
||||
}
|
||||
|
||||
const ROW: [Item; 5] = [Item::Pod(Pod::Ai), Item::Pod(Pod::Monitor), Item::Island, Item::Pod(Pod::Home), Item::Pod(Pod::Notifications)];
|
||||
|
||||
const PODS: [Pod; 4] = [Pod::Ai, Pod::Monitor, Pod::Home, Pod::Notifications];
|
||||
|
||||
impl Pod {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
// its own colour; home's follows the sky
|
||||
fn hue(self) -> Color {
|
||||
match self {
|
||||
Self::Ai => hues::ai(),
|
||||
Self::Monitor => hues::monitor(),
|
||||
Self::Notifications => hues::notifications(),
|
||||
Self::Home => match home::sky() {
|
||||
Some((Sky::Clear | Sky::PartlyCloudy, true)) => hues::home_clear_day(),
|
||||
Some((Sky::Clear | Sky::PartlyCloudy, false)) => hues::home_clear_night(),
|
||||
Some((Sky::Cloudy, _)) | None => hues::home_cloudy(),
|
||||
Some((Sky::Fog, _)) => hues::home_fog(),
|
||||
Some((Sky::Rain, _)) => hues::home_rain(),
|
||||
Some((Sky::Snow, _)) => hues::home_snow(),
|
||||
Some((Sky::Storm, _)) => hues::home_storm(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// whether its card takes typing: then it has the keyboard and stays open when the pointer leaves
|
||||
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> {
|
||||
PODS.into_iter().find(|pod| pod.word() == word)
|
||||
}
|
||||
}
|
||||
|
||||
// the wallpaper's surfaces with some of a colour mixed in, keeping their see-through
|
||||
fn tinted(colors: &[Color; 3], hue: Color, amount: f32) -> [Color; 3] {
|
||||
colors.map(|color| Color::blend(color, Color::rgba(hue.red(), hue.green(), hue.blue(), color.alpha()), amount))
|
||||
}
|
||||
|
||||
pub struct Pods {
|
||||
// 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,
|
||||
|
||||
// a switch from one open card straight to another: the one before, and the switch's progress
|
||||
previous: Option<Pod>,
|
||||
switch: Animation,
|
||||
|
||||
hover: HashMap<Pod, Animation>,
|
||||
|
||||
// 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(), previous: None, switch: switching(1.0), 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)
|
||||
}
|
||||
|
||||
fn switching(from: f32) -> Animation {
|
||||
Animation::new(from).duration(Duration::from_millis(layout::open_ms())).easing(Easing::InOut)
|
||||
}
|
||||
|
||||
pub fn open(pod: Pod, monitor: &str) {
|
||||
island::collapse();
|
||||
|
||||
let mut pods = Pods::write();
|
||||
|
||||
let open_here = pods.shown && pods.timeline.value() > 0.5;
|
||||
let current = pods.card.as_ref().filter(|(_, on)| on == monitor).map(|(open, _)| *open);
|
||||
|
||||
match current {
|
||||
Some(open) if open == pod => {}
|
||||
// another card while one is open here: the open one turns into it
|
||||
Some(open) if open_here => {
|
||||
pods.previous = Some(open);
|
||||
pods.switch = switching(0.0);
|
||||
pods.switch.to(1.0);
|
||||
pods.card = Some((pod, monitor.to_string()));
|
||||
}
|
||||
// another card, or one on another monitor: it grows from its own pill
|
||||
_ => {
|
||||
pods.card = Some((pod, monitor.to_string()));
|
||||
pods.timeline = timeline();
|
||||
pods.previous = None;
|
||||
pods.switch = switching(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/*
|
||||
* whether this window draws the island's pill on `monitor` now: a little past the start
|
||||
* of opening and until a little before the end of closing. this window draws it from
|
||||
* the first frame to the last, so the two overlap at each hand-over, at the same place
|
||||
*/
|
||||
pub fn holds_island(monitor: &str) -> bool {
|
||||
let pods = Pods::read();
|
||||
|
||||
pods.card.as_ref().is_some_and(|(_, on)| on == monitor) && pods.timeline.value() > HAND_OVER
|
||||
}
|
||||
|
||||
// a share of the open timeline (420 ms by default: 0.08 is about two frames)
|
||||
const HAND_OVER: f32 = 0.08;
|
||||
|
||||
fn hovering(pod: Pod) -> impl Fn(bool) + 'static {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// the island's pill in the carousel: the card closes back into place, then the island opens
|
||||
fn island_tapped(monitor: String) {
|
||||
close();
|
||||
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(layout::open_ms()));
|
||||
|
||||
island::tap(&monitor);
|
||||
});
|
||||
}
|
||||
|
||||
// an item's width in the carousel
|
||||
fn item_width(item: Item) -> f32 {
|
||||
match item {
|
||||
Item::Pod(_) => island_pill::height(),
|
||||
// beside an open card the island's pill shrinks to its cover or its bars
|
||||
Item::Island => island::small_width(),
|
||||
}
|
||||
}
|
||||
|
||||
// an item's left edge in the row with nothing open, around the island as wide as it is now
|
||||
fn closed_x(item: Item, middle: f32, island_w: f32) -> f32 {
|
||||
let (h, spacing, gap) = (island_pill::height(), layout::spacing(), layout::gap());
|
||||
|
||||
match item {
|
||||
Item::Island => middle - island_pill::width() / 2.0,
|
||||
Item::Pod(Pod::Monitor) => middle - island_w / 2.0 - gap - h,
|
||||
Item::Pod(Pod::Ai) => middle - island_w / 2.0 - gap - 2.0 * h - spacing,
|
||||
Item::Pod(Pod::Home) => middle + island_w / 2.0 + gap,
|
||||
Item::Pod(Pod::Notifications) => middle + island_w / 2.0 + gap + h + spacing,
|
||||
}
|
||||
}
|
||||
|
||||
fn index(item: Item) -> usize {
|
||||
ROW.iter().position(|other| *other == item).unwrap_or(0)
|
||||
}
|
||||
|
||||
/*
|
||||
* the two items on each side of `open`'s card: the other four in the row's order, the
|
||||
* first two on its left and the last two on its right, so the left-most stays left-most
|
||||
* (around Home: AI and System on its left, the island and the bell on its right; around
|
||||
* AI: System and the island on its left, Home and the bell on its right)
|
||||
*/
|
||||
fn beside(open: Pod) -> ([Item; 2], [Item; 2]) {
|
||||
let others: Vec<Item> = ROW.into_iter().filter(|item| *item != Item::Pod(open)).collect();
|
||||
|
||||
([others[0], others[1]], [others[2], others[3]])
|
||||
}
|
||||
|
||||
// an item's left edge in the carousel around `open`'s card, `card_w` wide, in the middle, and its side of the card (-1, 0 for the card's own, 1)
|
||||
fn carousel(item: Item, open: Pod, card_w: f32, middle: f32) -> (f32, i8) {
|
||||
// the card's own pill, while it fades into the card or out of it, sits in the card's middle
|
||||
if item == Item::Pod(open) {
|
||||
return (middle - item_width(item) / 2.0, 0);
|
||||
}
|
||||
|
||||
let (spacing, gap) = (layout::spacing(), layout::gap());
|
||||
let (left, right) = beside(open);
|
||||
|
||||
match left.iter().position(|other| *other == item) {
|
||||
// leftwards from the card, past the one between
|
||||
Some(k) => {
|
||||
let between: f32 = left[k + 1..].iter().map(|other| item_width(*other) + spacing).sum();
|
||||
|
||||
(middle - card_w / 2.0 - gap - between - item_width(item), -1)
|
||||
}
|
||||
None => {
|
||||
let k = right.iter().position(|other| *other == item).unwrap_or(0);
|
||||
let between: f32 = right[..k].iter().map(|other| item_width(*other) + spacing).sum();
|
||||
|
||||
(middle + card_w / 2.0 + gap + between, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* an item's side of the middle in the closed row, where every card opens: AI and System
|
||||
* left, the island in it (0, so it slides out to whichever side it goes), Home and the
|
||||
* bell right. no item changes sides on opening, so none crosses the card
|
||||
*/
|
||||
fn closed_side(item: Item) -> i8 {
|
||||
(index(item) as i8 - index(Item::Island) as i8).signum()
|
||||
}
|
||||
|
||||
/*
|
||||
* from one place to another by `k`, with how much it shows: sliding when it stays on its
|
||||
* side of the card; changing sides it fades out where it is over the first half and in at
|
||||
* its new place over the second, so it never crosses the card
|
||||
*/
|
||||
fn travel(from: (f32, i8), to: (f32, i8), k: f32) -> (f32, f32) {
|
||||
if from.1 == to.1 || from.1 == 0 || to.1 == 0 {
|
||||
(mix(from.0, to.0, k), 1.0)
|
||||
} else if k < 0.5 {
|
||||
(from.0, 1.0 - 2.0 * k)
|
||||
} else {
|
||||
(to.0, 2.0 * k - 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// what both windows need to know about this monitor's card this frame
|
||||
struct Scene {
|
||||
// the pod and its timeline, while it's at all open here
|
||||
card: Option<(Pod, f32)>,
|
||||
grow: f32,
|
||||
pill_shown: f32,
|
||||
reveal: f32,
|
||||
switch: f32,
|
||||
previous: Option<Pod>,
|
||||
middle: f32,
|
||||
island_w: f32,
|
||||
// the row's move into the carousel: it starts only after the hand-over (see holds_island), so the island's pill never shows twice
|
||||
row_grow: f32,
|
||||
}
|
||||
|
||||
fn scene(state: &Pods, monitor: &Monitor) -> Scene {
|
||||
let here = monitor.name.as_str();
|
||||
let card = state.card.as_ref().filter(|(_, on)| on == here).map(|(pod, _)| (*pod, state.timeline.value())).filter(|(_, t)| *t > 0.0);
|
||||
let (grow, pill_shown, reveal) = card.map_or((0.0, 1.0, 0.0), |(_, t)| phases(t));
|
||||
|
||||
// read only here: a running animation redraws every window that reads it
|
||||
let switch = if card.is_some() { state.switch.value() } else { 1.0 };
|
||||
let previous = state.previous.filter(|_| switch < 1.0 && card.is_some());
|
||||
|
||||
let row_grow = card.map_or(0.0, |(_, t)| phases(((t - 2.0 * HAND_OVER) / (1.0 - 2.0 * HAND_OVER)).max(0.0)).0);
|
||||
|
||||
Scene { card, grow, pill_shown, reveal, switch, previous, middle: monitor.width as f32 / 2.0, island_w: island::width_on(here), row_grow }
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
// an item's left edge on the monitor and how much it shows: between the closed row and the carousel by `row_grow`, between two carousels by `switch`
|
||||
fn place(&self, item: Item) -> (f32, f32) {
|
||||
let closed = closed_x(item, self.middle, self.island_w);
|
||||
|
||||
let Some((open, _)) = self.card else { return (closed, 1.0) };
|
||||
|
||||
let now = carousel(item, open, open.size().0, self.middle);
|
||||
|
||||
let (open_x, open_shown, open_side) = match self.previous {
|
||||
Some(before) => {
|
||||
let from = carousel(item, before, before.size().0, self.middle);
|
||||
let (x, shown) = travel(from, now, self.switch);
|
||||
|
||||
(x, shown, if self.switch < 0.5 { from.1 } else { now.1 })
|
||||
}
|
||||
None => (now.0, 1.0, now.1),
|
||||
};
|
||||
|
||||
let (x, shown) = travel((closed, closed_side(item)), (open_x, open_side), self.row_grow);
|
||||
|
||||
(x, shown * open_shown)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* the row of pills: as wide as the monitor and as tall as a pill, never changing. amane
|
||||
* repaints a whole window every frame and hyprland then recomposites (and re-blurs)
|
||||
* everything under it, so a window as tall as the cards ran the animations at 15 fps;
|
||||
* the cards have their own window (card_view)
|
||||
*/
|
||||
pub fn row_view(monitor: &Monitor) -> LayerWindow {
|
||||
theme::begin();
|
||||
|
||||
let here = monitor.name.as_str();
|
||||
let wallpaper = theme::now().surfaces();
|
||||
let state = Pods::read();
|
||||
let scene = scene(&state, monitor);
|
||||
let (h, top, shadow) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow());
|
||||
|
||||
// with the bar on a fullscreen monitor, unless a card is open
|
||||
let slide = if scene.card.is_some() { 0.0 } else { edge::hidden_by(Edge::read().shown(here), 0.0, edge::hide_h()) };
|
||||
|
||||
let window = LayerWindow::new()
|
||||
.width(monitor.width as f32)
|
||||
.height(top + h + shadow)
|
||||
.anchor_vertical(Vertical::Top)
|
||||
.anchor_horizontal(Horizontal::Left)
|
||||
.margin(Margin { top: 0, right: 0, bottom: 0, left: 0 })
|
||||
.layer(Layer::Overlay)
|
||||
.space(Zone::Ignore)
|
||||
.namespace("magic-pods");
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
let (card, previous, switch) = (scene.card, scene.previous, scene.switch);
|
||||
|
||||
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
||||
let mut regions: Vec<InputArea> = Vec::new();
|
||||
|
||||
for item in ROW {
|
||||
let (x, seen) = scene.place(item);
|
||||
|
||||
if seen <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
match item {
|
||||
Item::Island => {
|
||||
// the island's own window draws its pill while nothing is open here
|
||||
if card.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// it shrinks to its cover (or its bars) as it moves aside
|
||||
let small = scene.row_grow;
|
||||
let width = mix(island_pill::width(), island::small_width(), small);
|
||||
let on = here.to_string();
|
||||
let pill = island::pill_face(here, small).cursor(Pointer).on_hover(edge::over_bar).on_click(move |_| island_tapped(on.clone()));
|
||||
let pill = if seen < 1.0 { pill.opacity(seen) } else { pill };
|
||||
|
||||
regions.push(InputArea { x: x as i32, y: top as i32, width: width.ceil() as i32, height: h.ceil() as i32 });
|
||||
layers.push(Box::new(pill.translate(x, top)));
|
||||
}
|
||||
Item::Pod(pod) => {
|
||||
// the open one is the card, drawn last. in a switch, the one switched to slides into
|
||||
// the card and fades over the first half; the one switched away from fades back in as a pill
|
||||
let shown = match previous {
|
||||
Some(_) if card.is_some_and(|(open, _)| open == pod) => 1.0 - 2.0 * switch,
|
||||
// its card is in the card window, which starts drawing at HAND_OVER; the pill stays a little longer under it
|
||||
_ if card.is_some_and(|(open, t)| open == pod && t > 2.0 * HAND_OVER) => continue,
|
||||
Some(before) if before == pod => switch,
|
||||
_ => 1.0,
|
||||
} * seen;
|
||||
|
||||
if shown <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
||||
let pill = pill(pod, lit, &wallpaper, here);
|
||||
let pill = if shown < 1.0 { pill.opacity(shown) } else { pill };
|
||||
|
||||
regions.push(InputArea { x: x as i32, y: (top + slide) as i32, width: h.ceil() as i32, height: h.ceil() as i32 });
|
||||
layers.push(Box::new(pill.translate(x, top + slide)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.input_region(regions).child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent)))
|
||||
}
|
||||
|
||||
/*
|
||||
* the open card, in the middle: a window as big as the biggest card while one is open
|
||||
* here, and an empty pixel otherwise. it changes size only while it shows nothing (the
|
||||
* card starts and ends as its pill, which the row window draws under it meanwhile), so
|
||||
* hyprland's squashed first frame at a new size is never seen
|
||||
*/
|
||||
pub fn card_view(monitor: &Monitor) -> LayerWindow {
|
||||
theme::begin();
|
||||
|
||||
let wallpaper = theme::now().surfaces();
|
||||
let state = Pods::read();
|
||||
let scene = scene(&state, monitor);
|
||||
let (h, top, shadow) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow());
|
||||
|
||||
let window = |width: f32, height: f32| LayerWindow::new().width(width).height(height).anchor_vertical(Vertical::Top).layer(Layer::Overlay).space(Zone::Ignore).namespace("magic-pods-card");
|
||||
|
||||
let Some((pod, t)) = scene.card else {
|
||||
return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||
};
|
||||
|
||||
let widest = PODS.iter().map(|pod| pod.size().0).fold(h, f32::max);
|
||||
let tallest = [settings::ai::height(), stats::tallest(), home::height(), history::height()].into_iter().fold(h, f32::max);
|
||||
let window_w = (widest + 2.0 * shadow).round();
|
||||
|
||||
// centred by hyprland: its left edge on the monitor
|
||||
let left = ((monitor.width as f32 - window_w) / 2.0).floor();
|
||||
let window = window(window_w, top + tallest + shadow);
|
||||
|
||||
// the first frames at the new size show at the old place, so they stay empty (the row draws the pill meanwhile)
|
||||
if t <= HAND_OVER {
|
||||
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||
}
|
||||
|
||||
let (grow, pill_shown, reveal, switch, previous, middle) = (scene.grow, scene.pill_shown, scene.reveal, scene.switch, scene.previous, scene.middle);
|
||||
|
||||
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
|
||||
let mut regions: Vec<InputArea> = Vec::new();
|
||||
|
||||
let typing = pod.types() && state.shown;
|
||||
|
||||
{
|
||||
let (card_w, card_h) = pod.size();
|
||||
|
||||
// a switch morphs the card from the one before: its size, its colour, and its face crossing over
|
||||
let (full_w, full_h, hue) = match previous {
|
||||
Some(before) => {
|
||||
let (before_w, before_h) = before.size();
|
||||
|
||||
(mix(before_w, card_w, switch), mix(before_h, card_h, switch), Color::blend(before.hue(), pod.hue(), switch))
|
||||
}
|
||||
None => (card_w, card_h, pod.hue()),
|
||||
};
|
||||
|
||||
// from its pill's place in the closed row to the middle, and back into that place; in the window
|
||||
let from_x = closed_x(Item::Pod(pod), middle, scene.island_w) - left;
|
||||
let to_x = middle - full_w / 2.0 - left;
|
||||
|
||||
let width = mix(h, full_w, grow);
|
||||
let height = mix(h, full_h, grow);
|
||||
let x = mix(from_x, to_x, grow);
|
||||
let radius = mix(h / 2.0, layout::radius(), grow);
|
||||
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
|
||||
let colors = tinted(&wallpaper, hue, mix(hues::pill_mix(), hues::card_mix(), grow));
|
||||
|
||||
let face: Rectangle = match previous {
|
||||
// the card before fades out over the first half of the switch, this one in over the second
|
||||
Some(before) if switch < 0.5 => {
|
||||
let (before_w, before_h) = before.size();
|
||||
|
||||
veiled(content(before, before_w, before_h), width, height, radius, 1.0 - 2.0 * switch, &colors)
|
||||
}
|
||||
Some(_) => veiled(content(pod, card_w, card_h), width, height, radius, 2.0 * switch - 1.0, &colors),
|
||||
None if reveal > 0.0 => veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors),
|
||||
None => {
|
||||
// the pill's icon fading as the shape grows
|
||||
let tint = Color::blend(CLEAR, pod.hue(), pill_shown);
|
||||
|
||||
Rectangle::new().width(h).height(h).align_child(Center, Center).child(icon(pod.glyph(), layout::icon(), tint))
|
||||
}
|
||||
};
|
||||
|
||||
let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face);
|
||||
let shape = surface::shell(width, height, radius, &colors, lit * pill_shown, face).on_hover(over_card).translate(x, top);
|
||||
|
||||
regions.push(InputArea { x: x as i32, y: top as i32, width: width.ceil() as i32, height: height.ceil() as i32 });
|
||||
layers.push(Box::new(shape));
|
||||
}
|
||||
|
||||
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 in its own colour: the surface with one icon; the bell carries the unread count
|
||||
fn pill(pod: Pod, lit: f32, wallpaper: &[Color; 3], monitor: &str) -> Rectangle {
|
||||
let h = island_pill::height();
|
||||
let theme = theme::now();
|
||||
let hue = pod.hue();
|
||||
let colors = tinted(wallpaper, hue, hues::pill_mix());
|
||||
|
||||
let glyph = icon(pod.glyph(), layout::icon() * (1.0 + 0.08 * lit), Color::blend(hue, theme.text, 0.25 + 0.5 * lit));
|
||||
let mut inside: Vec<Box<dyn Widget>> = vec![Box::new(Rectangle::new().width(h).height(h).align_child(Center, Center).child(glyph))];
|
||||
|
||||
let 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(hue)
|
||||
.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} switch={:.2} unread={}", pods.timeline.value(), pods.switch.value(), notices::unread())
|
||||
}
|
||||
_ => String::from("usage: pods open|toggle <ai|monitor|home|notifications> [monitor] | close | ask <question> | processes | 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())
|
||||
}
|
||||
+43
-53
@@ -14,30 +14,27 @@ 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, Shadow, Start, Text, TextInput, Vertical, Weight, Widget,
|
||||
Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Start, Text, TextInput, Vertical, Weight, Widget,
|
||||
Zone, children,
|
||||
};
|
||||
|
||||
use crate::control::{self, Control, RIGHT};
|
||||
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, CHIP, CHIP_HOVER, CHIP_OPEN, HOVER_MS, SHADOW, TOP_GAP, backdrop, veiled};
|
||||
use crate::island::{self, veiled};
|
||||
use crate::parse::{self, Pairing};
|
||||
use crate::system;
|
||||
use crate::theme;
|
||||
use crate::{surface, theme};
|
||||
|
||||
const POPUP_W: f32 = 360.0;
|
||||
const POPUP_PAD: f32 = 16.0;
|
||||
const INNER_W: f32 = POPUP_W - 2.0 * POPUP_PAD;
|
||||
const HEADER_H: f32 = 36.0;
|
||||
const FIELD_H: f32 = 36.0;
|
||||
const CODE_H: f32 = 40.0;
|
||||
const LINE_H: f32 = 18.0;
|
||||
const BUTTON_W: f32 = 92.0;
|
||||
const BUTTON_H: f32 = 32.0;
|
||||
const GAP: f32 = 14.0;
|
||||
// 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 by then is stopped
|
||||
const SESSION_S: u64 = 90;
|
||||
// a bluetoothctl session that hasn't ended after popups.timing.session_s is stopped
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Ask {
|
||||
@@ -129,7 +126,7 @@ fn open(ask: Ask, wifi: bool, name: &str, target: &str) {
|
||||
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(220));
|
||||
prompt.appear = Animation::new(0.0).duration(Duration::from_millis(timing::appear_ms()));
|
||||
prompt.appear.to(1.0);
|
||||
}
|
||||
|
||||
@@ -236,7 +233,7 @@ fn run_session(address: &str, name: &str, pair: bool) {
|
||||
|
||||
// stopped if it hasn't ended in time, which ends the reading below too
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_secs(SESSION_S));
|
||||
thread::sleep(Duration::from_secs(timing::session_s()));
|
||||
|
||||
let late = session().as_ref().is_some_and(|session| session.id == id);
|
||||
|
||||
@@ -446,7 +443,7 @@ fn hovering(spot: u8) -> impl Fn(bool) + 'static {
|
||||
Prompt::write()
|
||||
.hover
|
||||
.entry(spot)
|
||||
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS)))
|
||||
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms())))
|
||||
.to(if inside { 1.0 } else { 0.0 });
|
||||
}
|
||||
}
|
||||
@@ -508,13 +505,13 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
};
|
||||
|
||||
let header = Row::new(children![
|
||||
Rectangle::new().width(HEADER_H).height(HEADER_H).radius(Full).fill(theme.accent).align_child(Center, Center).child(icon(mark, 17.0, theme.on_accent)),
|
||||
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(13.0).weight(Weight::Bold).color(theme.text).elide(),
|
||||
Text::new(question).size(11.0).color(theme.dim).elide(),
|
||||
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 - HEADER_H - 12.0),
|
||||
.width(inner_w() - layout::header_height() - 12.0),
|
||||
])
|
||||
.gap(12.0)
|
||||
.align(Center);
|
||||
@@ -525,7 +522,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
match &request.ask {
|
||||
Ask::Password | Ask::Pin | Ask::Passkey => {
|
||||
let field = TextInput::new(request.input)
|
||||
.size(13.0)
|
||||
.size(text::popup())
|
||||
.color(theme.text)
|
||||
.placeholder(match request.ask {
|
||||
Ask::Password => "password",
|
||||
@@ -540,22 +537,22 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
|
||||
rows.push(Box::new(
|
||||
Rectangle::new()
|
||||
.width(INNER_W)
|
||||
.height(FIELD_H)
|
||||
.width(inner_w())
|
||||
.height(layout::field_height())
|
||||
.radius(10.0)
|
||||
.fill(CHIP)
|
||||
.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 = FIELD_H;
|
||||
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 = CODE_H;
|
||||
body_h = layout::code_height();
|
||||
}
|
||||
Ask::Saved(password) => {
|
||||
let password = password.clone();
|
||||
@@ -563,12 +560,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
// 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 = CODE_H;
|
||||
body_h = layout::code_height();
|
||||
}
|
||||
Ask::Failed { reason, .. } => {
|
||||
rows.push(Box::new(Rectangle::new().width(INNER_W).height(LINE_H).align_child(Start, Center).child(Text::new(reason.as_str()).size(11.5).color(theme.text).elide())));
|
||||
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 = LINE_H;
|
||||
body_h = layout::line_height();
|
||||
}
|
||||
Ask::Confirm(_) | Ask::Show(_) => {}
|
||||
}
|
||||
@@ -582,9 +579,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
let password = password.clone();
|
||||
let label = if prompt.copied.is_some() { "Copied" } else { "Copy" };
|
||||
|
||||
buttons.push(Box::new(button(label, Color::blend(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(move |_| copy(&password, || Prompt::write().copied = Some(0)))));
|
||||
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(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(|_| refuse())));
|
||||
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 {
|
||||
@@ -598,7 +595,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
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 { CHIP };
|
||||
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));
|
||||
@@ -615,27 +612,20 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
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(BUTTON_H)));
|
||||
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 * POPUP_PAD + HEADER_H + body_h + BUTTON_H + GAP * (parts - 1.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(POPUP_W).height(card_h).padding(POPUP_PAD).child(Column::new(rows).gap(GAP));
|
||||
let content = Rectangle::new().width(layout::width()).height(card_h).padding(layout::pad()).child(Column::new(rows).gap(layout::gap()));
|
||||
|
||||
let card = Rectangle::new()
|
||||
.width(POPUP_W)
|
||||
.height(card_h)
|
||||
.radius(22.0)
|
||||
.fill(backdrop(&colors, 255))
|
||||
.border(1.0, Color::blend(theme.outline, theme.accent, 0.5))
|
||||
.shadow(Shadow::drop(theme.accent).opacity(0.45).blur(22.0))
|
||||
.translate(0.0, 8.0 * (1.0 - appear))
|
||||
.child(veiled(content, POPUP_W, card_h, 22.0, appear, &colors));
|
||||
// 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) = (RIGHT + (control::map_width(monitor.width as f32) - POPUP_W) / 2.0 - SHADOW, TOP_GAP + 110.0 - SHADOW);
|
||||
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
|
||||
@@ -644,7 +634,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
*/
|
||||
let typing = matches!(request.ask, Ask::Password | Ask::Pin | Ask::Passkey);
|
||||
|
||||
let window = window(POPUP_W + 2.0 * SHADOW, card_h + 2.0 * SHADOW)
|
||||
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 {
|
||||
@@ -660,9 +650,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let region = InputArea { x: SHADOW as i32, y: SHADOW as i32, width: POPUP_W as i32, height: card_h.ceil() as i32 };
|
||||
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(SHADOW).align_child(Start, Start).child(card))
|
||||
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
|
||||
@@ -671,9 +661,9 @@ fn spaced(code: &str) -> String {
|
||||
}
|
||||
|
||||
fn big_line(text: &str, size: f32, color: Color) -> Rectangle {
|
||||
Rectangle::new().width(INNER_W).height(CODE_H).align_child(Center, Center).child(Text::new(text).size(size).weight(Weight::Bold).color(color).elide())
|
||||
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(BUTTON_W).height(BUTTON_H).radius(10.0).fill(fill).align_child(Center, Center).scale(1.0 + 0.04 * lit).child(Text::new(label).size(12.0).weight(Weight::Bold).color(ink))
|
||||
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)
|
||||
}
|
||||
+21
-21
@@ -7,23 +7,23 @@ use std::{fs, thread};
|
||||
use amane::{Argument, Bus, Media, Service};
|
||||
|
||||
use crate::art;
|
||||
use crate::config::sound::{equalizer, presets as gains, timing};
|
||||
use crate::parse::{self, EQ_PREFIX, Sink, Stream};
|
||||
use crate::theme;
|
||||
|
||||
// how far a band can lift or cut, in dB
|
||||
pub const MAX_GAIN: f32 = 12.0;
|
||||
|
||||
// gains for BANDS (31 Hz .. 16 kHz)
|
||||
pub const EQ_PRESETS: [(&str, [f32; 10]); 8] = [
|
||||
("Flat", [0.0; 10]),
|
||||
("Bass", [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("Treble", [-2.0, -1.5, -1.0, 0.0, 1.0, 2.0, 3.0, 4.5, 5.5, 6.0]),
|
||||
("Vocal", [-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.5, 2.0, 0.0, -1.0]),
|
||||
("Pop", [-1.0, 1.0, 2.5, 3.5, 2.5, 0.5, -0.5, -0.5, 1.0, 2.0]),
|
||||
("Rock", [5.0, 4.0, 2.5, -0.5, -1.5, -0.5, 2.0, 3.5, 4.5, 5.0]),
|
||||
("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]),
|
||||
];
|
||||
// the presets with their gains for the 10 bands, low to high (config.rs: sound.presets.*)
|
||||
pub fn presets() -> [(&'static str, [f32; 10]); 8] {
|
||||
[
|
||||
("Flat", gains::flat()),
|
||||
("Bass", gains::bass()),
|
||||
("Treble", gains::treble()),
|
||||
("Vocal", gains::vocal()),
|
||||
("Pop", gains::pop()),
|
||||
("Rock", gains::rock()),
|
||||
("Jazz", gains::jazz()),
|
||||
("Classic", gains::classic()),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Eq {
|
||||
@@ -101,7 +101,7 @@ impl Service for Sound {
|
||||
poll(&mut known);
|
||||
save();
|
||||
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
thread::sleep(Duration::from_millis(timing::poll_ms()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,7 @@ impl Sound {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -261,7 +261,7 @@ impl Sound {
|
||||
|
||||
// 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) {
|
||||
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 mut sound = Self::write();
|
||||
@@ -287,18 +287,18 @@ impl Sound {
|
||||
|
||||
// false when there is no preset by that name
|
||||
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;
|
||||
};
|
||||
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
apply(player, identity, gains);
|
||||
apply(player, identity, &gains);
|
||||
|
||||
true
|
||||
}
|
||||
@@ -445,7 +445,7 @@ fn start_eq(key: &str, identity: &str, gains: &[f32; 10], target: &str) {
|
||||
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -9,10 +9,10 @@ use amane::{Media, Service};
|
||||
use crate::art;
|
||||
use crate::island::{self, Island};
|
||||
use crate::parse;
|
||||
use crate::config::sound::spectrum as settings;
|
||||
use crate::sound::Sound;
|
||||
|
||||
// cava's bars, low to high frequencies; the ring mirrors them
|
||||
pub const BARS: usize = 24;
|
||||
// cava's bars (sound.spectrum.bars), low to high frequencies; the ring mirrors them
|
||||
|
||||
/*
|
||||
* the shown player's own sound as bars: `pw-record --target <its stream>`
|
||||
@@ -58,17 +58,18 @@ impl Service for Spectrum {
|
||||
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 {
|
||||
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\
|
||||
[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()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+564
@@ -0,0 +1,564 @@
|
||||
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
|
||||
}
|
||||
|
||||
// the card's height at its tallest: an external gpu with its whole list of programs
|
||||
pub fn tallest() -> f32 {
|
||||
let graphs = 7.0 * (label_height() + settings::graph_height()) + 6.0 * 10.0;
|
||||
let egpu = (1 + settings::programs()) as f32 * program_row_height();
|
||||
|
||||
2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + 5.0 * 10.0
|
||||
}
|
||||
|
||||
// bytes a second in words
|
||||
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))
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use amane::{Blend, Color, Rectangle, Stack, children};
|
||||
|
||||
use crate::config::appearance::{frosted, glass, 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 {
|
||||
if surface::design() == "glass" {
|
||||
return glass_shell(width, height, radius, colors, lift, content);
|
||||
}
|
||||
|
||||
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")));
|
||||
static GLASS: LazyLock<PathBuf> = LazyLock::new(|| shader_file("glass.wgsl", include_str!("glass.wgsl")));
|
||||
|
||||
/*
|
||||
* after Apple's Liquid Glass: glass.wgsl draws the shadow and the thin tint in the
|
||||
* wallpaper's colours under the content, and the lens-like edge with its rim over it, so a
|
||||
* photo on a card lies behind the glass; there's no border. the frosting behind is
|
||||
* Hyprland's blur; the desktop isn't bent at the rim the way Apple's glass bends it, since
|
||||
* a shell's shader can't see what's behind its window
|
||||
*/
|
||||
fn glass_shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
|
||||
let (blur, drop): (f32, f32) = (22.0, 6.0);
|
||||
let margin = (1.25 * blur + drop).ceil();
|
||||
|
||||
let mut shadow = plain(Color::BLACK);
|
||||
|
||||
shadow[3] = (glass::shadow() + 0.1 * lift).clamp(0.0, 1.0);
|
||||
|
||||
let tint = |color: Color| {
|
||||
let mut tint = plain(color);
|
||||
|
||||
tint[3] = glass::opacity().clamp(0.0, 1.0);
|
||||
|
||||
tint
|
||||
};
|
||||
|
||||
// the under layer covers the shadow's room around the shape too, the edge only the shape
|
||||
let layer = |which: f32, margin: f32| {
|
||||
let values = vec![
|
||||
[width, height, radius, margin],
|
||||
shadow,
|
||||
[blur / 2.0, 0.0, drop, lift],
|
||||
[glass::edge(), glass::light(), glass::rim(), glass::dispersion()],
|
||||
tint(colors[0]),
|
||||
tint(colors[1]),
|
||||
tint(colors[2]),
|
||||
[which, 0.0, 0.0, 0.0],
|
||||
];
|
||||
|
||||
Rectangle::new().width(width + 2.0 * margin).height(height + 2.0 * margin).translate(-margin, -margin).shader(GLASS.as_path()).shader_values(values)
|
||||
};
|
||||
|
||||
Rectangle::new().width(width).height(height).child(Stack::new(children![layer(1.0, margin), content, layer(2.0, 0.0)]).width(width).height(height))
|
||||
}
|
||||
|
||||
// a shader's source is part of the shell; amane wants a file, so it goes beside cava's config
|
||||
pub fn shader_file(name: &str, source: &str) -> PathBuf {
|
||||
let path = crate::art::folder().join(name);
|
||||
|
||||
let _ = std::fs::write(&path, source);
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
// numbers as a shader's rows of four, the last one padded with 0
|
||||
pub fn rows(numbers: &[f32]) -> Vec<[f32; 4]> {
|
||||
numbers.chunks(4).map(|chunk| std::array::from_fn(|index| chunk.get(index).copied().unwrap_or(0.0))).collect()
|
||||
}
|
||||
|
||||
type Stops = Vec<(f32, Color)>;
|
||||
|
||||
struct Look {
|
||||
// colour, strength, blur, offset
|
||||
shadow: (Color, f32, f32, (f32, f32)),
|
||||
// angle and stops: up to 3 for the fill, 5 for the gradient inside the rim (none: no stops)
|
||||
fill: (f32, Stops),
|
||||
inner: (f32, Stops),
|
||||
// width and colour
|
||||
rim: (f32, Color),
|
||||
}
|
||||
|
||||
impl Look {
|
||||
// the rows surface.wgsl reads, see its top
|
||||
fn values(&self, width: f32, height: f32, radius: f32, margin: f32) -> Vec<[f32; 4]> {
|
||||
let (color, strength, blur, (dx, dy)) = self.shadow;
|
||||
let mut shadow = plain(color);
|
||||
|
||||
shadow[3] *= strength.clamp(0.0, 1.0);
|
||||
|
||||
let mut rows = vec![
|
||||
[width, height, radius, margin],
|
||||
shadow,
|
||||
[blur / 2.0, dx, dy, 1.0],
|
||||
[self.fill.0, self.inner.0, self.fill.1.len().min(3) as f32, self.inner.1.len().min(5) as f32],
|
||||
];
|
||||
|
||||
let (colors, offsets) = stops::<3>(&self.fill.1);
|
||||
|
||||
rows.extend(colors);
|
||||
rows.push([offsets[0], offsets[1], offsets[2], 0.0]);
|
||||
|
||||
let (colors, offsets) = stops::<5>(&self.inner.1);
|
||||
|
||||
rows.extend(colors);
|
||||
rows.push([offsets[0], offsets[1], offsets[2], offsets[3]]);
|
||||
rows.push([offsets[4], 0.0, 0.0, 0.0]);
|
||||
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
// N (at most 5) stops as colours and their offsets, missing ones clear
|
||||
fn stops<const N: usize>(stops: &[(f32, Color)]) -> ([[f32; 4]; N], [f32; 5]) {
|
||||
let mut colors = [[0.0; 4]; N];
|
||||
let mut offsets = [0.0; 5];
|
||||
|
||||
for (index, (offset, color)) in stops.iter().take(N).enumerate() {
|
||||
colors[index] = plain(*color);
|
||||
offsets[index] = *offset;
|
||||
}
|
||||
|
||||
(colors, offsets)
|
||||
}
|
||||
|
||||
// a colour as a shader reads it: plain, not premultiplied, 0 to 1
|
||||
pub fn plain(color: Color) -> [f32; 4] {
|
||||
[color.red(), color.green(), color.blue(), color.alpha()].map(|part| f32::from(part) / 255.0)
|
||||
}
|
||||
|
||||
// an opacity, 0 to 1, as a colour's alpha
|
||||
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);
|
||||
}
|
||||
+11
-12
@@ -14,9 +14,8 @@ use std::{fs, thread};
|
||||
|
||||
use amane::{Argument, Bluetooth, Bus, Service, Value};
|
||||
|
||||
use crate::control::Control;
|
||||
use crate::parse;
|
||||
use crate::theme::Theme;
|
||||
use crate::config::control::{polling, services};
|
||||
|
||||
const IWD: &str = "net.connman.iwd";
|
||||
const DEVICE: &str = "net.connman.iwd.Device";
|
||||
@@ -129,7 +128,7 @@ impl Service for Links {
|
||||
loop {
|
||||
refresh();
|
||||
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
thread::sleep(Duration::from_millis(polling::links_ms()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +187,7 @@ fn wifi_rate(bus: &Bus, device: &str, network: &str) -> Option<u32> {
|
||||
|
||||
if let Some((asked, on, rate)) = last.as_ref()
|
||||
&& on == network
|
||||
&& asked.elapsed() < Duration::from_secs(60)
|
||||
&& asked.elapsed() < Duration::from_secs(polling::wifi_rate_s())
|
||||
{
|
||||
return *rate;
|
||||
}
|
||||
@@ -360,11 +359,11 @@ pub fn toggle_wifi() {
|
||||
|
||||
/*
|
||||
* 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 `hotspot` in shell.conf
|
||||
* 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 = Theme::read().hotspot.clone();
|
||||
let name = services::hotspot();
|
||||
|
||||
if !wifi.service {
|
||||
root(&["rc-service", "iwd", "start"]);
|
||||
@@ -634,7 +633,7 @@ impl Service for Power {
|
||||
loop {
|
||||
refresh_power();
|
||||
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
thread::sleep(Duration::from_millis(polling::power_ms()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,7 +675,7 @@ fn charge() -> Option<Charge> {
|
||||
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(30) => *minutes,
|
||||
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);
|
||||
|
||||
@@ -800,14 +799,14 @@ impl Service for Gpu {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// ponytail: scans every process's open files every 2 s, so only while the control centre is open
|
||||
// ponytail: scans every process's open files every 2 s, so only while the system monitor's card is open
|
||||
fn listen() {
|
||||
let mut names: HashMap<String, String> = HashMap::new();
|
||||
let mut engines: HashMap<u32, (u64, Instant)> = HashMap::new();
|
||||
|
||||
loop {
|
||||
if !Control::read().expanded() {
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
if crate::pods::open_card() != Some(crate::pods::Pod::Monitor) {
|
||||
thread::sleep(Duration::from_millis(polling::gpu_wait_ms()));
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -820,7 +819,7 @@ impl Service for Gpu {
|
||||
*Self::write() = gpu;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
thread::sleep(Duration::from_millis(polling::gpu_ms()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+77
-71
@@ -6,13 +6,14 @@ use std::{env, fs, thread};
|
||||
|
||||
use amane::{Animation, Blend, Color, Service};
|
||||
|
||||
use crate::parse;
|
||||
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/amane/shell.conf
|
||||
* says whether the open media card takes the song cover's colours instead, and how long
|
||||
* a change cross-fades. without a theme file the shell keeps its original pink on purple
|
||||
* 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 {
|
||||
@@ -28,18 +29,23 @@ pub struct Colors {
|
||||
pub surface_low: Color,
|
||||
}
|
||||
|
||||
const PINK_ON_PURPLE: Colors = Colors {
|
||||
accent: Color::rgb(247, 168, 200),
|
||||
on_accent: Color::rgb(40, 18, 32),
|
||||
accent_soft: Color::rgb(150, 82, 122),
|
||||
flame: Color::rgb(255, 228, 240),
|
||||
text: Color::rgb(241, 236, 246),
|
||||
dim: Color::rgba(205, 192, 218, 160),
|
||||
outline: Color::rgba(255, 255, 255, 26),
|
||||
surface: Color::rgb(24, 18, 30),
|
||||
surface_high: Color::rgb(35, 26, 44),
|
||||
surface_low: Color::rgb(13, 10, 18),
|
||||
};
|
||||
// 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
|
||||
@@ -70,86 +76,86 @@ impl Blend for Colors {
|
||||
|
||||
pub struct Theme {
|
||||
colors: Animation<Colors>,
|
||||
|
||||
// the open media card and the equalizer take the cover's colours as their background
|
||||
pub cover_colors: bool,
|
||||
|
||||
// the iwd access point profile the control centre's hotspot starts (/var/lib/iwd/ap/<name>.ap); empty: none
|
||||
pub hotspot: String,
|
||||
|
||||
// a second Sound slider in the control centre, for the output whose name contains this; empty: none
|
||||
pub extra_output: String,
|
||||
// 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(PINK_ON_PURPLE),
|
||||
cover_colors: false,
|
||||
hotspot: String::new(),
|
||||
extra_output: String::new(),
|
||||
}
|
||||
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.
|
||||
* shell.conf on its own thread, the theme here; either change re-reads both
|
||||
* 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();
|
||||
reload(true);
|
||||
|
||||
thread::spawn(|| follow("shell.conf"));
|
||||
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));
|
||||
|
||||
follow("theme");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// what was read last, (theme, shell.conf); none until the first read
|
||||
static LAST: Mutex<Option<(String, String)>> = Mutex::new(None);
|
||||
|
||||
fn follow(name: &str) {
|
||||
for _ in amane::watch_file(&config_file(name).to_string_lossy()) {
|
||||
// one save can arrive as several writes: let it finish, then read once
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
reload();
|
||||
}
|
||||
// redraws every view, for a setting changed in the config window
|
||||
pub fn touch() {
|
||||
let _ = Theme::write();
|
||||
}
|
||||
|
||||
// a deleted theme file sends no event: the defaults come back at the next start
|
||||
fn reload() {
|
||||
let read = (read("theme"), read("shell.conf"));
|
||||
// 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);
|
||||
|
||||
// a write that changes nothing redraws nothing
|
||||
if last.as_ref() == Some(&read) {
|
||||
if last.as_ref() == Some(&theme) && !again {
|
||||
return;
|
||||
}
|
||||
|
||||
// the colours at startup show at once, later changes cross-fade
|
||||
apply(&read.0, &read.1, last.is_none());
|
||||
apply(&theme, last.is_none());
|
||||
|
||||
*last = Some(read);
|
||||
*last = Some(theme);
|
||||
}
|
||||
|
||||
fn read(name: &str) -> String {
|
||||
fs::read_to_string(config_file(name)).unwrap_or_default()
|
||||
// $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 {
|
||||
let config = 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.join("amane").join(name)
|
||||
config_home().join("amane").join(name)
|
||||
}
|
||||
|
||||
fn apply(theme: &str, config: &str, startup: bool) {
|
||||
let mut colors = PINK_ON_PURPLE;
|
||||
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) {
|
||||
@@ -174,16 +180,15 @@ fn apply(theme: &str, config: &str, startup: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
let settings = parse::settings(config);
|
||||
let setting = |name: &str| settings.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str());
|
||||
if !startup && Theme::read().target == colors {
|
||||
return;
|
||||
}
|
||||
|
||||
let fade = if startup { 0 } else { setting("transition_ms").and_then(|ms| ms.parse().ok()).unwrap_or(400) };
|
||||
let fade = if startup { 0 } else { config::appearance::colors::transition_ms() };
|
||||
|
||||
let mut theme = Theme::write();
|
||||
|
||||
theme.cover_colors = setting("cover_colors") == Some("true");
|
||||
theme.hotspot = setting("hotspot").unwrap_or("").to_string();
|
||||
theme.extra_output = setting("extra_output").unwrap_or("").to_string();
|
||||
theme.target = colors;
|
||||
|
||||
// a change in the middle of a fade starts from the colours on screen now
|
||||
let now = theme.colors.value();
|
||||
@@ -194,19 +199,20 @@ fn apply(theme: &str, config: &str, startup: bool) {
|
||||
|
||||
// the colours of the frame being drawn, set by the view that draws it
|
||||
thread_local! {
|
||||
static NOW: Cell<Colors> = const { Cell::new(PINK_ON_PURPLE) };
|
||||
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 change). says whether the media card takes the cover's
|
||||
* 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());
|
||||
|
||||
theme.cover_colors
|
||||
config::appearance::colors::cover_colors()
|
||||
}
|
||||
|
||||
pub fn now() -> Colors {
|
||||
|
||||
+47
-69
@@ -16,13 +16,16 @@ 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, Shadow, SpaceBetween,
|
||||
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::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded, veiled};
|
||||
use crate::{edge, parse, png, theme};
|
||||
use crate::island::{CLEAR, faded, veiled};
|
||||
use crate::{edge, parse, png, surface, theme};
|
||||
|
||||
const WATCHER: &str = "org.kde.StatusNotifierWatcher";
|
||||
const WATCHER_PATH: &str = "/StatusNotifierWatcher";
|
||||
@@ -30,18 +33,7 @@ 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
|
||||
pub const SLOT: f32 = 28.0;
|
||||
const SLOT_GAP: f32 = 4.0;
|
||||
const INSET: f32 = 5.0;
|
||||
const ICON: f32 = 18.0;
|
||||
|
||||
// the menu
|
||||
const MENU_W: f32 = 260.0;
|
||||
const MENU_PAD: f32 = 6.0;
|
||||
const ROW_H: f32 = 30.0;
|
||||
const SEPARATOR_H: f32 = 9.0;
|
||||
const MAX_ROWS: usize = 20;
|
||||
// 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>
|
||||
@@ -314,7 +306,7 @@ fn forget(name: &str) {
|
||||
* (gtk's choice, hicolor, Adwaita), then /usr/share/pixmaps. themes are walked once
|
||||
* per name, the answer kept
|
||||
*/
|
||||
fn icon_file(name: &str, theme_path: &str) -> Option<PathBuf> {
|
||||
pub fn icon_file(name: &str, theme_path: &str) -> Option<PathBuf> {
|
||||
static FOUND: Mutex<Option<HashMap<String, Option<PathBuf>>>> = Mutex::new(None);
|
||||
|
||||
if name.is_empty() {
|
||||
@@ -504,7 +496,7 @@ fn hovering(spot: String) -> impl Fn(bool) + 'static {
|
||||
TrayUi::write()
|
||||
.hover
|
||||
.entry(spot.clone())
|
||||
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS)))
|
||||
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms())))
|
||||
.to(if inside { 1.0 } else { 0.0 });
|
||||
}
|
||||
}
|
||||
@@ -570,7 +562,7 @@ fn open_menu(item: &Item, monitor: &str, x: f32) {
|
||||
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(180));
|
||||
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:"));
|
||||
@@ -633,7 +625,7 @@ fn leave_soon() {
|
||||
};
|
||||
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
thread::sleep(Duration::from_millis(menu::close_ms()));
|
||||
|
||||
let gone = {
|
||||
let ui = TrayUi::read();
|
||||
@@ -663,7 +655,7 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
|
||||
|
||||
let theme = theme::now();
|
||||
let count = shown.len() as f32;
|
||||
let width = 2.0 * INSET + count * SLOT + (count - 1.0) * SLOT_GAP;
|
||||
let width = 2.0 * pill::inset() + count * pill::slot() + (count - 1.0) * pill::slot_gap();
|
||||
|
||||
let mut slots: Vec<Box<dyn Widget>> = Vec::new();
|
||||
|
||||
@@ -673,20 +665,20 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
|
||||
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(ICON).height(ICON).fill(Image::contain(path.clone())),
|
||||
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(ICON)
|
||||
.height(ICON)
|
||||
.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(12.0).weight(Weight::Bold).color(theme.text)),
|
||||
.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(SLOT)
|
||||
.height(SLOT)
|
||||
.width(pill::slot())
|
||||
.height(pill::slot())
|
||||
.radius(Full)
|
||||
.fill(if open { faded(theme.accent, 90) } else { Color::blend(CLEAR, CHIP_HOVER, lit) })
|
||||
.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)
|
||||
@@ -694,7 +686,7 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
|
||||
|
||||
let slot = if item.attention { slot.border(1.5, theme.accent) } else { slot };
|
||||
|
||||
let x = left + INSET + index as f32 * (SLOT + SLOT_GAP);
|
||||
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
|
||||
@@ -710,17 +702,9 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
|
||||
slots.push(Box::new(slot));
|
||||
}
|
||||
|
||||
let pill = Rectangle::new()
|
||||
.width(width)
|
||||
.height(COMPACT_H)
|
||||
.radius(Full)
|
||||
.fill(backdrop(&theme.surfaces(), 255))
|
||||
.border(1.0, theme.outline)
|
||||
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
|
||||
.padding(INSET)
|
||||
.align_child(Start, Center)
|
||||
.on_hover(edge::over_bar)
|
||||
.child(Row::new(slots).gap(SLOT_GAP).align(Center));
|
||||
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))
|
||||
}
|
||||
@@ -749,10 +733,10 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
|
||||
|
||||
let theme = theme::now();
|
||||
let colors = theme.surfaces();
|
||||
let inner_w = MENU_W - 2.0 * MENU_PAD;
|
||||
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;
|
||||
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);
|
||||
@@ -761,26 +745,26 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
|
||||
Some((_, label)) => Box::new(
|
||||
Rectangle::new()
|
||||
.width(inner_w)
|
||||
.height(ROW_H)
|
||||
.height(menu::row_height())
|
||||
.radius(9.0)
|
||||
.fill(Color::blend(CLEAR, CHIP_HOVER, lit_back))
|
||||
.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(12.0).weight(Weight::Bold).color(theme.text).elide())]).gap(8.0).align(Center)),
|
||||
.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(ROW_H).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(11.0).weight(Weight::Bold).color(theme.dim).elide())),
|
||||
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 += ROW_H;
|
||||
height += menu::row_height();
|
||||
|
||||
for entry in open.entries.iter().take(MAX_ROWS) {
|
||||
for entry in open.entries.iter().take(menu::max_rows()) {
|
||||
if entry.separator {
|
||||
rows.push(Box::new(Rectangle::new().width(inner_w).height(SEPARATOR_H).align_child(Center, Center).child(Rectangle::new().width(inner_w - 16.0).height(1.0).fill(faded(theme.outline, 160)))));
|
||||
height += SEPARATOR_H;
|
||||
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;
|
||||
}
|
||||
@@ -795,18 +779,18 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
|
||||
_ => Box::new(Rectangle::new().width(14.0).height(14.0)),
|
||||
};
|
||||
|
||||
let more: Box<dyn Widget> = Box::new(Text::new(if entry.submenu { "›" } else { "" }).size(14.0).color(theme.dim));
|
||||
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(ROW_H)
|
||||
.height(menu::row_height())
|
||||
.radius(9.0)
|
||||
.fill(Color::blend(CLEAR, CHIP_HOVER, lit))
|
||||
.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(12.0).color(ink).elide())]).gap(8.0).align(Center)) as Box<dyn Widget>,
|
||||
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)
|
||||
@@ -823,20 +807,15 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
|
||||
};
|
||||
|
||||
rows.push(Box::new(row));
|
||||
height += ROW_H;
|
||||
height += menu::row_height();
|
||||
}
|
||||
|
||||
let appear = ui.appear.value();
|
||||
|
||||
let content = Rectangle::new().width(MENU_W).height(height).padding(MENU_PAD).child(Column::new(rows));
|
||||
let content = Rectangle::new().width(menu::width()).height(height).padding(menu::pad()).child(Column::new(rows));
|
||||
|
||||
let card = Rectangle::new()
|
||||
.width(MENU_W)
|
||||
.height(height)
|
||||
.radius(16.0)
|
||||
.fill(backdrop(&colors, 255))
|
||||
.border(1.0, theme.outline)
|
||||
.shadow(Shadow::drop(theme.accent).opacity(0.35).blur(22.0))
|
||||
// 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 {
|
||||
@@ -847,18 +826,17 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
|
||||
} else {
|
||||
leave_soon();
|
||||
}
|
||||
})
|
||||
.child(veiled(content, MENU_W, height, 16.0, appear, &colors));
|
||||
});
|
||||
|
||||
let left = (open.x - 8.0 - SHADOW).max(0.0);
|
||||
let top = TOP_GAP + COMPACT_H + 6.0 - SHADOW;
|
||||
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: SHADOW as i32, y: SHADOW as i32, width: MENU_W as i32, height: height.ceil() as i32 };
|
||||
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_W + 2.0 * SHADOW, height + 2.0 * SHADOW)
|
||||
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(SHADOW).align_child(Start, Start).child(card))
|
||||
.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
|
||||
|
||||
+110
-64
@@ -5,21 +5,18 @@ use std::time::Duration;
|
||||
|
||||
use amane::{
|
||||
Animation, Blend, Center, Color, Full, Horizontal, InputArea, Layer, LayerWindow, Monitor, Padding, Parent, Pointer,
|
||||
Rectangle, Row, Service, Shadow, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
|
||||
Rectangle, Row, Service, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
|
||||
};
|
||||
|
||||
use crate::edge::{self, Edge, HIDE_H, Part};
|
||||
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded, mix};
|
||||
use crate::{theme, tray};
|
||||
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 sits LEFT px from the monitor's edge
|
||||
const SLOT: f32 = 28.0;
|
||||
const SLOT_GAP: f32 = 4.0;
|
||||
const INSET: f32 = 5.0;
|
||||
const LEFT: f32 = 12.0;
|
||||
|
||||
// between the workspaces' pill and the tray's
|
||||
const TRAY_GAP: f32 = 8.0;
|
||||
// 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)]
|
||||
@@ -46,14 +43,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
let workspaces = Workspaces::read();
|
||||
let hover = Hover::read();
|
||||
|
||||
// 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
|
||||
let (slide, tray_slide, switched, peeking) = {
|
||||
let edge = Edge::read();
|
||||
let shown = edge.shown(&monitor.name);
|
||||
|
||||
(edge::hidden_by(edge.part(Part::Workspaces, &monitor.name), 0.0, HIDE_H), edge::hidden_by(shown, 0.0, HIDE_H), edge.switched(&monitor.name), shown == 0.0)
|
||||
};
|
||||
|
||||
// special workspaces (scratchpads) aren't places to switch to
|
||||
let here: Vec<&Workspace> = workspaces
|
||||
.list()
|
||||
@@ -62,13 +51,65 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
.filter(|workspace| !workspace.name().is_some_and(|name| name.starts_with("special")))
|
||||
.collect();
|
||||
|
||||
let count = here.len() as f32;
|
||||
let width = 2.0 * INSET + count * SLOT + (count - 1.0).max(0.0) * SLOT_GAP;
|
||||
/*
|
||||
* 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(TOP_GAP + COMPACT_H + SHADOW)
|
||||
.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
|
||||
@@ -78,46 +119,58 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
};
|
||||
|
||||
// empty and click-through rather than hidden: see the island's view for why
|
||||
if here.is_empty() || slide <= -HIDE_H {
|
||||
return window(LEFT + width + SHADOW).click_through().child(Rectangle::new().width(0.0).height(0.0));
|
||||
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, LEFT + width + TRAY_GAP) };
|
||||
let tray_w = tray.as_ref().map_or(0.0, |(_, tray_w)| TRAY_GAP + tray_w);
|
||||
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(LEFT + width + tray_w + SHADOW);
|
||||
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
|
||||
* 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| index as f32 * (SLOT + SLOT_GAP);
|
||||
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)| here.iter().position(|workspace| workspace.id() == from).map(|at| (at, t)));
|
||||
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, t))) => {
|
||||
// ease in and out, with a slight stretch in the middle like a drop of ink
|
||||
let eased = t * t * (3.0 - 2.0 * t);
|
||||
|
||||
Some(mix(slot_x(from), slot_x(to), eased))
|
||||
}
|
||||
(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() {
|
||||
if switched.is_some() || changing {
|
||||
amane::request_frame();
|
||||
}
|
||||
|
||||
for workspace in &here {
|
||||
// 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);
|
||||
|
||||
@@ -137,55 +190,48 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
} else {
|
||||
let resting = if workspace.windows() > 0 { theme.text } else { theme.dim };
|
||||
|
||||
(Color::blend(CLEAR, CHIP_HOVER, lit), Color::blend(resting, theme.text, lit), Weight::Regular)
|
||||
(Color::blend(CLEAR, overlays::chip_hover(), lit), Color::blend(resting, theme.text, lit), Weight::Regular)
|
||||
};
|
||||
|
||||
slots.push(Box::new(
|
||||
Rectangle::new()
|
||||
.width(SLOT)
|
||||
.height(SLOT)
|
||||
.width(pill::slot())
|
||||
.height(pill::slot())
|
||||
.radius(Full)
|
||||
.fill(fill)
|
||||
.align_child(Center, Center)
|
||||
.scale(1.0 + 0.08 * lit)
|
||||
.scale(sizes[at] * (1.0 + 0.08 * lit))
|
||||
.cursor(Pointer)
|
||||
.on_hover(move |inside| hovering(id, inside))
|
||||
.on_click(move |_| focus(id))
|
||||
.child(Text::new(label).size(12.0).weight(weight).color(color)),
|
||||
.translate(slot_x(at), 0.0)
|
||||
.child(Text::new(label).size(text::body()).weight(weight).color(color)),
|
||||
));
|
||||
}
|
||||
|
||||
let row_w = width - 2.0 * INSET;
|
||||
let row_w = width - 2.0 * pill::inset();
|
||||
|
||||
// the disc under the focused slot, a little wider while it's on its way
|
||||
// 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(SLOT + stretch).height(SLOT).radius(Full).fill(theme.accent).translate(x - stretch / 2.0, 0.0)));
|
||||
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.push(Box::new(Row::new(slots).gap(SLOT_GAP).align(Center)));
|
||||
layers.extend(slots);
|
||||
|
||||
let pill = Rectangle::new()
|
||||
.width(width)
|
||||
.height(COMPACT_H)
|
||||
.radius(Full)
|
||||
.fill(backdrop(&theme.surfaces(), 255))
|
||||
.border(1.0, theme.outline)
|
||||
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
|
||||
.padding(INSET)
|
||||
.align_child(Start, Center)
|
||||
.on_hover(edge::over_bar)
|
||||
.child(Stack::new(layers).width(row_w).height(SLOT));
|
||||
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: LEFT as i32, y: TOP_GAP as i32, width: width.ceil() as i32, height: COMPACT_H as i32 }];
|
||||
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: (LEFT + width + TRAY_GAP) as i32, y: TOP_GAP as i32, width: tray_pill_w.ceil() as i32, height: COMPACT_H as i32 });
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -193,9 +239,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
Rectangle::new()
|
||||
.width(Parent)
|
||||
.height(Parent)
|
||||
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: LEFT })
|
||||
.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(TRAY_GAP).align(Start)),
|
||||
.child(Row::new(pills).gap(pill::tray_gap()).align(Start)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -234,6 +280,6 @@ fn hovering(id: i64, inside: bool) {
|
||||
hover
|
||||
.0
|
||||
.entry(id)
|
||||
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS)))
|
||||
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms())))
|
||||
.to(if inside { 1.0 } else { 0.0 });
|
||||
}
|
||||
|
||||
@@ -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