Compare commits

3 Commits
Author SHA1 Message Date
server2 7d8525e95c AGENTS.md: settings, config window, workspace ghost; condensed 2026-10-15 02:59:52 +02:00
server2 3b9929aff4 Esc closes the config window; a removed empty workspace closes up during the switch
Hyprland drops an empty workspace the moment it's left, so its number vanished
and the row jumped before the disc slid. Its slot now stays for the slide,
shrinking and fading, and the pill narrows with it.
2026-10-15 02:55:30 +02:00
server2 4fbfad3ce3 Config window: every setting in config.toml, Hyprland options from Hyprland
All tunable values (sizes, timings, text sizes, colours, polls, equalizer
presets and bands, hotspot, extra output) are settings declared once in
config.rs, read from ~/.config/amane/config.toml, applied live on save.
shell.conf's four values move over on first start.

settings.rs: a normal window (control centre gear, or ipc config) with a
page per part, a page per Hyprland category, search and reset per row.
hypr.rs: Hyprland's options from hyprctl descriptions/getoption, applied
with hl.config and kept in ~/.config/hypr/amane.lua.
2026-10-15 02:18:57 +02:00
21 changed files with 3305 additions and 908 deletions
+84 -117
View File
@@ -1,154 +1,127 @@
# AGENTS.md # 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 ## Where things are
- **This repo:** `/root/amane-config` on the dev host; remote `git@server1:fabolous005/amane-config.git`. - **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). - **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:** `ssh -o BatchMode=yes fabian@192.168.2.169`. Hyprland 0.56.2 with a **Lua** config (`~/.config/hypr/*.lua`; API stubs in `/usr/share/hypr/stubs/hl.meta.lua`). Intel Xe iGPU (Raptor Lake). Monitors: eDP-1 1920×1200 at 1920,620; HDMI-A-1 1920×1080 at 0,620; DP-2 1920×1080 at 3840,0; HDMI-A-4 at 5760,0 only when connected. `~/.config/amane` on the desktop is a git checkout of this repo.
- **Desktop daemons the control centre uses:** OpenRC services `iwd` (Wi-Fi), `bluetooth` (bluez), `openvpn.<name>` (VPN); doas rule `permit nopass fabian` for `rc-service`, `sh` and `cat`; `wl-copy`, `ip`, `lspci`. iwd and bluetoothd are often stopped; the shell then greys their tiles. - **Desktop 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.
## Ground rules from the user ## 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.** 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.**
- **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. - **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. - **The user's own amane:** they sometimes run it themselves. Don't kill it without asking; `tools/amane-remote.sh start` refuses when it finds a shell it didn't start.
- **Desktop access is SSH only.** Don't probe the network, SSH keys or other hosts. If SSH fails, report it and wait (the user once had to restart sshd). - **Desktop access is SSH only.** Don't probe the network, SSH keys or other hosts. If SSH fails, report it and wait.
- **Leave the desktop clean** after testing: fake players and null sinks stopped, shell running without the frame log (see "After testing"). - **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. - **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. - **No hardcoded tuning values:** every size, timing, text size, colour or interval is a setting in `config.rs` (see Settings). The user asked for this explicitly.
- **Blur strength is Hyprland's only** (`decoration.blur.*`); the shell never sets it. A shell blur setting was added once and reverted at the user's request.
- **D-Bus only for reading status.** Actions go through the CLIs: `iwctl` to join, scan and disconnect, `bluetoothctl` to pair and connect (the user had a D-Bus agent version reverted, `afe963d`). The one exception, chosen by the user: the shell serves `org.kde.StatusNotifierWatcher` and calls tray apps' dbusmenus. Ask before serving anything else on the bus.
- **Never wake iwd by accident:** any D-Bus call to `net.connman.iwd` starts it (it then joins Wi-Fi and adds a route). Check `org.freedesktop.DBus.NameHasOwner` first; only talk to it while it has an owner. - **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. - **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. - Edits to AGENTS.md prompt for approval (`security.protected_instruction_files`); the user approves them at the screen, and a timed-out prompt is a no: don't retry by another path. Keep this file near 20k characters, Hermes's load limit.
- Report honestly what was and wasn't checked on the real desktop. - Report honestly what was and wasn't checked on the real desktop.
## Current state ## 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, instant transport buttons), an idle pill when no player is open, one island per monitor (card on one monitor, live bars only on the focused one), a workspace pill per monitor, wallpaper colours through matugen, and:
- **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): a status pill (Wi-Fi or hotspot, VPN, Bluetooth, Ethernet, battery) that opens a panel: tiles for Wi-Fi, Bluetooth, VPN, Hotspot, Ethernet; volume with the output, brightness, keyboard light, performance mode, external-GPU programs; a Settings button (gear) at its foot. The panel morphs into its menus as one shape: Wi-Fi and Bluetooth maps (click to join/pair, right-click a saved network for its password, click the centre to search again), VPN list, sound outputs, Ethernet details with copyable addresses. Popups (`magic-prompt`) ask for passwords, PINs and passkeys and say why something failed.
- **System tray** right of the workspace pill. - **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. - **Bar hiding:** only on monitors showing a fullscreen window; the pointer on the top edge brings it back. Peeks: the workspace pill alone on a workspace change, the island on a song change or a new player. A peek starts from where the part is (no dip).
- **Reserved space:** windows that aren't fullscreen stay below the bar (44 px at the top of every monitor). - **Workspace switch:** Hyprland deletes an empty workspace the moment it's left; its slot stays for the slide, shrinking and fading, so the row closes up with the disc's move instead of jumping.
- **Reserved space:** windows that aren't fullscreen stay below the bar (44 px).
- **Settings** (branch `config-window`, merged 2026-10-15): `~/.config/amane/config.toml` holds all 184 shell settings with their help; the config window (`Magic Island settings`, a normal window; Esc closes it) edits them plus every Hyprland option. See Settings.
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. On 2026-10-13 the user tried the mouse-only parts (tray clicks, top-edge reveal, Copy buttons, PIN pairing): "All works". The agent itself only drives them over IPC.
**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): **Glass designs** (`glass-base`, `design-frosted`, `design-liquid`, pushed, not merged): `glass-base` adds `src/surface.rs` (`shell()` draws the island, idle pill and workspace pill; with glass below 1 the open/close fade uses `.opacity()`, as a veil can't hide content on see-through glass) and a `glass` opacity; `design-frosted` (0.62, thin tint, hairline white rim, top sheen, soft black shadow) and `design-liquid` (0.4, specular gradient, light rim warming to `flame` on hover, accent glow) build on it. They branched from `036b326`, before the control centre and the settings, and read `glass` from `shell.conf`, which master no longer has: port them as settings (`appearance.surface.*`), not as a revived `shell.conf`. Measured 2026-10-09 (pill / open card GPU ms per frame): solid 1.6 / 3.3, liquid 2.2 / 3.3, frosted 2.9 / 3.3. `~/.config/hypr/windowrules.lua` has the glass layer rule (`^magic-.*$`, blur, `ignore_alpha = 0.5`).
| Branch | What it is | **On the desktop:** the agent-started shell runs the `config-window` build (pid in `~/.cache/amane-remote-test/shell.pid`, no frame log). `config.toml` was made from the old `shell.conf` (still there, unread). `~/.config/hypr/hyprland.lua` does **not** `require("amane")` yet (checked 2026-10-15), so Hyprland options set in the window are lost on a Hyprland reload; the user was told to add it. amane is not autostarted: after a reboot the user starts it.
|---|---|
| `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. **Theme pipeline:** Skwd-wall v2's matugen integration `amane` renders `~/.config/amane/matugen/amane.colors` into `~/.config/amane/theme` (scheme `scheme-fidelity`).
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`).
## Constraints of Amane itself ## 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 (`std::process::Command`, `amane::output`).
- Amane redraws the whole window every frame, so every effect is paid at the frame rate. - Amane redraws the whole window every frame, so every effect 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. - `amane compile` saves `~/.cache/amane/amane-shell`; `amane run` starts it.
## Architecture ## Architecture
| Piece | Kind | Job | | Piece | Kind | Job |
|---|---|---| |---|---|---|
| `island.rs` `Island` | input-only Service | `open: Animation` (0 pill, 1 card), `expanded`, `selected` player (bus name; `None` follows the playing one), open `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`). | | `config.rs` | settings | Every setting declared once in `settings! { part { group { name: type = default, range; "help" } } }`: a typed accessor (`config::island::pill::width()`) plus an `OPTIONS` entry (path, help, kind, range, default) for the window. Values live in a `RwLock`; reading per draw costs nothing measurable. `load()` reads `config.toml` (first start: writes it, moving `shell.conf`'s 4 values over); `save()` rewrites it with every setting and its help. `[hyprland.*]` tables hold Hyprland overrides. |
| `sound.rs` `Sound` | Service, 1 s poll | `pactl list sinks` + `sink-inputs` → each player's streams (matched by process ancestry, then app name), volume, mute, output, equalizer. Curves saved by player identity to `~/.config/amane/island-eq`. | | `settings.rs` | view, normal `Window` | The config window: a page per part and per Hyprland category, search over everything, a row per setting (switch, slider + field, text with colour swatch, choice chips), reset per row, `config.toml` errors at the top. Esc closes. The `config` IPC handler (`command`). |
| `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). | | `hypr.rs` `Hypr` | Service, worker thread | Hyprland's options from Hyprland itself (`hyprctl descriptions -j`, one `--batch` of `j/getoption`), so no option is named in code. `set`/`reset` queue jobs (superseded ones dropped); the worker applies with `hl.config`, rewrites `~/.config/hypr/amane.lua` and `config.toml`; a reset reloads Hyprland's config so the user's value returns. |
| `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`. | | `island.rs` `Island` | input-only Service | `open: Animation` (0 pill, 1 card), `expanded`, `selected` player (`None` follows the playing one), open `menu`, time-bar `scrub`, `hover` (per-`Spot`), `opened_on` / `pointer_on` (monitors), `playing_now`. Also the view and the `island` IPC handler. |
| `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). | | `sound.rs` `Sound` | Service, poll | `pactl list sinks` + `sink-inputs` → each player's streams (by process ancestry, then app name), volume, mute, output, equalizer; `presets()` from settings. Curves saved by player identity to `~/.config/amane/island-eq`. |
| `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. | | `spectrum.rs` `Spectrum` | Service | While the shown player plays: `pw-record --target <stream> \| cava -p $XDG_RUNTIME_DIR/magic-island/cava.conf` → levels (`sound.spectrum.bars`). On start it `pkill -KILL`s cava left by an earlier shell (cava ignores SIGTERM). |
| `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. | | `art.rs` `Art` | Service, poll | Cover URL → local file, copied to `palette-cover`, which `Palette` (opened in `main.rs`) watches. Only used for `cover_colors`. |
| `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. | | `theme.rs` `Theme` | Service, `amane::watch_file` | 10 colours from `~/.config/amane/theme`, faded together on one `Animation<Colors>`; also watches `config.toml` (own thread) and reloads it. Views start with `theme::begin()` and draw with `theme::now()`; `theme::touch()` redraws every view. No theme file: `appearance.fallback.*`. `watch_file` watches the folder, so matugen's rename-over saves count. |
| `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`. | | `workspaces.rs` | view + input-only `Hover` | Workspace pill per monitor (`magic-workspaces`, top left), slots placed by x in one `Stack` under one sliding accent disc. Click runs `hyprctl dispatch 'hl.dsp.focus({ workspace = N })'` on a thread. |
| `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. | | `parse.rs` | plain functions | pactl/cava/proc parsing, filter-chain config, EQ store, `key = value`, colours, iwctl/bluetoothctl output, Wi-Fi bars, `hyprctl` JSON, `ip`, tray icons; the TOML reader/writer (`Value`, `toml`), a JSON reader (`json`, skips non-JSON lines between values), Hyprland option values (`hypr_option`, `hypr_lua`). Unit-tested on its own. |
| `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). | | `control.rs` `Control` | view + input-only Service | The control centre (`magic-control`) and its menu window (`magic-control-menu`: the panel morphs to the menu's size, then its content fades in). `menu_content(menu, map_w, ..)`, `map_width(monitor_w)`, the `control` IPC handler. |
| `system.rs` `Links`, `Power`, `Gpu` | Services, threads | iwd (D-Bus reads, only while it has an owner), openvpn, bluez state, wired cards from sysfs, battery, brightness, keyboard light, power profile, eGPU programs from fdinfo; actions via CLIs. Intervals in `control.polling.*`. |
| `prompt.rs` `Prompt` | view + input-only Service | The popup (`magic-prompt`): password, PIN, passkey, confirm, code, saved password, failure reason. Runs the `bluetoothctl` pairing session. `prompt::copy(text, done)` is the one wl-copy. |
| `map.rs` | widget functions | The Wi-Fi/Bluetooth maps: nodes on an ellipse round the centre bubble (nearer = stronger), floating, lines, search rings. Sizes in `control.maps.*` (8 nodes at most: a 9th makes labels meet at 822 px; picked by simulating the drift). |
| `glyph.rs` | shapes | Every icon as `Shape`s; maps draw all of theirs into ONE canvas (`glyph_shapes`), as each `Canvas` has a large fixed GPU cost. | | `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. | | `edge.rs` `Edge` | Service, Hyprland sockets | `.socket2.sock` events and `.socket.sock` queries, no polling. Hides the bar on fullscreen monitors, the 1 px `magic-edge` strip, peeks, the island's pop, the workspace slide (`switched`), `hide_h()`, and `spacer` (`magic-reserve`, 44 px). |
| `tray.rs` `Tray`, `TrayUi` | Services, bus threads | Owns `org.kde.StatusNotifierWatcher` (only if no other watcher runs) and is its host; 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. | | `tray.rs` `Tray`, `TrayUi` | Services, bus threads | Owns `org.kde.StatusNotifierWatcher` (only if no other watcher runs) and is its host; icons from themes or `IconPixmap`; dbusmenu menus in `magic-tray-menu`; the `tray` IPC handler. |
| `png.rs` | plain functions | Stored-deflate PNG writer for pixmap tray icons (amane draws images from files). | | `png.rs` | plain functions | Stored-deflate PNG writer for pixmap tray icons. |
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 equalizer: one `setsid -f pipewire -c eq-<key>.conf` per player with `libpipewire-module-filter-chain`, 10 biquads at `sound.equalizer.bands`. The player's streams move into sink `magic_island_eq.<key>`; gains change live with `pw-cli set-param`. A player only gets one when its curve or output first changes, or it has a saved non-flat curve.
### The view (island.rs `view`) ### The view (island.rs `view`)
- 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. - One `LayerWindow` per monitor (`magic-island`, `Layer::Overlay` because the user's windows are fullscreen), sized for the open card; an `InputArea` lets clicks outside through. No player: `idle()`, a click-through pill.
- No player: `idle()`, a static click-through pill with a drawn note and "Nothing playing". - `tap(monitor)` opens the card where it was clicked, `expand()` (IPC) on the focused monitor. Only the focused window and the one with the card read `Spectrum` (reading subscribes), so the rest stay idle.
- 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 timeline (`island.animation.open_ms`): pill fades out, the empty shape grows until `shape_done`, then the card fades in. At most one face per frame; `veiled()` fades with a backdrop-coloured veil instead of `.opacity()`.
- `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: one 150° three-stop gradient (`backdrop()`); with `cover_colors` the card blends to the cover's colours as it opens. White-alpha overlays (`appearance.overlays.*`, `CLEAR`) stay unthemed.
- 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)` eases a `Spot` over `appearance.motion.hover_ms`. Amane matches hover targets by index in the last frame, so a vanished button never hears the pointer leave: entering a spot fades all others, and `collapse()`/`set_menu()` drop stale highlights.
- 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`; `refresh_soon()` re-reads Media at 150 and 600 ms.
- 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). ## Settings
- Moving the pointer off the open card collapses it.
- **Add a setting** by declaring it in `config.rs`'s `settings!` block in the right part and group, with a range and a one-line help; use the accessor in code. Derived values (`inner_w()` and the like) are functions over settings, not constants. Text sizes are roles (`appearance.text.tiny|caption|small|label|body|heading|section|title|popup`), not numbers. Remaining `const`s are names and identities only (D-Bus names, `CLEAR`, `TAU`).
- The font and cava's bar count apply on restart; everything else on save.
- `amane ipc call config get|set|reset <path> [value]` drives everything without the mouse (`hyprland.<option.path>` for Hyprland, e.g. `hyprland.decoration.blur.size`); `status` says `open=… page=… errors=… search=…`.
- `hl.config` value forms: numbers and bools plain, gradients `{ colors = { "rgba(…)" }, angle = 45.0 }`, vec2 `{ x, y }`, css gaps a number or `{ top, right, bottom, left }`. `hyprctl --batch` of all `j/getoption` prints plain "invalid type (internal error)" lines between objects for a few options.
## Hyprland 0.56 (Lua config) pitfalls ## 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))`. - **Never return `visible(false)` from a layer view**: a hidden-at-start layer window stays blank forever ("has no child to draw"). Return `window.click_through().child(Rectangle::new().width(0.0).height(0.0))`.
- **`Workspaces::focus` does nothing:** it sends `dispatch workspace N`, which the Lua config rejects. Use `hl.dsp.focus({ workspace = N })`. Test dispatches harmlessly on the already-focused workspace. - **`Workspaces::focus` does nothing** (sends `dispatch workspace N`). Use `hl.dsp.focus({ workspace = N })`.
- `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. - `hyprctl eval` / `repl` share one Lua state; globals (a rule kept in a variable) vanish on a config reload.
- Layer-rule `match.namespace` is a full match: `^magic-` matches nothing, `^magic-.*$` works. Layer rules can only switch blur on; blur strength (`decoration.blur.size`, `passes`) is global. - Layer-rule `match.namespace` is a full match: `^magic-.*$`. Layer rules can only switch blur on; strength is global.
- `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead instances; the live one has `.socket.sock` (`amane-remote.sh` picks it). A shell started with a dead signature gets an empty `Workspaces` list. - `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead instances; the live one has `.socket.sock`. A shell started with a dead signature gets an empty `Workspaces` list.
- `hyprctl layers` lists hidden layer windows too (alpha 0); filter on `alpha > 0`. - `hyprctl layers` lists hidden layer windows too (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:`. - **Reserved space:** `Zone::Reserve` reserves the whole window height and only on a window anchored to one edge. Tiled and maximised windows stay out of the strip; fullscreen 2/3 cover it. Bar windows keep `Zone::Ignore`. Check with `hyprctl monitors` (`reserved: 0 44 0 0`).
- **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 only shows on the **focused** monitor; test with a workspace hop there, then restore the focus.
- 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. - `hl.dsp.window.float(...)` acts on the focused window. To screenshot the config window floating, add a temporary rule: `hyprctl eval 'r = hl.window_rule({ name = "amane-settings", match = { title = "^Magic Island settings$" }, float = true, center = true, size = { 1100, 760 } })'`.
## Performance rules ## 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. - **Measure before and after every visual change**, baseline first, same player and monitor: Amane's per-frame `gpu` time and `top` CPU (the iGPU busy counter reads 100% even with the shell paused).
- Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in. - Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in. Don't resize a layer window every frame: give it the largest size for the animation (the workspace ghost does).
- `.clip()` and `.opacity(<1)` each add a GPU layer (a clip over the whole card once took the iGPU from 47 to 9 fps). A shadow is two layers per frame: halos only while hovered. - `.clip()` and `.opacity(<1)` each add a GPU layer (a clip over the whole card once took the iGPU from 47 to 9 fps); a shadow is two. Halos only while hovered.
- 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). - One backdrop gradient is affordable, many aren't. A veil over a gradient must be the same gradient at the same size.
- Draw repeated marks (candles, bars) as one `Path` per look. - 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. - Streaming data needs `amane::request_frame()` in the view while levels are live.
- `Rectangle::child` holds one child; stack layers with `Stack`.
## Develop ## 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): 1. Type-check in a scratch crate outside the repo (don't commit a `Cargo.toml`), at `/root/.hermes/cache/scratch/amane-check/` (pruned after 24 h idle): `[package] name = "amane-shell"`, edition 2024, `[[bin]] path = '/root/amane-config/src/main.rs'`, `amane = { path = '<copy of the desktop's ~/git/amane>' }`, an empty `[workspace]`. Then `cargo clippy` (first build takes minutes).
2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (12 tests).
```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).
## Testing on the desktop ## 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 ```sh
H=fabian@192.168.2.169 H=fabian@192.168.2.169
@@ -157,44 +130,38 @@ 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 # stop the shell it started earlier, start one WITH frame logging
tools/amane-remote.sh $H fps 3 # frames, average gap, gpu wait tools/amane-remote.sh $H fps 3 # frames, average gap, gpu wait
tools/amane-remote.sh $H anim island # open/close 3x; stutter = 20-500 ms gaps tools/amane-remote.sh $H anim island # open/close 3x; stutter = 20-500 ms gaps
tools/amane-remote.sh $H shot magic-island out.png
tools/amane-remote.sh $H sinks start # null sinks island_test_a/_b tools/amane-remote.sh $H sinks start # null sinks island_test_a/_b
tools/amane-remote.sh $H players start 4 # fake MPRIS players; "Synth Test" plays a synth into island_test_a tools/amane-remote.sh $H players start 4 # fake MPRIS players; "Synth Test" plays into island_test_a
tools/amane-remote.sh $H ipc island status
tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical session's environment tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical session's environment
``` ```
- Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound; stopping the fake players lets the island fall back to the user's player. - Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound.
- `ipc island open` opens on the **focused** monitor (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. - `ipc island open` opens on the **focused** monitor. Read geometry from `hyprctl layers -j` right before each `grim` capture. With one window per monitor the frame log mixes windows.
- With one window per monitor the frame log mixes windows; resting ones show ~1000 ms gaps. - **Animations:** capture a strip in a loop (`grim -g "3840,0 300x60"` takes ~20 ms) while dispatching the change, then tile the frames with ffmpeg (`tile=2x12`) and look at one sheet.
- 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). - Keys to a focused window: `wtype -k Escape`. Hover can't be faked with `movecursor`; real motion moves the user's mouse, so ask first.
- 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. - Control centre and tray without a mouse: `ipc control open|close|menu <wifi|bluetooth|vpn|sound>|ethernet|status` (`status` ends with `prompt=…`), `ipc tray list|menu|open N|activate N|choose ID|close`. Fake tray app: `python3 tools/fake_tray.py name|path`; kill it by pid.
- Before trusting timings, check `uptime`: a background Gentoo build once made every build look broken. - Maps with many nodes: deploy a scratch `src/` whose `map::map` injects fake nodes (never commit it), then redeploy the real one.
- A fresh shell once exited with "lost the gpu (Device is lost)" (Mesa warns Xe is experimental); starting again worked. - Measure gaps and alignment from pixels (`ffmpeg … crop=1920:1:0:Y -f rawvideo`), not by asking a vision model.
- To tune a visualiser, sample real levels read-only (`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. - To tune a visualiser, sample real levels read-only first (`pw-record --target <stream> | cava`, `timeout -s KILL` on each stage); typical music sits at 0.15–0.35.
- 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). - Check `uptime` before trusting timings. In `pkill -f` patterns over SSH use `[p]ython3 ...`. A fresh shell once died with "lost the gpu"; starting again worked.
- **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).
### After testing ### After testing
```sh ```sh
tools/amane-remote.sh $H players stop tools/amane-remote.sh $H players stop
tools/amane-remote.sh $H sinks stop tools/amane-remote.sh $H sinks stop
tools/amane-remote.sh $H sh '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 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" > /dev/null 2>&1'
``` ```
## Open threads ## 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. - The user reviews the glass designs, ported onto master as settings; then merge the chosen one.
- The user adds `require("amane")` to `hyprland.lua` (or asks the agent to).
- The config window was only driven over IPC and Esc; clicking, dragging and typing in it are untested by the agent.
- The candle ring is still mirrored (`mirrored()`); the pill's bars aren't. - 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. - Upstream Amane fixes for the two Hyprland 0.56 issues (hidden layer at start, `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. - `island-eq` is tracked but rewritten live; untracking it was proposed, no answer yet. Leftovers offered for deletion, no answer: `src.before-test/` and old screenshots in `~/.cache/amane-remote-test/` on the desktop, merged local branches on the dev host.
- 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 and run under the island.
- 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. - The open/close stutter rise (25–40 slow frames per 3 opens and closes on every build) is unexplained.
- See README "Known limits". - See README "Known limits".
+33 -12
View File
@@ -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). - *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. - **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. - **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). - **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. - **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. - **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> 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: 4. To share `eth0`'s internet with the hotspot's devices, forwarding and NAT (iwd doesn't do this), e.g. with nftables:
```sh ```sh
@@ -109,16 +109,25 @@ Register the template in Skwd-wall as a matugen integration:
The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`). 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).
``` ## 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 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.
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 The config window edits the same file: Settings at the foot of the control centre, or `amane ipc call config toggle`. It is a normal window with a page per part of the shell, a page per Hyprland category, and a search over everything. Numbers with a range get a slider and a field, switches toggle, colours show a swatch; a typed value applies on Enter. Reset brings back the default.
### 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")
``` ```
Keep comments on lines of their own in the real file: a `#` after a value becomes part of it. Reset on a Hyprland option removes it from both files and reloads Hyprland's config, so your own value comes back.
Gradients are typed `rgba(80d9ffff) rgba(b18cffff) 45deg` (or `#80d9ff`), points `0 0`, gaps `6` or `6 10` (css-style).
## Layout ## Layout
@@ -137,10 +146,12 @@ 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/png.rs a stdlib png writer, for tray icons sent as pixels
src/system.rs what the control centre shows: iwd, wired cards, openvpn, power, the external GPU src/system.rs what the control centre shows: iwd, wired cards, openvpn, power, the external GPU
src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one
src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade src/theme.rs the shell's colours: the matugen theme file, the cross-fade; watches config.toml
src/config.rs every setting, declared once (typed accessors + the window's list), config.toml read and written
src/hypr.rs Hyprland's options for the config window: hyprctl descriptions/getoption, hl.config, amane.lua
src/settings.rs the config window, `config` IPC
src/parse.rs pure text parsing with unit tests, no amane dependency src/parse.rs pure text parsing with unit tests, no amane dependency
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme 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) 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`), see AGENTS.md
``` ```
@@ -157,7 +168,7 @@ On the desktop that runs the shell:
## Install and run ## 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 ```sh
amane compile amane compile
@@ -176,6 +187,14 @@ 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 preset Rock # apply an equalizer preset to the shown player
amane ipc call island output <sink> # move the shown player to a sink (pactl sink name) amane ipc call island output <sink> # move the shown player to a sink (pactl sink name)
amane ipc call island status # player=<identity> expanded=<bool> menu=<bool> amane ipc call island status # player=<identity> expanded=<bool> menu=<bool>
amane ipc call config toggle # the config window; also: open, close
amane ipc call config page island # open it on a page: appearance, island, ..., hyprland:decoration
amane ipc call config search blur # open it searching
amane ipc call config get island.pill.width
amane ipc call config set island.pill.width 260 # any setting by its config.toml path
amane ipc call config set hyprland.decoration.blur.size 3
amane ipc call config reset island.pill.width
amane ipc call config status # open=<bool> page=<id> errors=<n> search=<words>
``` ```
Example Hyprland keybind: Example Hyprland keybind:
@@ -186,6 +205,8 @@ bind = SUPER, M, exec, amane ipc call island toggle
## Runtime files ## Runtime files
- `~/.config/amane/config.toml`: every setting (see [Settings](#settings)); rewritten by the config window.
- `~/.config/hypr/amane.lua`: the Hyprland options set in the config window, as `hl.config`; rewritten by the shell.
- `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`. - `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`.
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout. - `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout.
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`). - Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
-16
View File
@@ -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
+2 -1
View File
@@ -7,6 +7,7 @@ use std::{env, fs, thread};
use amane::{Media, Service}; use amane::{Media, Service};
use crate::island::{self, Island}; use crate::island::{self, Island};
use crate::config::island::timing;
use crate::parse; use crate::parse;
/* /*
@@ -42,7 +43,7 @@ impl Service for Art {
} }
// 4 checks a second: after next/previous the new cover follows the title quickly // 4 checks a second: after next/previous the new cover follows the title quickly
thread::sleep(Duration::from_millis(250)); thread::sleep(Duration::from_millis(timing::cover_check_ms()));
} }
} }
} }
+814
View File
@@ -0,0 +1,814 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex, PoisonError, RwLock};
use amane::Color;
use crate::parse::{self, Value};
use crate::{hypr, theme};
/*
* every setting the shell has, in ~/.config/amane/config.toml. each is declared once
* below: the macro makes a typed function for the code (config::island::pill::width())
* and an entry in OPTIONS for the config window and the file. the file holds every
* setting with its help above it; saving it applies at once (theme.rs watches it).
* [hyprland.*] tables are Hyprland options changed in the window, see hypr.rs
*/
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Kind {
Bool,
Int,
Float,
Text,
Color,
List,
}
pub struct Opt {
// "module.group.name", also its place in config.toml
pub path: &'static str,
pub help: &'static str,
pub kind: Kind,
pub range: Option<(f64, f64)>,
pub default: fn() -> Value,
}
impl Opt {
pub fn module(&self) -> &'static str {
self.path.split('.').next().unwrap_or("")
}
pub fn group(&self) -> &'static str {
self.path.split('.').nth(1).unwrap_or("")
}
pub fn name(&self) -> &'static str {
self.path.rsplit('.').next().unwrap_or("")
}
}
// a type a setting can have: how it reads from and writes to the file
pub trait Setting: Sized {
const KIND: Kind;
fn read(value: &Value) -> Option<Self>;
fn value(&self) -> Value;
}
impl Setting for bool {
const KIND: Kind = Kind::Bool;
fn read(value: &Value) -> Option<Self> {
if let Value::Bool(on) = value { Some(*on) } else { None }
}
fn value(&self) -> Value {
Value::Bool(*self)
}
}
impl Setting for f32 {
const KIND: Kind = Kind::Float;
fn read(value: &Value) -> Option<Self> {
value.number().map(|number| number as f32)
}
// through its own shortest text, so 0.66 is written 0.66 and not 0.6600000262260437
fn value(&self) -> Value {
Value::Float(self.to_string().parse().unwrap_or(f64::from(*self)))
}
}
macro_rules! whole_setting {
($($ty:ty),*) => {$(
impl Setting for $ty {
const KIND: Kind = Kind::Int;
fn read(value: &Value) -> Option<Self> {
if let Value::Int(number) = value { <$ty>::try_from(*number).ok() } else { None }
}
fn value(&self) -> Value {
Value::Int(i64::try_from(*self).unwrap_or(i64::MAX))
}
}
)*};
}
whole_setting!(u32, u64, usize);
impl Setting for String {
const KIND: Kind = Kind::Text;
fn read(value: &Value) -> Option<Self> {
value.text().map(String::from)
}
fn value(&self) -> Value {
Value::Text(self.clone())
}
}
impl Setting for Color {
const KIND: Kind = Kind::Color;
fn read(value: &Value) -> Option<Self> {
color(value.text()?)
}
fn value(&self) -> Value {
Value::Text(self.hex())
}
}
impl Setting for [f32; 10] {
const KIND: Kind = Kind::List;
fn read(value: &Value) -> Option<Self> {
let Value::List(items) = value else { return None };
let numbers: Vec<f32> = items.iter().map(|item| item.number().map(|number| number as f32)).collect::<Option<_>>()?;
numbers.try_into().ok()
}
fn value(&self) -> Value {
Value::List(self.iter().map(Setting::value).collect())
}
}
impl Setting for [u32; 10] {
const KIND: Kind = Kind::List;
fn read(value: &Value) -> Option<Self> {
let Value::List(items) = value else { return None };
let numbers: Vec<u32> = items.iter().map(u32::read).collect::<Option<_>>()?;
numbers.try_into().ok()
}
fn value(&self) -> Value {
Value::List(self.iter().map(Setting::value).collect())
}
}
// "#rrggbb" or "#rrggbbaa", the # optional
pub fn color(text: &str) -> Option<Color> {
let digits = text.trim().trim_start_matches('#');
if !(digits.len() == 6 || digits.len() == 8) || !digits.chars().all(|digit| digit.is_ascii_hexdigit()) {
return None;
}
let channel = |at: usize| u8::from_str_radix(&digits[at..at + 2], 16).ok();
Some(Color::rgba(channel(0)?, channel(2)?, channel(4)?, if digits.len() == 8 { channel(6)? } else { 255 }))
}
macro_rules! settings {
($( $module:ident { $( $group:ident { $( $name:ident : $ty:ty = $default:expr $(, $min:literal ..= $max:literal)? ; $help:literal )* } )* } )*) => {
$( pub mod $module {
$( pub mod $group {
#[allow(unused_imports)]
use super::super::*;
$(
#[allow(dead_code)]
pub fn $name() -> $ty {
super::super::get(concat!(stringify!($module), ".", stringify!($group), ".", stringify!($name)), || $default)
}
)*
} )*
} )*
pub static OPTIONS: &[Opt] = &[ $( $( $( Opt {
path: concat!(stringify!($module), ".", stringify!($group), ".", stringify!($name)),
help: $help,
kind: <$ty as Setting>::KIND,
range: settings!(@range $($min, $max)?),
default: || <$ty as Setting>::value(&$default),
}, )* )* )* ];
};
(@range) => { None };
(@range $min:literal, $max:literal) => { Some(($min as f64, $max as f64)) };
}
settings! {
appearance {
font {
family: String = String::from("Fira Code"); "The font for all text. Takes effect when the shell restarts"
letter_width: f32 = 0.66, 0.3..=1.2; "How wide one letter is, as a share of the text size: chips are sized to their labels with it. Match it to the font"
}
text {
tiny: f32 = 10.0, 6..=40; "The smallest text: badges, the equalizer's band labels, details under map nodes"
caption: f32 = 10.5, 6..=40; "Subtitles under the control centre's tiles"
small: f32 = 11.0, 6..=40; "Secondary text: chips, notes, percentages, the artist line"
label: f32 = 11.5, 6..=40; "Labels in menus and popups"
body: f32 = 12.0, 6..=40; "Most text"
popup: f32 = 13.0, 6..=40; "A popup's title and its main line"
heading: f32 = 14.0, 6..=40; "A menu's title"
section: f32 = 15.0, 6..=40; "The equalizer's heading"
title: f32 = 19.0, 6..=60; "The song title on the open media card"
}
colors {
cover_colors: bool = false; "The open media card and the equalizer take the song cover's colours; off, the whole shell uses the wallpaper's"
transition_ms: u64 = 400, 0..=5000; "How long a colour change cross-fades, in milliseconds; 0 switches at once"
}
motion {
hover_ms: u64 = 220, 0..=2000; "How long hover highlights take to fade in and out"
}
fallback {
accent: Color = Color::rgb(247, 168, 200); "Accent, used until matugen writes ~/.config/amane/theme from the wallpaper"
on_accent: Color = Color::rgb(40, 18, 32); "Text and icons on the accent colour, without a theme file"
accent_soft: Color = Color::rgb(150, 82, 122); "The softer accent, without a theme file"
flame: Color = Color::rgb(255, 228, 240); "Highlights such as the candles' flame tips, without a theme file"
text: Color = Color::rgb(241, 236, 246); "Text, without a theme file"
dim: Color = Color::rgba(205, 192, 218, 160); "Secondary text, without a theme file"
outline: Color = Color::rgba(255, 255, 255, 26); "Outlines, without a theme file"
surface: Color = Color::rgb(24, 18, 30); "The middle of the background gradient, without a theme file"
surface_high: Color = Color::rgb(35, 26, 44); "The light end of the background gradient, without a theme file"
surface_low: Color = Color::rgb(13, 10, 18); "The dark end of the background gradient, without a theme file"
}
overlays {
chip: Color = Color::rgba(255, 255, 255, 18); "Fill of chips and blocks; white with little alpha reads on any dark surface"
chip_open: Color = Color::rgba(255, 255, 255, 34); "A chip whose menu is open"
chip_hover: Color = Color::rgba(255, 255, 255, 46); "A hovered chip or button"
track: Color = Color::rgba(255, 255, 255, 24); "Slider tracks and the time bar"
strike: Color = Color::rgba(205, 205, 212, 170); "The line through the icon of a service that isn't running"
idle_line: Color = Color::rgba(205, 205, 212, 85); "The wobbling line to a map node that isn't connected"
low_battery: Color = Color::rgb(255, 105, 97); "The battery when it runs low"
}
}
island {
animation {
open_ms: u64 = 560, 0..=5000; "How long the island takes to open into the card, and to close"
pill_gone: f32 = 0.18, 0.0..=1.0; "Share of the opening over which the pill fades out"
shape_done: f32 = 0.6, 0.0..=1.0; "Share of the opening by which the empty shape has grown; the card fades in after it"
hover_grow: f32 = 10.0, 0..=100; "How much wider the pill gets when hovered, in pixels"
bold: f32 = 2.0, 0..=8; "How much thicker the play, pause, previous and next icons get when hovered"
}
timing {
seek_shown_ms: u64 = 1500, 0..=10000; "How long the time bar keeps showing a seek while the player catches up"
cover_check_ms: u64 = 250, 50..=5000; "How often the cover art is checked for a change"
}
pill {
width: f32 = 220.0, 100..=800; "Width of the closed island (the pill)"
height: f32 = 38.0, 20..=100; "Height of every pill: the island, workspaces, tray and control centre"
top_gap: f32 = 6.0, 0..=100; "Space between the top of the monitor and the pills"
shadow: f32 = 34.0, 0..=120; "Room around every pill and panel for its glow"
bars: usize = 7, 1..=32; "Sound bars in the pill"
bars_width: f32 = 36.0, 8..=200; "Width of the pill's sound bars"
bars_height: f32 = 26.0, 4..=100; "Height of the pill's sound bars"
bars_boost: f32 = 1.4, 0.2..=5.0; "How much the pill's bars amplify the sound levels"
}
card {
width: f32 = 560.0, 360..=1600; "Width of the open media card"
radius: f32 = 28.0, 0..=100; "Corner radius of the open card"
pad: f32 = 22.0, 0..=100; "Padding inside the card"
gap: f32 = 16.0, 0..=100; "Space between the card's sections"
side_gap: f32 = 20.0, 0..=100; "Space between the cd and the song details"
}
disc {
size: f32 = 204.0, 80..=600; "The cd's box, candles included; also the height of the card's top section"
cover: f32 = 136.0, 40..=600; "Diameter of the cover on the cd"
hole: f32 = 26.0, 0..=200; "Diameter of the cd's hole"
candles: usize = 40, 4..=200; "Candles in the ring around the cd"
candle_reach: f32 = 20.0, 0..=200; "How far the candles reach past the cover"
cover_pixels: u32 = 256, 32..=2048; "Size covers are decoded at, for the pill and the cd"
}
controls {
chip_height: f32 = 24.0, 10..=80; "Height of the output, player and volume chips"
output_chip_max: f32 = 150.0, 40..=600; "The widest the output chip gets"
volume_chip_width: f32 = 60.0, 20..=300; "Width of the volume chip"
bar_height: f32 = 8.0, 1..=40; "Thickness of the time bar"
knob: f32 = 16.0, 2..=60; "Size of the time bar's and equalizer's knobs"
height: f32 = 44.0, 16..=120; "Height of the row with previous, play and next"
}
equalizer {
header_height: f32 = 26.0, 10..=100; "Height of the equalizer's heading row"
slider_height: f32 = 140.0, 40..=600; "Height of the equalizer's sliders"
track_width: f32 = 10.0, 1..=60; "Width of a slider's track"
label_height: f32 = 14.0, 6..=60; "Height of the band labels under the sliders"
curve_steps: usize = 72, 8..=500; "Points on the curve drawn through the bands"
preset_height: f32 = 30.0, 10..=100; "Height of a preset button"
preset_gap: f32 = 8.0, 0..=60; "Space between the preset buttons"
}
menus {
top: f32 = 92.0, 0..=600; "How far below the card's padding the output and player menus open"
row_height: f32 = 30.0, 12..=100; "Height of a row in those menus"
rows: usize = 10, 1..=50; "Rows a menu shows before it scrolls"
row_letters: usize = 30, 4..=200; "Letters of a device name a menu row shows before cutting the middle"
chip_letters: usize = 13, 4..=200; "Letters of a device name the output chip shows before cutting the middle"
}
}
sound {
equalizer {
max_gain: f32 = 12.0, 1..=30; "How far a band can lift or cut, in dB"
bands: [u32; 10] = parse::BANDS, 10..=24000; "The 10 bands' frequencies in Hz. Equalizers started afterwards use them"
}
presets {
flat: [f32; 10] = [0.0; 10], -30..=30; "Gains of the Flat preset, in dB, low band to high"
bass: [f32; 10] = [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], -30..=30; "Gains of the Bass preset"
treble: [f32; 10] = [-2.0, -1.5, -1.0, 0.0, 1.0, 2.0, 3.0, 4.5, 5.5, 6.0], -30..=30; "Gains of the Treble preset"
vocal: [f32; 10] = [-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.5, 2.0, 0.0, -1.0], -30..=30; "Gains of the Vocal preset"
pop: [f32; 10] = [-1.0, 1.0, 2.5, 3.5, 2.5, 0.5, -0.5, -0.5, 1.0, 2.0], -30..=30; "Gains of the Pop preset"
rock: [f32; 10] = [5.0, 4.0, 2.5, -0.5, -1.5, -0.5, 2.0, 3.5, 4.5, 5.0], -30..=30; "Gains of the Rock preset"
jazz: [f32; 10] = [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.5, 2.5, 3.0], -30..=30; "Gains of the Jazz preset"
classic: [f32; 10] = [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0], -30..=30; "Gains of the Classic preset"
}
timing {
poll_ms: u64 = 1000, 100..=10000; "How often players' streams, volumes and outputs are read"
hold_ms: u64 = 2500, 0..=10000; "How long a dragged volume keeps its place while PipeWire catches up"
}
spectrum {
bars: usize = 24, 4..=128; "Bars cava splits the sound into. Takes effect when the capture next starts"
check_ms: u64 = 200, 50..=5000; "How often the shown player is checked, to start or stop capturing its sound"
}
}
workspaces {
pill {
slot: f32 = 28.0, 10..=100; "Size of one workspace's round slot"
slot_gap: f32 = 4.0, 0..=40; "Space between slots"
inset: f32 = 5.0, 0..=40; "Space between the slots and the pill's edge"
left: f32 = 12.0, 0..=400; "Distance from the monitor's left edge"
tray_gap: f32 = 8.0, 0..=100; "Space between the workspaces and the tray"
}
}
tray {
pill {
slot: f32 = 28.0, 10..=100; "Size of one tray icon's slot"
slot_gap: f32 = 4.0, 0..=40; "Space between slots"
inset: f32 = 5.0, 0..=40; "Space between the slots and the pill's edge"
icon: f32 = 18.0, 6..=80; "Size of the icons"
}
menu {
width: f32 = 260.0, 100..=800; "Width of an app's menu"
pad: f32 = 6.0, 0..=40; "Padding inside the menu"
row_height: f32 = 30.0, 12..=100; "Height of a menu row"
separator_height: f32 = 9.0, 1..=40; "Height of a separator"
max_rows: usize = 20, 1..=100; "The most rows a menu shows"
appear_ms: u64 = 180, 0..=2000; "How long the menu takes to appear"
close_ms: u64 = 400, 0..=5000; "How long after the pointer leaves the menu closes"
}
}
control {
services {
hotspot: String = String::new(); "The iwd access point profile the hotspot tile starts (/var/lib/iwd/ap/<name>.ap). Empty: the tile says how to set one up"
extra_output: String = String::new(); "A second Sound slider for the output whose name contains this, like an AV receiver. Empty: none"
}
animation {
open_ms: u64 = 520, 0..=5000; "How long the panel takes to open and close"
menu_ms: u64 = 380, 0..=5000; "How long a menu takes to open from its tile"
hover_grow: f32 = 6.0, 0..=100; "How much wider the pill gets when hovered"
collapse_ms: u64 = 250, 0..=5000; "How long after the pointer leaves the panel closes"
held_ms: u64 = 1200, 0..=10000; "How long a dragged slider shows where it was let go while the system catches up"
busy_s: u64 = 10, 1..=120; "How long a clicked tile shows it's working before giving up"
pending_s: u64 = 20, 1..=300; "How long a network or device shows as connecting after a click"
}
pill {
pad: f32 = 16.0, 0..=100; "Padding on each side of the pill's icons"
gap: f32 = 12.0, 0..=100; "Space between the pill's icons"
right: f32 = 12.0, 0..=400; "Distance of the pill and panel from the monitor's right edge"
}
panel {
width: f32 = 360.0, 240..=1000; "Width of the panel"
radius: f32 = 26.0, 0..=100; "Corner radius of the panel"
pad: f32 = 12.0, 0..=100; "Padding inside the panel"
gap: f32 = 10.0, 0..=100; "Space between the panel's tiles and blocks"
tile_height: f32 = 60.0, 30..=200; "Height of a connection tile"
button: f32 = 34.0, 16..=120; "Size of a tile's round button"
footer_height: f32 = 32.0, 16..=120; "Height of the Settings button at the bottom"
}
modules {
radius: f32 = 18.0, 0..=100; "Corner radius of the blocks under the tiles"
pad: f32 = 12.0, 0..=100; "Padding inside a block"
title_height: f32 = 18.0, 8..=80; "Height of a block's title row"
line_gap: f32 = 8.0, 0..=60; "Space between the lines in a block"
slider_height: f32 = 28.0, 8..=100; "Height of a slider"
keys_height: f32 = 26.0, 8..=100; "Height of the keyboard light's buttons"
profile_height: f32 = 48.0, 16..=200; "Height of the performance profile buttons"
vram_height: f32 = 14.0, 4..=60; "Height of the external GPU's memory line"
row_height: f32 = 22.0, 8..=100; "Height of a program row under the external GPU"
max_rows: usize = 5, 1..=50; "Programs listed under the external GPU before \"and N more\""
}
menus {
pad: f32 = 12.0, 0..=100; "Padding inside a menu"
header_height: f32 = 28.0, 10..=100; "Height of a menu's title row"
row_height: f32 = 44.0, 16..=150; "Height of a menu row"
rows: usize = 7, 1..=50; "The most VPN rows the VPN menu lists"
note_height: f32 = 18.0, 6..=80; "Height of a note line in a menu"
output_height: f32 = 22.0, 8..=100; "Height of a row in the Sound menu"
outputs: usize = 8, 1..=50; "The most outputs the Sound menu lists"
}
ethernet {
width: f32 = 500.0, 240..=1200; "Width of the Ethernet details"
detail_height: f32 = 28.0, 10..=100; "Height of a detail row"
label_width: f32 = 78.0, 20..=300; "Width of a detail's label"
copy_width: f32 = 58.0, 20..=200; "Width of a Copy button"
}
maps {
height: f32 = 480.0, 200..=1600; "Height of the Wi-Fi and Bluetooth maps"
min_width: f32 = 600.0, 200..=3000; "The narrowest a map gets on a small monitor (where it then runs under the island)"
island_gap: f32 = 16.0, 0..=200; "Space between a map and the media island's pill"
nodes: usize = 8, 1..=40; "The most networks or devices a map shows; the strongest come first"
bubble: f32 = 40.0, 10..=200; "Size of the bubble for this computer in the middle"
node_radius: f32 = 22.0, 6..=100; "Radius of a network's or device's disc"
icon: f32 = 18.0, 6..=80; "Size of the icons on the map"
label_width: f32 = 140.0, 40..=500; "Width of a node's label"
label_height: f32 = 30.0, 10..=100; "Height of a node's label"
drift_x: f32 = 26.0, 0..=200; "How far nodes float sideways"
drift_y: f32 = 16.0, 0..=200; "How far nodes float up and down"
ring_s: f32 = 1.6, 0.1..=10.0; "How long a search ring takes to ripple out, in seconds"
ring_gap_s: f32 = 0.5, 0.0..=10.0; "Time between the three search rings, in seconds"
ring_reach: f32 = 230.0, 20..=2000; "How far the search rings ripple"
}
polling {
links_ms: u64 = 2000, 200..=60000; "How often Wi-Fi, VPN and hotspot state is read"
power_ms: u64 = 2000, 200..=60000; "How often the battery and performance profile are read"
gpu_ms: u64 = 2000, 200..=60000; "How often the external GPU's programs are scanned while the panel is open"
gpu_wait_ms: u64 = 400, 50..=10000; "How often the GPU scan checks whether the panel has opened"
wifi_rate_s: u64 = 60, 1..=3600; "How long a Wi-Fi network's link speed is kept before it's asked again"
battery_estimate_s: u64 = 30, 1..=3600; "How long a battery time estimate is kept"
}
}
popups {
layout {
width: f32 = 360.0, 200..=1000; "Width of a popup (password, PIN, failure)"
pad: f32 = 16.0, 0..=100; "Padding inside a popup"
header_height: f32 = 36.0, 10..=120; "Height of a popup's title row"
field_height: f32 = 36.0, 16..=120; "Height of the text field"
code_height: f32 = 40.0, 16..=120; "Height of a shown PIN or passkey"
line_height: f32 = 18.0, 8..=80; "Height of a line of text"
button_width: f32 = 92.0, 30..=300; "Width of a button"
button_height: f32 = 32.0, 16..=100; "Height of a button"
gap: f32 = 14.0, 0..=100; "Space between a popup's parts"
}
timing {
appear_ms: u64 = 220, 0..=2000; "How long a popup takes to appear"
session_s: u64 = 90, 5..=900; "How long a Bluetooth pairing may take before it's stopped"
}
}
bar {
hiding {
dwell_ms: u64 = 300, 0..=5000; "How long the pointer rests on the top edge of a fullscreen monitor before the bar shows"
hide_ms: u64 = 700, 0..=10000; "How long after the pointer leaves the bar hides again"
slide_ms: u64 = 220, 0..=3000; "How long the bar takes to slide in or out"
}
peeks {
workspaces_ms: u64 = 1600, 0..=20000; "How long the workspaces show on a fullscreen monitor after a workspace change"
island_ms: u64 = 3500, 0..=20000; "How long the island shows on a fullscreen monitor after a song change"
pop_s: f32 = 0.7, 0.0..=5.0; "How long the island's pop lasts on a new song, in seconds"
switch_s: f32 = 0.35, 0.0..=5.0; "How long the workspace highlight takes to slide over, in seconds"
media_check_ms: u64 = 300, 50..=5000; "How often the song is checked for a change, for the island's peek"
}
}
}
// what config.toml set, by path; a missing or bad setting takes its default
static VALUES: LazyLock<RwLock<HashMap<String, Value>>> = LazyLock::new(Default::default);
// what was wrong in the file when it was last read, for the config window
static ERRORS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// ponytail: a lock and a hash lookup per call, a few hundred a frame; a snapshot struct if a profile ever shows it
fn get<T: Setting>(path: &str, default: impl FnOnce() -> T) -> T {
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).and_then(T::read).unwrap_or_else(default)
}
pub fn file() -> PathBuf {
theme::config_file("config.toml")
}
pub fn option(path: &str) -> Option<&'static Opt> {
OPTIONS.iter().find(|option| option.path == path)
}
// at startup: config.toml, or the first time, one made from the old shell.conf
pub fn load() {
let text = fs::read_to_string(file()).unwrap_or_else(|_| {
migrate();
let text = render();
let _ = fs::write(file(), &text);
text
});
apply_text(&text);
}
// shell.conf's four settings, moved to their places in config.toml
fn migrate() {
let old = fs::read_to_string(theme::config_file("shell.conf")).unwrap_or_default();
let mut values = VALUES.write().unwrap_or_else(PoisonError::into_inner);
for (key, text) in parse::settings(&old) {
let path = match key.as_str() {
"cover_colors" => "appearance.colors.cover_colors",
"transition_ms" => "appearance.colors.transition_ms",
"hotspot" => "control.services.hotspot",
"extra_output" => "control.services.extra_output",
_ => continue,
};
if let Some(option) = option(path)
&& let Ok(value) = from_text(option, &text)
{
values.insert(path.to_string(), value);
}
}
}
// the file's text, read anew: what it sets replaces everything set before
pub fn apply_text(text: &str) {
let (pairs, mut errors) = parse::toml(text);
let mut values = HashMap::new();
for (path, value) in pairs {
match check(&path, &value) {
Ok(()) => {
values.insert(path, value);
}
Err(why) => errors.push(format!("{path}: {why}")),
}
}
*VALUES.write().unwrap_or_else(PoisonError::into_inner) = values;
*ERRORS.lock().unwrap_or_else(PoisonError::into_inner) = errors;
hypr::sync_soon();
}
// whether a value fits its setting: kind, range, a list's length. Hyprland's are checked by Hyprland
fn check(path: &str, value: &Value) -> Result<(), String> {
if path.starts_with("hyprland.") {
return if matches!(value, Value::Bool(_) | Value::Int(_) | Value::Float(_) | Value::Text(_)) { Ok(()) } else { Err(String::from("needs a value")) };
}
let option = option(path).ok_or("there's no such setting")?;
let fits = match option.kind {
Kind::Bool => matches!(value, Value::Bool(_)),
Kind::Int => matches!(value, Value::Int(number) if *number >= 0),
Kind::Float => value.number().is_some(),
Kind::Text => value.text().is_some(),
Kind::Color => value.text().and_then(color).is_some(),
Kind::List => match (value, (option.default)()) {
(Value::List(items), Value::List(default)) => items.len() == default.len() && items.iter().all(|item| item.number().is_some()),
_ => false,
},
};
if !fits {
return Err(format!("needs {}", kind_words(option)));
}
let numbers: Vec<f64> = match value {
Value::List(items) => items.iter().filter_map(Value::number).collect(),
_ => value.number().into_iter().collect(),
};
if let Some((min, max)) = option.range
&& numbers.iter().any(|number| *number < min || *number > max)
{
return Err(format!("must be from {min} to {max}"));
}
Ok(())
}
fn kind_words(option: &Opt) -> String {
match option.kind {
Kind::Bool => String::from("true or false"),
Kind::Int => String::from("a whole number"),
Kind::Float => String::from("a number"),
Kind::Text => String::from("text in quotes"),
Kind::Color => String::from("a colour like \"#f7a8c8\" or \"#ffffff12\""),
Kind::List => match (option.default)() {
Value::List(items) => format!("a list of {} numbers", items.len()),
_ => String::from("a list"),
},
}
}
pub fn errors() -> Vec<String> {
ERRORS.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
// a setting's value, its default where config.toml doesn't set it
pub fn current(option: &Opt) -> Value {
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(option.path).cloned().unwrap_or_else(option.default)
}
pub fn is_default(option: &Opt) -> bool {
current(option) == (option.default)()
}
// the Hyprland options set here, as (hyprland.decoration.blur.size, value), sorted
pub fn overrides() -> Vec<(String, Value)> {
let mut found: Vec<(String, Value)> = VALUES
.read()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.filter(|(path, _)| path.starts_with("hyprland."))
.map(|(path, value)| (path.clone(), value.clone()))
.collect();
found.sort_by(|a, b| a.0.cmp(&b.0));
found
}
pub fn override_of(path: &str) -> Option<Value> {
VALUES.read().unwrap_or_else(PoisonError::into_inner).get(path).cloned()
}
// sets one setting and saves the file; the shell redraws with it at once
pub fn set(path: &str, value: Value) -> Result<(), String> {
check(path, &value)?;
VALUES.write().unwrap_or_else(PoisonError::into_inner).insert(path.to_string(), value);
save()
}
// back to the default (a Hyprland option: back to Hyprland's own config)
pub fn reset(path: &str) -> Result<(), String> {
VALUES.write().unwrap_or_else(PoisonError::into_inner).remove(path);
save()
}
fn save() -> Result<(), String> {
fs::write(file(), render()).map_err(|error| format!("couldn't write {}: {error}", file().display()))?;
theme::touch();
Ok(())
}
// config.toml as the shell writes it: every setting with its help, then the Hyprland options set here
pub fn render() -> String {
let mut out = String::from(
"# Magic Island settings. The config window (amane ipc call config toggle, or Settings in the\n\
# control centre) writes this file; hand edits apply as soon as it's saved. A setting left out\n\
# takes its default. Saving from the window rewrites the file, comments included.\n",
);
let mut table = "";
for option in OPTIONS {
let at = &option.path[..option.path.len() - option.name().len() - 1];
if at != table {
out.push_str(&format!("\n[{at}]\n"));
table = at;
}
for line in wrapped(option.help, 96) {
out.push_str(&format!("# {line}\n"));
}
out.push_str(&format!("{} = {}\n", option.name(), parse::toml_text(&current(option))));
}
let overrides = overrides();
if !overrides.is_empty() {
out.push_str(
"\n# Hyprland options changed in the config window. The shell writes them to ~/.config/hypr/amane.lua;\n\
# load that last in hyprland.lua with require(\"amane\") so they win over the rest of your config.\n",
);
let mut table = String::new();
for (path, value) in overrides {
let (at, name) = path.rsplit_once('.').unwrap_or(("hyprland", &path));
if at != table {
out.push_str(&format!("\n[{at}]\n"));
table = at.to_string();
}
out.push_str(&format!("{name} = {}\n", parse::toml_text(&value)));
}
}
out
}
// help text as comment lines of at most `width` letters
fn wrapped(text: &str, width: usize) -> Vec<String> {
let mut lines: Vec<String> = vec![String::new()];
for word in text.split_whitespace() {
let line = lines.last_mut().expect("starts with one line");
if !line.is_empty() && line.len() + 1 + word.len() > width {
lines.push(word.to_string());
} else {
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
}
lines
}
// a value as the config window shows it
pub fn shown(value: &Value) -> String {
match value {
Value::Bool(on) => on.to_string(),
Value::Int(number) => number.to_string(),
Value::Float(number) => parse::number_text(*number),
Value::Text(text) => text.clone(),
Value::List(items) => items.iter().map(shown).collect::<Vec<_>>().join(" "),
Value::Null | Value::Table(_) => String::new(),
}
}
// what was typed for a setting, as its value; checked when it's set
pub fn from_text(option: &Opt, text: &str) -> Result<Value, String> {
let text = text.trim();
let wrong = || format!("{} needs {}", option.name(), kind_words(option));
match option.kind {
Kind::Bool => match text {
"true" | "yes" | "on" | "1" => Ok(Value::Bool(true)),
"false" | "no" | "off" | "0" => Ok(Value::Bool(false)),
_ => Err(wrong()),
},
Kind::Int => text.parse::<i64>().map(Value::Int).map_err(|_| wrong()),
Kind::Float => text.parse::<f64>().map(Value::Float).map_err(|_| wrong()),
Kind::Text => Ok(Value::Text(text.to_string())),
Kind::Color => color(text).map(|color| Value::Text(color.hex())).ok_or_else(wrong),
Kind::List => {
let whole = matches!((option.default)(), Value::List(items) if items.first().is_some_and(|item| matches!(item, Value::Int(_))));
text.split(|letter: char| letter.is_whitespace() || letter == ',')
.filter(|word| !word.is_empty())
.map(|word| if whole { word.parse().map(Value::Int).ok() } else { word.parse().map(Value::Float).ok() })
.collect::<Option<Vec<_>>>()
.map(Value::List)
.ok_or_else(wrong)
}
}
}
// the shell's pages in the window, in the order they're declared
pub fn modules() -> Vec<&'static str> {
let mut found: Vec<&'static str> = Vec::new();
for option in OPTIONS {
if !found.contains(&option.module()) {
found.push(option.module());
}
}
found
}
// a page's or group's name as the window shows it
pub fn title(id: &str) -> String {
match id {
"control" => String::from("Control centre"),
"bar" => String::from("Bar hiding"),
"input-capture" => String::from("Input capture"),
"opengl" => String::from("OpenGL"),
"xwayland" => String::from("XWayland"),
_ => {
let words = id.replace(['_', '-'], " ");
let mut letters = words.chars();
letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
}
}
}
// a setting's name as a label: "peek_ms" reads "Peek (ms)"
pub fn label(name: &str) -> String {
for (unit, words) in [("_ms", " (ms)"), ("_s", " (s)")] {
if let Some(stem) = name.strip_suffix(unit) {
return format!("{}{words}", title(stem));
}
}
title(name)
}
+224 -239
View File
File diff suppressed because it is too large Load Diff
+17 -26
View File
@@ -1,7 +1,7 @@
/* /*
* when the bar shows. on a monitor whose workspace shows a fullscreen window, the island, * 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 * 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 * 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 * 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 * 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 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; 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 // 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 // the parts an event may bring in alone
#[derive(Clone, Copy, PartialEq, Eq, Hash)] #[derive(Clone, Copy, PartialEq, Eq, Hash)]
@@ -120,13 +111,13 @@ impl Edge {
self.shown(monitor).max(peek) 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 { pub fn pop(&self, part: Part, monitor: &str) -> f32 {
let Some(at) = self.pops.get(&(part, monitor.to_string())) else { let Some(at) = self.pops.get(&(part, monitor.to_string())) else {
return 0.0; 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 } if t < 1.0 { (t * PI).sin() * (1.0 - t * 0.5) } else { 0.0 }
} }
@@ -134,7 +125,7 @@ impl Edge {
// a workspace change on this monitor still sliding: the workspace it came from, and how far along, 0 to 1 // 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)> { pub fn switched(&self, monitor: &str) -> Option<(i64, f32)> {
let (from, at) = self.switched.get(monitor)?; 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)) (t < 1.0).then_some((*from, t))
} }
@@ -251,8 +242,8 @@ fn peek(edge: &mut Edge, part: Part, monitor: &str) {
entry.1 = began; entry.1 = began;
let hold = match part { let hold = match part {
Part::Island => PEEK_ISLAND_MS, Part::Island => peeks::island_ms(),
Part::Workspaces => PEEK_WORKSPACES_MS, Part::Workspaces => peeks::workspaces_ms(),
}; };
thread::spawn(move || { thread::spawn(move || {
@@ -282,7 +273,7 @@ fn watch_media() {
let mut last: Option<(Vec<String>, String)> = None; let mut last: Option<(Vec<String>, String)> = None;
loop { loop {
thread::sleep(Duration::from_millis(300)); thread::sleep(Duration::from_millis(peeks::media_check_ms()));
let (players, song) = { let (players, song) = {
let media = Media::read(); let media = Media::read();
@@ -362,7 +353,7 @@ fn at_edge(monitor: String) -> impl Fn(bool) + 'static {
// still resting there after the dwell: this monitor's bar comes back // still resting there after the dwell: this monitor's bar comes back
thread::spawn(move || { thread::spawn(move || {
thread::sleep(Duration::from_millis(DWELL_MS)); thread::sleep(Duration::from_millis(hiding::dwell_ms()));
let mut edge = Edge::write(); let mut edge = Edge::write();
@@ -375,10 +366,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) { fn hide_soon(moves: u32) {
thread::spawn(move || { thread::spawn(move || {
thread::sleep(Duration::from_millis(HIDE_MS)); thread::sleep(Duration::from_millis(hiding::hide_ms()));
let mut edge = Edge::write(); let mut edge = Edge::write();
@@ -395,7 +386,7 @@ fn hide_soon(moves: u32) {
} }
fn sliding(at: f32) -> Animation { 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 +419,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
pub fn spacer(_: &Monitor) -> LayerWindow { pub fn spacer(_: &Monitor) -> LayerWindow {
LayerWindow::new() LayerWindow::new()
.width(Full) .width(Full)
.height(TOP_GAP + COMPACT_H) .height(pill::top_gap() + pill::height())
.anchor_vertical(Vertical::Top) .anchor_vertical(Vertical::Top)
.layer(Layer::Background) .layer(Layer::Background)
.space(Zone::Reserve) .space(Zone::Reserve)
+14
View File
@@ -30,6 +30,8 @@ pub enum Glyph {
Laptop, Laptop,
// a network socket, for a wired link // a network socket, for a wired link
Ethernet, Ethernet,
// a cog, for the config window
Gear,
} }
pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas { pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
@@ -220,5 +222,17 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
line(0.5, 0.46, 0.5, 0.62), line(0.5, 0.46, 0.5, 0.62),
line(0.64, 0.46, 0.64, 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
}
} }
} }
+410
View File
@@ -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);
}
}
+169 -207
View File
@@ -11,95 +11,59 @@ use amane::{
}; };
use crate::art::Art; use crate::art::Art;
use crate::edge::{self, Edge, HIDE_H, Part}; use crate::edge::{self, Edge, Part};
use crate::glyph::{Glyph, bold_icon, icon}; use crate::glyph::{Glyph, bold_icon, icon};
use crate::parse::{self, BANDS, Sink}; use crate::parse::{self, Sink};
use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound}; use crate::sound::{self, Sound};
use crate::spectrum::Spectrum; use crate::spectrum::Spectrum;
use crate::theme; use crate::theme;
/* /*
* opening is one linear timeline in two phases, so the shape never resizes with the * opening is one linear timeline in two phases, so the shape never resizes with the
* whole card in it: the pill fades out, the empty shape grows, then the card fades in * whole card in it: the pill fades out, the empty shape grows, then the card fades in
* on the settled shape. closing runs the same timeline backwards * on the settled shape. closing runs the same timeline backwards. every size and timing
* is a setting (config.rs: island.*), read as it's drawn
*/ */
const OPEN_MS: u64 = 560; use crate::config::appearance::{font, motion, overlays, text};
pub const PILL_GONE: f32 = 0.18; use crate::config::island as c;
pub const SHAPE_DONE: f32 = 0.6;
// hover highlights ease in and out over this long; the pill widens by PILL_HOVER_GROW // the card's inner width, and what's derived from its settings
pub const HOVER_MS: u64 = 220; fn inner_w() -> f32 {
const PILL_HOVER_GROW: f32 = 10.0; c::card::width() - 2.0 * c::card::pad()
}
// how much thicker, in px, the transport icons get when hovered fn side_w() -> f32 {
const BOLD: f32 = 2.0; inner_w() - c::disc::size() - c::card::side_gap()
}
// sizes, in pixels fn eq_h() -> f32 {
pub const COMPACT_W: f32 = 220.0; c::equalizer::slider_height() + 8.0 + c::equalizer::label_height()
pub const COMPACT_H: f32 = 38.0; }
const WIDE_W: f32 = 560.0;
const WIDE_RADIUS: f32 = 28.0;
const PAD: f32 = 22.0;
const INNER_W: f32 = WIDE_W - 2.0 * PAD;
const GAP: f32 = 16.0;
pub const TOP_GAP: f32 = 6.0;
pub const SHADOW: f32 = 34.0;
// the cd: its box, the cover, the hole, and how far the candles reach past it fn preset_w() -> f32 {
const DISC_BOX: f32 = 204.0; (inner_w() - 3.0 * c::equalizer::preset_gap()) / 4.0
const DISC: f32 = 136.0; }
const HOLE: f32 = 26.0;
const CANDLES: usize = 40;
const CANDLE_REACH: f32 = 20.0;
const SIDE_GAP: f32 = 20.0; fn presets_h() -> f32 {
const SIDE_W: f32 = INNER_W - DISC_BOX - SIDE_GAP; 2.0 * c::equalizer::preset_height() + c::equalizer::preset_gap()
const HEADER_H: f32 = DISC_BOX; }
const CHIP_H: f32 = 24.0;
const OUTPUT_CHIP_MAX: f32 = 150.0;
const VOLUME_CHIP_W: f32 = 60.0;
const BAR_H: f32 = 8.0;
const KNOB: f32 = 16.0;
const CONTROLS_H: f32 = 44.0;
const EQ_HEAD_H: f32 = 26.0;
const SLIDER_H: f32 = 140.0;
const TRACK_W: f32 = 10.0;
const LABEL_H: f32 = 14.0;
const EQ_H: f32 = SLIDER_H + 8.0 + LABEL_H;
const CURVE_STEPS: usize = 72;
const PRESET_H: f32 = 30.0;
const PRESET_GAP: f32 = 8.0;
const PRESET_W: f32 = (INNER_W - 3.0 * PRESET_GAP) / 4.0;
const PRESETS_H: f32 = 2.0 * PRESET_H + PRESET_GAP;
// every section has a fixed height, so the window is sized up front // every section has a fixed height, so the window is sized up front
const WIDE_H: f32 = 2.0 * PAD + HEADER_H + 1.0 + EQ_HEAD_H + EQ_H + PRESETS_H + 4.0 * GAP; fn wide_h() -> f32 {
2.0 * c::card::pad() + c::disc::size() + 1.0 + c::equalizer::header_height() + eq_h() + presets_h() + 4.0 * c::card::gap()
}
// the dropdowns open under the chips // the dropdowns open under the chips
const MENU_X: f32 = PAD + DISC_BOX + SIDE_GAP; fn menu_x() -> f32 {
const MENU_Y: f32 = PAD + 92.0; c::card::pad() + c::disc::size() + c::card::side_gap()
const MENU_W: f32 = SIDE_W; }
const MENU_ROW_H: f32 = 30.0;
const MENU_ROWS: usize = 10;
// letters that fit a dropdown row and the output chip, for cutting long device names in the middle fn menu_y() -> f32 {
const MENU_LETTERS: usize = 30; c::card::pad() + c::menus::top()
const CHIP_LETTERS: usize = 13; }
// covers are decoded once at this size, for both the pill and the cd // where there's no fill, a fade starts from this: white, so it stays grey-free
const THUMB: u32 = 256;
// Fira Code renders about 0.64 em per letter here, so a chip can be sized to its label (plus slack)
const LETTER: f32 = 0.66;
// the white overlays below stay: they read on any dark surface. the theme colours are in theme.rs
pub const CHIP: Color = Color::rgba(255, 255, 255, 18);
pub const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34);
// a hovered chip or button; CLEAR is its starting point where there is no fill, white so the fade stays grey-free
pub const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46);
pub const CLEAR: Color = Color::rgba(255, 255, 255, 0); pub const CLEAR: Color = Color::rgba(255, 255, 255, 0);
pub const TRACK: Color = Color::rgba(255, 255, 255, 24);
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
pub enum Menu { pub enum Menu {
@@ -206,7 +170,7 @@ fn hovering(spot: Spot) -> impl Fn(bool) + 'static {
island island
.hover .hover
.entry(spot) .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 }); .to(if inside { 1.0 } else { 0.0 });
} }
} }
@@ -248,13 +212,13 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
}; };
let open = if opened_here { island.open.value() } else { 0.0 }; let open = if opened_here { island.open.value() } else { 0.0 };
let slide = edge::hidden_by(edge_in, open, HIDE_H); let slide = edge::hidden_by(edge_in, open, edge::hide_h());
let window_w = WIDE_W + 2.0 * SHADOW; let window_w = c::card::width() + 2.0 * c::pill::shadow();
let window = LayerWindow::new() let window = LayerWindow::new()
.width(window_w) .width(window_w)
.height(TOP_GAP + WIDE_H + SHADOW) .height(c::pill::top_gap() + wide_h() + c::pill::shadow())
.anchor_vertical(Vertical::Top) .anchor_vertical(Vertical::Top)
// Overlay: on a desktop of fullscreen windows, Top is hidden behind every one of them // Overlay: on a desktop of fullscreen windows, Top is hidden behind every one of them
.layer(Layer::Overlay) .layer(Layer::Overlay)
@@ -262,7 +226,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.namespace("magic-island"); .namespace("magic-island");
// out of sight: empty and click-through (never hidden, see below), and nothing else read, so a playing player doesn't redraw it // out of sight: empty and click-through (never hidden, see below), and nothing else read, so a playing player doesn't redraw it
if slide <= -HIDE_H { if slide <= -edge::hide_h() {
return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
} }
@@ -292,7 +256,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
Rectangle::new() Rectangle::new()
.width(Parent) .width(Parent)
.height(Parent) .height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 }) .padding(Padding { top: c::pill::top_gap(), right: 0.0, bottom: 0.0, left: 0.0 })
.align_child(Center, Start) .align_child(Center, Start)
.translate(0.0, slide) .translate(0.0, slide)
.child(idle(&theme::now().surfaces())), .child(idle(&theme::now().surfaces())),
@@ -315,9 +279,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
amane::request_frame(); amane::request_frame();
} }
let width = mix(COMPACT_W + PILL_HOVER_GROW * (lift + 2.0 * pop), WIDE_W, grow); let width = mix(c::pill::width() + c::animation::hover_grow() * (lift + 2.0 * pop), c::card::width(), grow);
let height = mix(COMPACT_H, WIDE_H, grow); let height = mix(c::pill::height(), wide_h(), grow);
let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow); let radius = mix(c::pill::height() / 2.0, c::card::radius(), grow);
/* /*
* the wallpaper's surfaces as a gradient for the pill and the card; with cover_colors * the wallpaper's surfaces as a gradient for the pill and the card; with cover_colors
@@ -363,7 +327,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
/* /*
* one faint pink glow around the island, pill and card alike, stronger while the * one faint pink glow around the island, pill and card alike, stronger while the
* pill is hovered. blur 22 fades out within ~28 px (amane blurs with a deviation * pill is hovered. blur 22 fades out within ~28 px (amane blurs with a deviation
* of blur / 2), inside the window's SHADOW margin. no clip anywhere: neither face * of blur / 2), inside the window's c::pill::shadow() margin. no clip anywhere: neither face
* ever overflows the shape * ever overflows the shape
*/ */
let shape = shape.shadow(Shadow::drop(theme::now().accent).opacity(mix(0.3, 0.7, lift)).blur(22.0)); let shape = shape.shadow(Shadow::drop(theme::now().accent).opacity(mix(0.3, 0.7, lift)).blur(22.0));
@@ -396,7 +360,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
// clicks outside the island reach the windows underneath // clicks outside the island reach the windows underneath
let region = InputArea { let region = InputArea {
x: ((window_w - width) / 2.0).floor() as i32, x: ((window_w - width) / 2.0).floor() as i32,
y: TOP_GAP as i32, y: c::pill::top_gap() as i32,
width: width.ceil() as i32, width: width.ceil() as i32,
height: height.ceil() as i32, height: height.ceil() as i32,
}; };
@@ -405,7 +369,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
Rectangle::new() Rectangle::new()
.width(Parent) .width(Parent)
.height(Parent) .height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 }) .padding(Padding { top: c::pill::top_gap(), right: 0.0, bottom: 0.0, left: 0.0 })
.align_child(Center, Start) .align_child(Center, Start)
.translate(0.0, slide) .translate(0.0, slide)
.child(shape), .child(shape),
@@ -441,7 +405,7 @@ fn open_on(island: &mut Island, monitor: Option<String>) {
// the open timeline, at rest on the pill // the open timeline, at rest on the pill
fn opening() -> Animation { fn opening() -> Animation {
Animation::new(0.0).duration(Duration::from_millis(OPEN_MS)).easing(Easing::Linear) Animation::new(0.0).duration(Duration::from_millis(c::animation::open_ms())).easing(Easing::Linear)
} }
// the monitor of the focused workspace, which on hyprland follows the mouse; none without a workspace list // the monitor of the focused workspace, which on hyprland follows the mouse; none without a workspace list
@@ -563,9 +527,9 @@ pub fn mix(from: f32, to: f32, amount: f32) -> f32 {
// one point on the open timeline as (shape grown, pill shown, card shown), each 0 to 1 // one point on the open timeline as (shape grown, pill shown, card shown), each 0 to 1
pub fn phases(timeline: f32) -> (f32, f32, f32) { pub fn phases(timeline: f32) -> (f32, f32, f32) {
let grow = (timeline / SHAPE_DONE).clamp(0.0, 1.0); let grow = (timeline / c::animation::shape_done()).clamp(0.0, 1.0);
let pill = 1.0 - (timeline / PILL_GONE).clamp(0.0, 1.0); let pill = 1.0 - (timeline / c::animation::pill_gone()).clamp(0.0, 1.0);
let reveal = ((timeline - SHAPE_DONE) / (1.0 - SHAPE_DONE)).clamp(0.0, 1.0); let reveal = ((timeline - c::animation::shape_done()) / (1.0 - c::animation::shape_done())).clamp(0.0, 1.0);
// the shape eases out (cubic), the card in and out (smoothstep) // the shape eases out (cubic), the card in and out (smoothstep)
(1.0 - (1.0 - grow).powi(3), pill, reveal * reveal * (3.0 - 2.0 * reveal)) (1.0 - (1.0 - grow).powi(3), pill, reveal * reveal * (3.0 - 2.0 * reveal))
@@ -629,30 +593,30 @@ pub fn faded(color: Color, alpha: u8) -> Color {
// the island while no player is open: a music note and "Nothing playing", glowing like the pill // the island while no player is open: a music note and "Nothing playing", glowing like the pill
fn idle(colors: &[Color; 3]) -> Rectangle { fn idle(colors: &[Color; 3]) -> Rectangle {
Rectangle::new() Rectangle::new()
.width(COMPACT_W) .width(c::pill::width())
.height(COMPACT_H) .height(c::pill::height())
.radius(Full) .radius(Full)
.fill(backdrop(colors, 255)) .fill(backdrop(colors, 255))
.border(1.0, theme::now().outline) .border(1.0, theme::now().outline)
.shadow(Shadow::drop(theme::now().accent).opacity(0.3).blur(22.0)) .shadow(Shadow::drop(theme::now().accent).opacity(0.3).blur(22.0))
.align_child(Center, Center) .align_child(Center, Center)
.child(Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(12.0).color(theme::now().dim)]).gap(8.0).align(Center)) .child(Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(text::body()).color(theme::now().dim)]).gap(8.0).align(Center))
} }
// the pill: cover, title, the player's live levels // the pill: cover, title, the player's live levels
fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle { fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle {
Rectangle::new() Rectangle::new()
.width(COMPACT_W) .width(c::pill::width())
.height(COMPACT_H) .height(c::pill::height())
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 }) .padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 })
.child( .child(
Row::new(children![ Row::new(children![
cover(art, 26.0, 1.5, false), cover(art, 26.0, 1.5, false),
Rectangle::new() Rectangle::new()
.width(Parent) .width(Parent)
.height(COMPACT_H) .height(c::pill::height())
.align_child(Start, Center) .align_child(Start, Center)
.child(Text::new(title(player)).size(12.0).color(theme::now().text).elide()), .child(Text::new(title(player)).size(text::body()).color(theme::now().text).elide()),
pill_levels(playing, levels), pill_levels(playing, levels),
]) ])
.gap(10.0) .gap(10.0)
@@ -667,32 +631,32 @@ fn card(scene: &Scene) -> Rectangle {
layers.push(Box::new(menu_panel(scene, menu))); layers.push(Box::new(menu_panel(scene, menu)));
} }
Rectangle::new().width(WIDE_W).height(WIDE_H).child(Stack::new(layers).width(WIDE_W).height(WIDE_H)) Rectangle::new().width(c::card::width()).height(wide_h()).child(Stack::new(layers).width(c::card::width()).height(wide_h()))
} }
fn content(scene: &Scene) -> Rectangle { fn content(scene: &Scene) -> Rectangle {
Rectangle::new().width(WIDE_W).height(WIDE_H).padding(PAD).child( Rectangle::new().width(c::card::width()).height(wide_h()).padding(c::card::pad()).child(
Column::new(children![ Column::new(children![
Row::new(children![disc(scene), side(scene)]).gap(SIDE_GAP).align(Center).width(INNER_W).height(HEADER_H), Row::new(children![disc(scene), side(scene)]).gap(c::card::side_gap()).align(Center).width(inner_w()).height(c::disc::size()),
Rectangle::new().width(INNER_W).height(1.0).fill(theme::now().outline), Rectangle::new().width(inner_w()).height(1.0).fill(theme::now().outline),
eq_header(scene), eq_header(scene),
equalizer(scene), equalizer(scene),
presets(scene), presets(scene),
]) ])
.gap(GAP), .gap(c::card::gap()),
) )
} }
// the cd: a still cover with a hole, inside a ring of candles that move with this player's sound // the cd: a still cover with a hole, inside a ring of candles that move with this player's sound
fn disc(scene: &Scene) -> Stack { fn disc(scene: &Scene) -> Stack {
let offset = (DISC_BOX - DISC) / 2.0; let offset = (c::disc::size() - c::disc::cover()) / 2.0;
let player = scene.player.name().to_string(); let player = scene.player.name().to_string();
let volume = scene.sound.channel(scene.player.name()).map_or(100, |channel| channel.volume); let volume = scene.sound.channel(scene.player.name()).map_or(100, |channel| channel.volume);
let cd = Rectangle::new() let cd = Rectangle::new()
.width(DISC) .width(c::disc::cover())
.height(DISC) .height(c::disc::cover())
.radius(Full) .radius(Full)
.fill(theme::now().accent) .fill(theme::now().accent)
.shadow(Shadow::drop(theme::now().accent).opacity(0.35).blur(18.0)) .shadow(Shadow::drop(theme::now().accent).opacity(0.35).blur(18.0))
@@ -700,9 +664,9 @@ fn disc(scene: &Scene) -> Stack {
.translate(offset, offset) .translate(offset, offset)
// scroll over the cd for this player's volume // scroll over the cd for this player's volume
.on_scroll(move |scroll| Sound::set_volume(&player, stepped(volume, scroll))) .on_scroll(move |scroll| Sound::set_volume(&player, stepped(volume, scroll)))
.child(cover(scene.art, DISC - 6.0, 0.0, true)); .child(cover(scene.art, c::disc::cover() - 6.0, 0.0, true));
Stack::new(children![ring(scene.levels), cd]).width(DISC_BOX).height(DISC_BOX) Stack::new(children![ring(scene.levels), cd]).width(c::disc::size()).height(c::disc::size())
} }
fn stepped(volume: u8, scroll: Scroll) -> u8 { fn stepped(volume: u8, scroll: Scroll) -> u8 {
@@ -716,20 +680,20 @@ fn stepped(volume: u8, scroll: Scroll) -> u8 {
* shape each: 120 shapes cost this iGPU a third of its frame rate, 3 cost nothing * shape each: 120 shapes cost this iGPU a third of its frame rate, 3 cost nothing
*/ */
fn ring(levels: &[f32]) -> Canvas { fn ring(levels: &[f32]) -> Canvas {
let center = DISC_BOX / 2.0; let center = c::disc::size() / 2.0;
let inner = DISC / 2.0 + 7.0; let inner = c::disc::cover() / 2.0 + 7.0;
let mut glow = Path::new(); let mut glow = Path::new();
let mut body = Path::new(); let mut body = Path::new();
let mut flames = Path::new(); let mut flames = Path::new();
for index in 0..CANDLES { for index in 0..c::disc::candles() {
// a gentle curve lifts the quiet bands, so the ring isn't all bass // a gentle curve lifts the quiet bands, so the ring isn't all bass
let level = mirrored(levels, index).powf(0.75); let level = mirrored(levels, index).powf(0.75);
let (sin, cos) = (index as f32 / CANDLES as f32 * TAU).sin_cos(); let (sin, cos) = (index as f32 / c::disc::candles() as f32 * TAU).sin_cos();
let reach = inner + 3.0 + level * CANDLE_REACH; let reach = inner + 3.0 + level * c::disc::candle_reach();
let (x0, y0) = (center + sin * inner, center - cos * inner); let (x0, y0) = (center + sin * inner, center - cos * inner);
let (x1, y1) = (center + sin * reach, center - cos * reach); let (x1, y1) = (center + sin * reach, center - cos * reach);
@@ -746,7 +710,7 @@ fn ring(levels: &[f32]) -> Canvas {
} }
} }
Canvas::new().width(DISC_BOX).height(DISC_BOX).shapes(shapes![ Canvas::new().width(c::disc::size()).height(c::disc::size()).shapes(shapes![
glow.stroke(9.0, faded(theme::now().accent, 50)).cap(Cap::Round), glow.stroke(9.0, faded(theme::now().accent, 50)).cap(Cap::Round),
body.stroke(4.5, faded(theme::now().accent, 215)).cap(Cap::Round), body.stroke(4.5, faded(theme::now().accent, 215)).cap(Cap::Round),
flames.stroke(4.0, theme::now().flame).cap(Cap::Round), flames.stroke(4.0, theme::now().flame).cap(Cap::Round),
@@ -759,8 +723,8 @@ fn mirrored(levels: &[f32], index: usize) -> f32 {
return 0.0; return 0.0;
} }
let half = CANDLES / 2; let half = c::disc::candles() / 2;
let step = if index < half { index } else { CANDLES - 1 - index }; let step = if index < half { index } else { c::disc::candles() - 1 - index };
levels[(step * levels.len() / half).min(levels.len() - 1)] levels[(step * levels.len() / half).min(levels.len() - 1)]
} }
@@ -772,18 +736,18 @@ fn side(scene: &Scene) -> Column {
Column::new(children![ Column::new(children![
Column::new(children![ Column::new(children![
Text::new(title(player)).size(19.0).weight(Weight::Bold).color(theme::now().text).elide(), Text::new(title(player)).size(text::title()).weight(Weight::Bold).color(theme::now().text).elide(),
Text::new(format!("BY {artist}")).size(12.0).color(theme::now().dim).elide(), Text::new(format!("BY {artist}")).size(text::body()).color(theme::now().dim).elide(),
chips(scene), chips(scene),
]) ])
.gap(8.0) .gap(8.0)
.width(SIDE_W), .width(side_w()),
timeline(scene), timeline(scene),
controls(player, scene.island), controls(player, scene.island),
]) ])
.justify(SpaceBetween) .justify(SpaceBetween)
.width(SIDE_W) .width(side_w())
.height(HEADER_H) .height(c::disc::size())
} }
// output device and player, each opening its dropdown // output device and player, each opening its dropdown
@@ -791,11 +755,11 @@ fn chips(scene: &Scene) -> Row {
let channel = scene.sound.channel(scene.player.name()); let channel = scene.sound.channel(scene.player.name());
let device = channel.and_then(|channel| channel.output.as_ref()); let device = channel.and_then(|channel| channel.output.as_ref());
let output_label = device.map_or(String::from("Output"), |device| parse::middle_ellipsis(sink_label(device), CHIP_LETTERS)); let output_label = device.map_or(String::from("Output"), |device| parse::middle_ellipsis(sink_label(device), c::menus::chip_letters()));
let output_w = label_width(&output_label, 11.0, 50.0).clamp(90.0, OUTPUT_CHIP_MAX); let output_w = label_width(&output_label, 11.0, 50.0).clamp(90.0, c::controls::output_chip_max());
let player_label = format!("VIA {}", scene.player.identity()); let player_label = format!("VIA {}", scene.player.identity());
let player_w = label_width(&player_label, 11.0, 22.0).clamp(50.0, SIDE_W - output_w - 8.0); let player_w = label_width(&player_label, 11.0, 22.0).clamp(50.0, side_w() - output_w - 8.0);
let menu = scene.island.menu; let menu = scene.island.menu;
@@ -804,9 +768,9 @@ fn chips(scene: &Scene) -> Row {
let output = Rectangle::new() let output = Rectangle::new()
.width(output_w) .width(output_w)
.height(CHIP_H) .height(c::controls::chip_height())
.radius(6.0) .radius(6.0)
.fill(Color::blend(if menu == Some(Menu::Output) { CHIP_OPEN } else { CHIP }, CHIP_HOVER, output_lit)) .fill(Color::blend(if menu == Some(Menu::Output) { overlays::chip_open() } else { overlays::chip() }, overlays::chip_hover(), output_lit))
.padding(Padding { top: 0.0, right: 9.0, bottom: 0.0, left: 9.0 }) .padding(Padding { top: 0.0, right: 9.0, bottom: 0.0, left: 9.0 })
.align_child(Start, Center) .align_child(Start, Center)
.cursor(Pointer) .cursor(Pointer)
@@ -816,7 +780,7 @@ fn chips(scene: &Scene) -> Row {
.child( .child(
Row::new(children![ Row::new(children![
icon(if device.is_some_and(|device| device.bluetooth) { Glyph::Bluetooth } else { Glyph::Speaker }, 12.0, theme::now().accent), icon(if device.is_some_and(|device| device.bluetooth) { Glyph::Bluetooth } else { Glyph::Speaker }, 12.0, theme::now().accent),
Text::new(output_label).size(11.0).color(theme::now().text).elide(), Text::new(output_label).size(text::small()).color(theme::now().text).elide(),
icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().text, output_lit)), icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().text, output_lit)),
]) ])
.gap(6.0) .gap(6.0)
@@ -825,9 +789,9 @@ fn chips(scene: &Scene) -> Row {
let player = Rectangle::new() let player = Rectangle::new()
.width(player_w) .width(player_w)
.height(CHIP_H) .height(c::controls::chip_height())
.radius(6.0) .radius(6.0)
.fill(Color::blend(if menu == Some(Menu::Player) { CHIP_OPEN } else { CLEAR }, CHIP_HOVER, player_lit)) .fill(Color::blend(if menu == Some(Menu::Player) { overlays::chip_open() } else { CLEAR }, overlays::chip_hover(), player_lit))
.padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 4.0 }) .padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 4.0 })
.align_child(Start, Center) .align_child(Start, Center)
.cursor(Pointer) .cursor(Pointer)
@@ -836,19 +800,19 @@ fn chips(scene: &Scene) -> Row {
.on_hover(hovering(Spot::Player)) .on_hover(hovering(Spot::Player))
.child( .child(
Row::new(children![ Row::new(children![
Text::new(player_label).size(11.0).color(Color::blend(theme::now().dim, theme::now().text, player_lit)).elide(), Text::new(player_label).size(text::small()).color(Color::blend(theme::now().dim, theme::now().text, player_lit)).elide(),
icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().text, player_lit)), icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().text, player_lit)),
]) ])
.gap(6.0) .gap(6.0)
.align(Center), .align(Center),
); );
Row::new(children![output, player]).gap(8.0).align(Center).width(SIDE_W).height(CHIP_H) Row::new(children![output, player]).gap(8.0).align(Center).width(side_w()).height(c::controls::chip_height())
} }
// a monospace label's width plus whatever surrounds it // a monospace label's width plus whatever surrounds it
fn label_width(label: &str, size: f32, extra: f32) -> f32 { fn label_width(label: &str, size: f32, extra: f32) -> f32 {
label.chars().count() as f32 * size * LETTER + extra label.chars().count() as f32 * size * font::letter_width() + extra
} }
fn timeline(scene: &Scene) -> Column { fn timeline(scene: &Scene) -> Column {
@@ -869,27 +833,27 @@ fn timeline(scene: &Scene) -> Column {
// hovered, the track thickens and brightens and the knob grows // hovered, the track thickens and brightens and the knob grows
let lit = island.lit(Spot::Timeline); let lit = island.lit(Spot::Timeline);
let bar_h = BAR_H + 4.0 * lit; let bar_h = c::controls::bar_height() + 4.0 * lit;
let bar = Rectangle::new().width(SIDE_W).height(KNOB).align_child(Start, Center).child( let bar = Rectangle::new().width(side_w()).height(c::controls::knob()).align_child(Start, Center).child(
Stack::new(children![ Stack::new(children![
Rectangle::new().width(SIDE_W).height(bar_h).radius(Full).fill(Color::blend(TRACK, CHIP_HOVER, lit)).child( Rectangle::new().width(side_w()).height(bar_h).radius(Full).fill(Color::blend(overlays::track(), overlays::chip_hover(), lit)).child(
Rectangle::new() Rectangle::new()
.width((SIDE_W * fraction).max(bar_h)) .width((side_w() * fraction).max(bar_h))
.height(bar_h) .height(bar_h)
.radius(Full) .radius(Full)
.fill(Color::blend(theme::now().accent, theme::now().flame, 0.4 * lit)), .fill(Color::blend(theme::now().accent, theme::now().flame, 0.4 * lit)),
), ),
Rectangle::new() Rectangle::new()
.width(KNOB) .width(c::controls::knob())
.height(KNOB) .height(c::controls::knob())
.radius(Full) .radius(Full)
.fill(Color::WHITE) .fill(Color::WHITE)
.shadow(Shadow::drop("#000000").opacity(0.4).blur(6.0)) .shadow(Shadow::drop("#000000").opacity(0.4).blur(6.0))
.translate(SIDE_W * fraction - KNOB / 2.0, (bar_h - KNOB) / 2.0) .translate(side_w() * fraction - c::controls::knob() / 2.0, (bar_h - c::controls::knob()) / 2.0)
.scale(1.0 + 0.25 * lit), .scale(1.0 + 0.25 * lit),
]) ])
.width(SIDE_W) .width(side_w())
.height(bar_h), .height(bar_h),
); );
@@ -905,7 +869,7 @@ fn timeline(scene: &Scene) -> Column {
.on_drag(|point| { .on_drag(|point| {
let mut island = Island::write(); let mut island = Island::write();
island.scrub = Some((point.x / SIDE_W).clamp(0.0, 1.0)); island.scrub = Some((point.x / side_w()).clamp(0.0, 1.0));
island.seek_shown_until = None; island.seek_shown_until = None;
}) })
.on_click(move |_| seek(&name, length)) .on_click(move |_| seek(&name, length))
@@ -917,12 +881,12 @@ fn timeline(scene: &Scene) -> Column {
Column::new(children![ Column::new(children![
bar, bar,
Row::new(children![Text::new(clock(shown)).size(11.0).color(theme::now().dim), Text::new(total).size(11.0).color(theme::now().dim)]) Row::new(children![Text::new(clock(shown)).size(text::small()).color(theme::now().dim), Text::new(total).size(text::small()).color(theme::now().dim)])
.justify(SpaceBetween) .justify(SpaceBetween)
.width(SIDE_W), .width(side_w()),
]) ])
.gap(4.0) .gap(4.0)
.width(SIDE_W) .width(side_w())
} }
fn leave_bar(inside: bool) { fn leave_bar(inside: bool) {
@@ -943,7 +907,7 @@ fn seek(player: &str, length: f32) {
return; return;
}; };
island.seek_shown_until = Some(Instant::now() + Duration::from_millis(1500)); island.seek_shown_until = Some(Instant::now() + Duration::from_millis(c::timing::seek_shown_ms()));
fraction fraction
}; };
@@ -984,14 +948,14 @@ fn controls(player: &MediaPlayer, island: &Island) -> Row {
// hovered, the transport icons go bold: thicker strokes, nothing else // hovered, the transport icons go bold: thicker strokes, nothing else
let lit = island.lit(Spot::PlayPause); let lit = island.lit(Spot::PlayPause);
let middle = Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center); let middle = Rectangle::new().width(c::controls::height()).height(c::controls::height()).align_child(Center, Center);
let middle = if shown { let middle = if shown {
let bar = || Rectangle::new().width(10.0 + BOLD * lit).height(30.0 + BOLD * lit).radius(3.0).fill(theme::now().accent); let bar = || Rectangle::new().width(10.0 + c::animation::bold() * lit).height(30.0 + c::animation::bold() * lit).radius(3.0).fill(theme::now().accent);
middle.child(Row::new(children![bar(), bar()]).gap(7.0 - BOLD * lit)) middle.child(Row::new(children![bar(), bar()]).gap(7.0 - c::animation::bold() * lit))
} else { } else {
middle.child(bold_icon(Glyph::Play, 30.0, theme::now().accent, BOLD * lit)) middle.child(bold_icon(Glyph::Play, 30.0, theme::now().accent, c::animation::bold() * lit))
}; };
Row::new(children![ Row::new(children![
@@ -1008,8 +972,8 @@ fn controls(player: &MediaPlayer, island: &Island) -> Row {
.gap(26.0) .gap(26.0)
.align(Center) .align(Center)
.justify(Center) .justify(Center)
.width(SIDE_W) .width(side_w())
.height(CONTROLS_H) .height(c::controls::height())
} }
// whether to show `player` as playing: a just-clicked play/pause wins until the player has answered // whether to show `player` as playing: a just-clicked play/pause wins until the player has answered
@@ -1060,7 +1024,7 @@ fn button(glyph: Glyph, lit: f32) -> Rectangle {
.height(40.0) .height(40.0)
.align_child(Center, Center) .align_child(Center, Center)
.cursor(Pointer) .cursor(Pointer)
.child(bold_icon(glyph, 18.0, theme::now().text, BOLD * lit)) .child(bold_icon(glyph, 18.0, theme::now().text, c::animation::bold() * lit))
} }
fn eq_header(scene: &Scene) -> Row { fn eq_header(scene: &Scene) -> Row {
@@ -1080,10 +1044,10 @@ fn eq_header(scene: &Scene) -> Row {
right.push(Box::new( right.push(Box::new(
Rectangle::new() Rectangle::new()
.width(VOLUME_CHIP_W) .width(c::controls::volume_chip_width())
.height(22.0) .height(22.0)
.radius(6.0) .radius(6.0)
.fill(Color::blend(CHIP, CHIP_HOVER, scene.island.lit(Spot::Volume))) .fill(Color::blend(overlays::chip(), overlays::chip_hover(), scene.island.lit(Spot::Volume)))
.align_child(Center, Center) .align_child(Center, Center)
.cursor(Pointer) .cursor(Pointer)
.scale(1.0 + 0.06 * scene.island.lit(Spot::Volume)) .scale(1.0 + 0.06 * scene.island.lit(Spot::Volume))
@@ -1093,7 +1057,7 @@ fn eq_header(scene: &Scene) -> Row {
.child( .child(
Row::new(children![ Row::new(children![
icon(if channel.muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, theme::now().dim), icon(if channel.muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, theme::now().dim),
Text::new(label).size(11.0).color(theme::now().text), Text::new(label).size(text::small()).color(theme::now().text),
]) ])
.gap(5.0) .gap(5.0)
.align(Center), .align(Center),
@@ -1102,57 +1066,57 @@ fn eq_header(scene: &Scene) -> Row {
} }
right.push(Box::new( right.push(Box::new(
Rectangle::new().width(70.0).height(22.0).radius(6.0).fill(CHIP).align_child(Center, Center).child(Text::new(state).size(11.0).color(theme::now().dim)), Rectangle::new().width(70.0).height(22.0).radius(6.0).fill(overlays::chip()).align_child(Center, Center).child(Text::new(state).size(text::small()).color(theme::now().dim)),
)); ));
right.push(Box::new(Text::new(eq.preset).size(12.0).color(theme::now().text))); right.push(Box::new(Text::new(eq.preset).size(text::body()).color(theme::now().text)));
Row::new(children![Text::new("Equalizer").size(15.0).color(theme::now().accent), Row::new(right).gap(12.0).align(Center)]) Row::new(children![Text::new("Equalizer").size(text::section()).color(theme::now().accent), Row::new(right).gap(12.0).align(Center)])
.justify(SpaceBetween) .justify(SpaceBetween)
.align(Center) .align(Center)
.width(INNER_W) .width(inner_w())
.height(EQ_HEAD_H) .height(c::equalizer::header_height())
} }
// the knob's centre for a gain, from the top of the slider // the knob's centre for a gain, from the top of the slider
fn knob_y(gain: f32) -> f32 { fn knob_y(gain: f32) -> f32 {
KNOB / 2.0 + (1.0 - (gain + MAX_GAIN) / (2.0 * MAX_GAIN)) * (SLIDER_H - KNOB) c::controls::knob() / 2.0 + (1.0 - (gain + crate::config::sound::equalizer::max_gain()) / (2.0 * crate::config::sound::equalizer::max_gain())) * (c::equalizer::slider_height() - c::controls::knob())
} }
fn gain_at(y: f32) -> f32 { fn gain_at(y: f32) -> f32 {
(1.0 - (y - KNOB / 2.0) / (SLIDER_H - KNOB)) * 2.0 * MAX_GAIN - MAX_GAIN (1.0 - (y - c::controls::knob() / 2.0) / (c::equalizer::slider_height() - c::controls::knob())) * 2.0 * crate::config::sound::equalizer::max_gain() - crate::config::sound::equalizer::max_gain()
} }
// ten vertical sliders, a glowing curve through their knobs, and the bands' names // ten vertical sliders, a glowing curve through their knobs, and the bands' names
fn equalizer(scene: &Scene) -> Column { fn equalizer(scene: &Scene) -> Column {
let gains = scene.sound.eq(scene.player.identity()).gains; let gains = scene.sound.eq(scene.player.identity()).gains;
let column = INNER_W / BANDS.len() as f32; let column = inner_w() / crate::config::sound::equalizer::bands().len() as f32;
let mut tracks: Vec<Box<dyn Widget>> = Vec::new(); let mut tracks: Vec<Box<dyn Widget>> = Vec::new();
let mut knobs: Vec<Box<dyn Widget>> = Vec::new(); let mut knobs: Vec<Box<dyn Widget>> = Vec::new();
let mut labels: Vec<Box<dyn Widget>> = Vec::new(); let mut labels: Vec<Box<dyn Widget>> = Vec::new();
for (band, (frequency, gain)) in BANDS.iter().zip(gains).enumerate() { for (band, (frequency, gain)) in crate::config::sound::equalizer::bands().iter().zip(gains).enumerate() {
let y = knob_y(gain); let y = knob_y(gain);
// hovered, a band's track widens and brightens, its knob grows and its label turns pink // hovered, a band's track widens and brightens, its knob grows and its label turns pink
let lit = scene.island.lit(Spot::Band(band)); let lit = scene.island.lit(Spot::Band(band));
let track_w = TRACK_W + 4.0 * lit; let track_w = c::equalizer::track_width() + 4.0 * lit;
tracks.push(Box::new( tracks.push(Box::new(
Rectangle::new().width(column).height(SLIDER_H).align_child(Center, Start).child( Rectangle::new().width(column).height(c::equalizer::slider_height()).align_child(Center, Start).child(
Rectangle::new() Rectangle::new()
.width(track_w) .width(track_w)
.height(SLIDER_H) .height(c::equalizer::slider_height())
.radius(track_w / 2.0) .radius(track_w / 2.0)
.fill(Color::blend(TRACK, CHIP_HOVER, lit)) .fill(Color::blend(overlays::track(), overlays::chip_hover(), lit))
.align_child(Center, End) .align_child(Center, End)
.child( .child(
// rounded itself rather than clipped by the track: clips are gpu layers // rounded itself rather than clipped by the track: clips are gpu layers
Rectangle::new() Rectangle::new()
.width(track_w) .width(track_w)
.height(SLIDER_H - y) .height(c::equalizer::slider_height() - y)
.radius(track_w / 2.0) .radius(track_w / 2.0)
.fill(Color::blend(theme::now().accent, theme::now().flame, 0.45 * lit)), .fill(Color::blend(theme::now().accent, theme::now().flame, 0.45 * lit)),
), ),
@@ -1165,7 +1129,7 @@ fn equalizer(scene: &Scene) -> Column {
knobs.push(Box::new( knobs.push(Box::new(
Rectangle::new() Rectangle::new()
.width(column) .width(column)
.height(SLIDER_H) .height(c::equalizer::slider_height())
.align_child(Center, Start) .align_child(Center, Start)
.cursor(Pointer) .cursor(Pointer)
.on_hover(hovering(Spot::Band(band))) .on_hover(hovering(Spot::Band(band)))
@@ -1173,12 +1137,12 @@ fn equalizer(scene: &Scene) -> Column {
.child( .child(
// a pink ring instead of a blurred shadow: ten blurs per frame cost the gpu more than the rest of the card // a pink ring instead of a blurred shadow: ten blurs per frame cost the gpu more than the rest of the card
Rectangle::new() Rectangle::new()
.width(KNOB) .width(c::controls::knob())
.height(KNOB) .height(c::controls::knob())
.radius(Full) .radius(Full)
.fill(Color::WHITE) .fill(Color::WHITE)
.border(2.0, Color::blend(faded(theme::now().accent, 150), theme::now().accent, lit)) .border(2.0, Color::blend(faded(theme::now().accent, 150), theme::now().accent, lit))
.translate(0.0, y - KNOB / 2.0) .translate(0.0, y - c::controls::knob() / 2.0)
.scale(1.0 + 0.25 * lit), .scale(1.0 + 0.25 * lit),
), ),
)); ));
@@ -1186,12 +1150,12 @@ fn equalizer(scene: &Scene) -> Column {
let label = if *frequency >= 1000 { format!("{}k", frequency / 1000) } else { frequency.to_string() }; let label = if *frequency >= 1000 { format!("{}k", frequency / 1000) } else { frequency.to_string() };
labels.push(Box::new( labels.push(Box::new(
Rectangle::new().width(column).height(LABEL_H).align_child(Center, Center).child(Text::new(label).size(10.0).color(Color::blend(theme::now().dim, theme::now().accent, lit))), Rectangle::new().width(column).height(c::equalizer::label_height()).align_child(Center, Center).child(Text::new(label).size(text::tiny()).color(Color::blend(theme::now().dim, theme::now().accent, lit))),
)); ));
} }
Column::new(children![ Column::new(children![
Stack::new(children![Row::new(tracks), curve(&gains, scene.levels), Row::new(knobs)]).width(INNER_W).height(SLIDER_H), Stack::new(children![Row::new(tracks), curve(&gains, scene.levels), Row::new(knobs)]).width(inner_w()).height(c::equalizer::slider_height()),
Row::new(labels), Row::new(labels),
]) ])
.gap(8.0) .gap(8.0)
@@ -1199,15 +1163,15 @@ fn equalizer(scene: &Scene) -> Column {
// the curve through the knobs, shivering with the player's sound // the curve through the knobs, shivering with the player's sound
fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas { fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas {
let column = INNER_W / BANDS.len() as f32; let column = inner_w() / crate::config::sound::equalizer::bands().len() as f32;
let points: Vec<(f32, f32)> = (0..=CURVE_STEPS) let points: Vec<(f32, f32)> = (0..=c::equalizer::curve_steps())
.map(|step| { .map(|step| {
let along = step as f32 / CURVE_STEPS as f32; let along = step as f32 / c::equalizer::curve_steps() as f32;
// cosine blend between neighbouring knobs keeps the curve round // cosine blend between neighbouring knobs keeps the curve round
let place = along * (BANDS.len() - 1) as f32; let place = along * (crate::config::sound::equalizer::bands().len() - 1) as f32;
let band = (place.floor() as usize).min(BANDS.len() - 2); let band = (place.floor() as usize).min(crate::config::sound::equalizer::bands().len() - 2);
let eased = (1.0 - ((place - band as f32) * PI).cos()) / 2.0; let eased = (1.0 - ((place - band as f32) * PI).cos()) / 2.0;
let y = mix(knob_y(gains[band]), knob_y(gains[band + 1]), eased); let y = mix(knob_y(gains[band]), knob_y(gains[band + 1]), eased);
@@ -1216,7 +1180,7 @@ fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas {
let shiver = level * 9.0 * if step % 2 == 0 { -1.0 } else { 0.6 }; let shiver = level * 9.0 * if step % 2 == 0 { -1.0 } else { 0.6 };
(column / 2.0 + along * (INNER_W - column), y + shiver) (column / 2.0 + along * (inner_w() - column), y + shiver)
}) })
.collect(); .collect();
@@ -1231,7 +1195,7 @@ fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas {
}; };
// a wide faint stroke under a thin bright one reads as a glow // a wide faint stroke under a thin bright one reads as a glow
Canvas::new().width(INNER_W).height(SLIDER_H).shapes(shapes![stroke(7.0, 45), stroke(2.0, 235)]) Canvas::new().width(inner_w()).height(c::equalizer::slider_height()).shapes(shapes![stroke(7.0, 45), stroke(2.0, 235)])
} }
fn presets(scene: &Scene) -> Column { fn presets(scene: &Scene) -> Column {
@@ -1239,23 +1203,23 @@ fn presets(scene: &Scene) -> Column {
let mut rows: Vec<Box<dyn Widget>> = Vec::new(); let mut rows: Vec<Box<dyn Widget>> = Vec::new();
for row in EQ_PRESETS.chunks(4) { for row in sound::presets().chunks(4) {
let mut buttons: Vec<Box<dyn Widget>> = Vec::new(); let mut buttons: Vec<Box<dyn Widget>> = Vec::new();
for (name, _) in row { for &(name, _) in row {
let chosen = current == *name; let chosen = current == name;
let lit = scene.island.lit(Spot::Preset(name)); let lit = scene.island.lit(Spot::Preset(name));
let player = scene.player.name().to_string(); let player = scene.player.name().to_string();
let identity = scene.player.identity().to_string(); let identity = scene.player.identity().to_string();
// a hovered preset brightens: grey ones toward white, the chosen pink one toward its flame // a hovered preset brightens: grey ones toward white, the chosen pink one toward its flame
let fill = if chosen { Color::blend(theme::now().accent, theme::now().flame, lit * 0.6) } else { Color::blend(CHIP, CHIP_HOVER, lit) }; let fill = if chosen { Color::blend(theme::now().accent, theme::now().flame, lit * 0.6) } else { Color::blend(overlays::chip(), overlays::chip_hover(), lit) };
buttons.push(Box::new( buttons.push(Box::new(
Rectangle::new() Rectangle::new()
.width(PRESET_W) .width(preset_w())
.height(PRESET_H) .height(c::equalizer::preset_height())
.radius(8.0) .radius(8.0)
.fill(fill) .fill(fill)
.align_child(Center, Center) .align_child(Center, Center)
@@ -1266,18 +1230,18 @@ fn presets(scene: &Scene) -> Column {
Sound::preset(&player, &identity, name); Sound::preset(&player, &identity, name);
}) })
.child( .child(
Text::new(*name) Text::new(name)
.size(12.0) .size(text::body())
.weight(if chosen { Weight::Bold } else { Weight::Regular }) .weight(if chosen { Weight::Bold } else { Weight::Regular })
.color(if chosen { theme::now().on_accent } else { theme::now().text }), .color(if chosen { theme::now().on_accent } else { theme::now().text }),
), ),
)); ));
} }
rows.push(Box::new(Row::new(buttons).gap(PRESET_GAP))); rows.push(Box::new(Row::new(buttons).gap(c::equalizer::preset_gap())));
} }
Column::new(rows).gap(PRESET_GAP) Column::new(rows).gap(c::equalizer::preset_gap())
} }
// the dropdown of output devices or of players // the dropdown of output devices or of players
@@ -1296,7 +1260,7 @@ fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle {
let glyph = if sink.bluetooth { Glyph::Bluetooth } else { Glyph::Speaker }; let glyph = if sink.bluetooth { Glyph::Bluetooth } else { Glyph::Speaker };
let current = current.as_deref() == Some(sink.name.as_str()); let current = current.as_deref() == Some(sink.name.as_str());
rows.push(Box::new(menu_row(&parse::middle_ellipsis(sink_label(sink), MENU_LETTERS), current, glyph, row, scene.island, move || { rows.push(Box::new(menu_row(&parse::middle_ellipsis(sink_label(sink), c::menus::row_letters()), current, glyph, row, scene.island, move || {
Sound::set_output(&player, &identity, &device); Sound::set_output(&player, &identity, &device);
}))); })));
} }
@@ -1314,10 +1278,10 @@ fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle {
} }
} }
let height = rows.len().clamp(1, MENU_ROWS) as f32 * MENU_ROW_H + 12.0; let height = rows.len().clamp(1, c::menus::rows()) as f32 * c::menus::row_height() + 12.0;
Rectangle::new() Rectangle::new()
.width(MENU_W) .width(side_w())
.height(height) .height(height)
.radius(10.0) .radius(10.0)
.fill(theme::now().surface_high) .fill(theme::now().surface_high)
@@ -1325,7 +1289,7 @@ fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle {
.shadow(Shadow::drop("#000000").opacity(0.5).blur(20.0).offset(0.0, 8.0)) .shadow(Shadow::drop("#000000").opacity(0.5).blur(20.0).offset(0.0, 8.0))
.padding(6.0) .padding(6.0)
.clip() .clip()
.translate(MENU_X, MENU_Y) .translate(menu_x(), menu_y())
.child(ScrollArea::new("island-menu", Column::new(rows))) .child(ScrollArea::new("island-menu", Column::new(rows)))
} }
@@ -1336,9 +1300,9 @@ fn sink_label(sink: &Sink) -> &str {
fn menu_row(label: &str, current: bool, glyph: Glyph, row: usize, island: &Island, choose: impl Fn() + 'static) -> Rectangle { fn menu_row(label: &str, current: bool, glyph: Glyph, row: usize, island: &Island, choose: impl Fn() + 'static) -> Rectangle {
Rectangle::new() Rectangle::new()
.width(Parent) .width(Parent)
.height(MENU_ROW_H) .height(c::menus::row_height())
.radius(6.0) .radius(6.0)
.fill(Color::blend(if current { CHIP } else { CLEAR }, CHIP_HOVER, island.lit(Spot::Row(row)))) .fill(Color::blend(if current { overlays::chip() } else { CLEAR }, overlays::chip_hover(), island.lit(Spot::Row(row))))
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }) .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center) .align_child(Start, Center)
.cursor(Pointer) .cursor(Pointer)
@@ -1352,7 +1316,7 @@ fn menu_row(label: &str, current: bool, glyph: Glyph, row: usize, island: &Islan
.child( .child(
Row::new(children![ Row::new(children![
icon(glyph, 11.0, if current { theme::now().accent } else { theme::now().dim }), icon(glyph, 11.0, if current { theme::now().accent } else { theme::now().dim }),
Text::new(label).size(12.0).color(if current { theme::now().accent } else { theme::now().text }).elide(), Text::new(label).size(text::body()).color(if current { theme::now().accent } else { theme::now().text }).elide(),
]) ])
.gap(9.0) .gap(9.0)
.align(Center), .align(Center),
@@ -1377,11 +1341,11 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
let picture = Rectangle::new().width(size).height(size).radius(Full).align_child(Center, Center); let picture = Rectangle::new().width(size).height(size).radius(Full).align_child(Center, Center);
let picture = if art.is_empty() { picture } else { picture.fill(Image::cover(art).thumbnail(THUMB, THUMB)) }; let picture = if art.is_empty() { picture } else { picture.fill(Image::cover(art).thumbnail(c::disc::cover_pixels(), c::disc::cover_pixels())) };
// a rectangle holds one child, so the hole sits on the picture, not beside it // a rectangle holds one child, so the hole sits on the picture, not beside it
let picture = if hole { let picture = if hole {
picture.child(Rectangle::new().width(HOLE).height(HOLE).radius(Full).fill(theme::now().surface_low).border(2.0, theme::now().outline)) picture.child(Rectangle::new().width(c::disc::hole()).height(c::disc::hole()).radius(Full).fill(theme::now().surface_low).border(2.0, theme::now().outline))
} else { } else {
picture picture
}; };
@@ -1395,34 +1359,32 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
* player has no stream to listen to * player has no stream to listen to
*/ */
fn pill_levels(playing: bool, levels: &[f32]) -> Canvas { fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
const BARS: usize = 7; let (bars, width, height) = (c::pill::bars(), c::pill::bars_width(), c::pill::bars_height());
const WIDTH: f32 = 36.0;
const HEIGHT: f32 = 26.0;
if !playing { if !playing {
return icon(Glyph::Pause, 13.0, theme::now().dim); return icon(Glyph::Pause, 13.0, theme::now().dim);
} }
// lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line // lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line
let middle = HEIGHT / 2.0; let middle = height / 2.0;
let mut glow = Path::new(); let mut glow = Path::new();
let mut body = Path::new(); let mut body = Path::new();
for index in 0..BARS { for index in 0..bars {
let level = if levels.is_empty() { let level = if levels.is_empty() {
[0.55, 0.85, 0.6, 0.75, 0.45, 0.6, 0.35][index] [0.55, 0.85, 0.6, 0.75, 0.45, 0.6, 0.35][index % 7]
} else { } else {
/* /*
* the loudest band of this bar's seventh of the spectrum, linear with a little * the loudest band of this bar's share of the spectrum, linear with a little
* gain: typical music sits at 0.15 to 0.35 here, and a lifting curve made every * gain (bars_boost): typical music sits at 0.15 to 0.35 here, and a lifting
* bar look nearly full * curve made every bar look nearly full
*/ */
(band(levels, index, BARS) * 1.4).min(1.0) (band(levels, index, bars) * c::pill::bars_boost()).min(1.0)
}; };
let half = 1.5 + level * (middle - 3.0); let half = 1.5 + level * (middle - 3.0);
let x = 3.0 + index as f32 * (WIDTH - 6.0) / (BARS - 1) as f32; let x = 3.0 + index as f32 * (width - 6.0) / bars.saturating_sub(1).max(1) as f32;
body = body.move_to(x, middle - half).line_to(x, middle + half); body = body.move_to(x, middle - half).line_to(x, middle + half);
@@ -1431,7 +1393,7 @@ fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
} }
} }
Canvas::new().width(WIDTH).height(HEIGHT).shapes(shapes![ Canvas::new().width(width).height(height).shapes(shapes![
glow.stroke(7.0, faded(theme::now().accent, 55)).cap(Cap::Round), glow.stroke(7.0, faded(theme::now().accent, 55)).cap(Cap::Round),
body.stroke(3.2, theme::now().accent).cap(Cap::Round), body.stroke(3.2, theme::now().accent).cap(Cap::Round),
]) ])
+10 -2
View File
@@ -1,11 +1,14 @@
mod art; mod art;
mod config;
mod control; mod control;
mod edge; mod edge;
mod glyph; mod glyph;
mod hypr;
mod island; mod island;
mod map; mod map;
mod parse; mod parse;
mod prompt; mod prompt;
mod settings;
mod sound; mod sound;
mod spectrum; mod spectrum;
mod system; mod system;
@@ -17,17 +20,22 @@ mod workspaces;
use amane::{App, Palette, Service}; use amane::{App, Palette, Service};
fn main() { fn main() {
// config.toml first: the font is chosen once, before anything draws
config::load();
// the card's glow follows the cover art, which art::Art copies to this one path // the card's glow follows the cover art, which art::Art copies to this one path
Palette::write().open(&art::palette_file(), 8); Palette::write().open(&art::palette_file(), 8);
// one island on every monitor (the card opens only on one of them), a workspace pill top left // 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 // 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() App::new()
.font("Fira Code") .font(&config::appearance::font::family())
.ipc("island", island::command) .ipc("island", island::command)
.ipc("control", control::command) .ipc("control", control::command)
.ipc("tray", tray::command) .ipc("tray", tray::command)
.ipc("config", settings::command)
.window_per_monitor(edge::spacer) .window_per_monitor(edge::spacer)
.window_per_monitor(edge::view) .window_per_monitor(edge::view)
.window_per_monitor(island::view) .window_per_monitor(island::view)
+35 -51
View File
@@ -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 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::glyph::{Glyph, glyph_shapes};
use crate::island::{CHIP_OPEN, faded}; use crate::island::faded;
use crate::theme; use crate::theme;
// the map's own height, under the menu's header // the map's sizes and timings are settings (config.rs: control.maps.*)
pub const AREA_H: f32 = 480.0;
const BUBBLE: f32 = 40.0; // a click on the bubble shows its rings at least until all three have run out once
const NODE_R: f32 = 22.0; pub fn ripple_s() -> f32 {
const ICON: f32 = 18.0; 2.0 * maps::ring_gap_s() + maps::ring_s()
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;
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
pub enum Link { pub enum Link {
@@ -87,13 +71,13 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectang
let t = clock(); let t = clock();
let (cx, cy) = (width / 2.0, height / 2.0); 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 // 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 // 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; 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 // a slow float, each on its own rhythm, two waves so the path never quite repeats
let drift = ( let drift = (
((t * 0.31 + phase * 1.7).sin() * 0.7 + (t * 0.53 + phase * 0.9).sin() * 0.3) * DRIFT_X, ((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) * DRIFT_Y, ((t * 0.27 + phase * 2.3).cos() * 0.7 + (t * 0.47 + phase * 1.3).cos() * 0.3) * maps::drift_y(),
); );
let x = cx + ex * push + drift.0; let 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 (ux, uy) = (dx / length, dy / length);
let start = (cx + ux * bubble, cy + uy * bubble); 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); 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 { let (fill, mark) = match node.link {
Link::Connected => (theme.accent, theme.on_accent), Link::Connected => (theme.accent, theme.on_accent),
Link::Connecting => (Color::blend(CHIP_OPEN, theme.accent, 0.45), theme.text), Link::Connecting => (Color::blend(overlays::chip_open(), theme.accent, 0.45), theme.text),
Link::Idle => (CHIP_OPEN, theme.text), Link::Idle => (overlays::chip_open(), theme.text),
}; };
let lit = node.lit; let lit = node.lit;
// its disc and icon go into the map's one canvas (see glyph_shapes), on top of every line // 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).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.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 // 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 { let target = match node.on_click {
Some(click) => target.cursor(Pointer).on_click(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; let bold = node.link == Link::Connected;
// away from the bubble: above the ones above it, so a label never lies on its own line // 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( 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![ 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.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(10.0).color(theme.dim).elide(), 13.0)), Box::new(centred(Text::new(node.detail).size(text::tiny()).color(theme.dim).elide(), 13.0)),
]) ])
.gap(2.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![ 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), glow.stroke(7.0, faded(theme.accent, 45)).cap(Cap::Round),
solid.stroke(2.4, theme.accent).cap(Cap::Round), solid.stroke(2.4, theme.accent).cap(Cap::Round),
running.stroke(2.2, faded(theme.accent, 220)).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 // 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 { if let Some(since) = centre.searching {
for ring in 0..3 { 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 { if age < 0.0 {
continue; 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); let eased = 1.0 - (1.0 - grown).powi(2);
lines.push(Box::new( 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) // this desktop's name under its bubble, and what to know under that (on hover: what a click does)
layers.push(Box::new( layers.push(Box::new(
Rectangle::new() Rectangle::new()
.width(LABEL_W * 2.0) .width(maps::label_width() * 2.0)
.height(16.0) .height(16.0)
.align_child(Center, Center) .align_child(Center, Center)
.translate(cx - LABEL_W, cy + BUBBLE + 12.0) .translate(cx - maps::label_width(), cy + maps::bubble() + 12.0)
.child(Text::new(centre.host).size(12.0).weight(Weight::Bold).color(theme.text)), .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 }; let note = if centre.lit > 0.5 { Some("Click to search again") } else { centre.note };
if let Some(note) = note { if let Some(note) = note {
layers.push(Box::new( 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( layers.push(Box::new(
Rectangle::new() Rectangle::new()
.width(2.0 * BUBBLE) .width(2.0 * maps::bubble())
.height(2.0 * BUBBLE) .height(2.0 * maps::bubble())
.radius(Full) .radius(Full)
.translate(cx - BUBBLE, cy - BUBBLE) .translate(cx - maps::bubble(), cy - maps::bubble())
.cursor(Pointer) .cursor(Pointer)
.on_hover(centre.on_hover) .on_hover(centre.on_hover)
.on_click(move |_| on_click()), .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 { 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)
} }
+541 -8
View File
@@ -1,6 +1,6 @@
// plain text parsing, kept free of amane so `rustc --test parse.rs` checks it on its own // plain text parsing, kept free of amane so `rustc --test parse.rs` checks it on its own
// the equalizer's bands, in Hz // the equalizer's 10 bands' default frequencies in Hz; config.toml's sound.equalizer.bands is what equalizers use
pub const BANDS: [u32; 10] = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; pub const BANDS: [u32; 10] = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
// node names of the island's own equalizer sinks start with this // node names of the island's own equalizer sinks start with this
@@ -237,12 +237,13 @@ pub fn middle_ellipsis(label: &str, max: usize) -> String {
/* /*
* a standalone `pipewire -c` config: one 10-band filter-chain sink named * a standalone `pipewire -c` config: one 10-band filter-chain sink named
* magic_island_eq.<key>, whose output plays into `target` (empty: the default) * magic_island_eq.<key> at these band frequencies, whose output plays into `target`
* (empty: the default)
*/ */
pub fn eq_config(key: &str, description: &str, gains: &[f32; 10], target: &str) -> String { pub fn eq_config(key: &str, description: &str, bands: &[u32; 10], gains: &[f32; 10], target: &str) -> String {
let mut nodes = String::new(); let mut nodes = String::new();
for (index, (band, gain)) in BANDS.iter().zip(gains).enumerate() { for (index, (band, gain)) in bands.iter().zip(gains).enumerate() {
let (label, q) = match index { let (label, q) = match index {
0 => ("bq_lowshelf", 0.7), 0 => ("bq_lowshelf", 0.7),
9 => ("bq_highshelf", 0.7), 9 => ("bq_highshelf", 0.7),
@@ -255,7 +256,7 @@ pub fn eq_config(key: &str, description: &str, gains: &[f32; 10], target: &str)
); );
} }
let links: String = (1..BANDS.len()) let links: String = (1..bands.len())
.map(|index| format!(" {{ output = \"b{index}:Out\" input = \"b{}:In\" }}\n", index + 1)) .map(|index| format!(" {{ output = \"b{index}:Out\" input = \"b{}:In\" }}\n", index + 1))
.collect(); .collect();
@@ -327,7 +328,7 @@ pub fn eq_line(identity: &str, preset: &str, gains: &[f32; 10]) -> String {
format!("{}\t{}\t{}\n", clean(identity), clean(preset), gains.join(" ")) format!("{}\t{}\t{}\n", clean(identity), clean(preset), gains.join(" "))
} }
// "key = value" lines, as in shell.conf and the matugen theme; blank lines, # comments and lines without = are skipped // "key = value" lines, as in the matugen theme (and the old shell.conf); blank lines, # comments and lines without = are skipped
pub fn settings(text: &str) -> Vec<(String, String)> { pub fn settings(text: &str) -> Vec<(String, String)> {
text.lines() text.lines()
.map(str::trim) .map(str::trim)
@@ -732,6 +733,466 @@ pub fn icon_score(path: &str) -> u32 {
fit + if symbolic { 1000 } else { 0 } fit + if symbolic { 1000 } else { 0 }
} }
// a value from config.toml or from hyprctl's json; json's objects are Tables
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Null,
Bool(bool),
Int(i64),
Float(f64),
Text(String),
List(Vec<Value>),
Table(Vec<(String, Value)>),
}
impl Value {
pub fn get(&self, key: &str) -> Option<&Value> {
match self {
Value::Table(pairs) => pairs.iter().find(|(name, _)| name == key).map(|(_, value)| value),
_ => None,
}
}
pub fn number(&self) -> Option<f64> {
match self {
Value::Int(number) => Some(*number as f64),
Value::Float(number) => Some(*number),
_ => None,
}
}
pub fn text(&self) -> Option<&str> {
match self {
Value::Text(text) => Some(text),
_ => None,
}
}
}
// one pass over toml or json text; both write strings, numbers, booleans and arrays the same way
struct Reader<'a> {
text: &'a str,
at: usize,
}
impl Reader<'_> {
fn rest(&self) -> &str {
&self.text[self.at..]
}
fn skip_space(&mut self) {
let rest = self.rest();
self.at += rest.len() - rest.trim_start().len();
}
fn eat(&mut self, expected: char) -> bool {
self.skip_space();
let found = self.rest().starts_with(expected);
if found {
self.at += expected.len_utf8();
}
found
}
fn word(&mut self, word: &str) -> bool {
let found = self.rest().starts_with(word);
if found {
self.at += word.len();
}
found
}
// "basic" with escapes (toml and json), or 'literal' (toml)
fn string(&mut self) -> Result<String, String> {
let quote = self.rest().chars().next().ok_or("a string ends early")?;
let (all, start) = (self.text, self.at);
let mut letters = all[start + 1..].char_indices();
let mut text = String::new();
while let Some((at, letter)) = letters.next() {
match letter {
_ if letter == quote => {
self.at = start + 1 + at + 1;
return Ok(text);
}
'\\' if quote == '"' => {
let (_, escaped) = letters.next().ok_or("a string ends early")?;
match escaped {
'n' => text.push('\n'),
't' => text.push('\t'),
'r' => text.push('\r'),
'b' => text.push('\u{8}'),
'f' => text.push('\u{c}'),
'u' => {
let digits: String = letters.by_ref().take(4).map(|(_, digit)| digit).collect();
let code = u32::from_str_radix(&digits, 16).map_err(|_| "a bad \\u escape")?;
text.push(char::from_u32(code).unwrap_or('\u{fffd}'));
}
other => text.push(other),
}
}
_ => text.push(letter),
}
}
Err(String::from("a string has no closing quote"))
}
fn number(&mut self) -> Result<Value, String> {
let length = self.rest().find(|letter: char| !(letter.is_ascii_alphanumeric() || "+-._".contains(letter))).unwrap_or(self.rest().len());
let word = self.rest()[..length].replace('_', "");
self.at += length;
let float = word.contains(['.', 'e', 'E']);
match word.parse::<i64>() {
Ok(number) if !float => Ok(Value::Int(number)),
_ => word.parse::<f64>().map(Value::Float).map_err(|_| format!("`{word}` isn't a value")),
}
}
fn value(&mut self) -> Result<Value, String> {
self.skip_space();
let first = self.rest().chars().next().ok_or("a value is missing")?;
match first {
'"' | '\'' => self.string().map(Value::Text),
'[' => {
self.at += 1;
let mut items = Vec::new();
while !self.eat(']') {
items.push(self.value()?);
if !self.eat(',') && !self.rest().trim_start().starts_with(']') {
return Err(String::from("an array needs a comma or ]"));
}
}
Ok(Value::List(items))
}
'{' => {
self.at += 1;
let mut pairs = Vec::new();
while !self.eat('}') {
self.skip_space();
let key = self.string()?;
if !self.eat(':') {
return Err(String::from("an object needs a colon"));
}
pairs.push((key, self.value()?));
if !self.eat(',') && !self.rest().trim_start().starts_with('}') {
return Err(String::from("an object needs a comma or }"));
}
}
Ok(Value::Table(pairs))
}
_ if self.word("true") => Ok(Value::Bool(true)),
_ if self.word("false") => Ok(Value::Bool(false)),
_ if self.word("null") => Ok(Value::Null),
_ => self.number(),
}
}
// a key: bare words and "quoted" parts, joined by dots
fn key(&mut self) -> Result<String, String> {
let mut parts = Vec::new();
loop {
self.skip_space();
if self.rest().starts_with(['"', '\'']) {
parts.push(self.string()?);
} else {
let length = self.rest().find(|letter: char| !(letter.is_ascii_alphanumeric() || "_-".contains(letter))).unwrap_or(self.rest().len());
if length == 0 {
return Err(String::from("a key is missing"));
}
parts.push(self.rest()[..length].to_string());
self.at += length;
}
if !self.eat('.') {
return Ok(parts.join("."));
}
}
}
// after a toml line's value or header only a # comment may follow
fn line_done(&mut self) -> Result<(), String> {
self.skip_space();
if self.rest().is_empty() || self.rest().starts_with('#') { Ok(()) } else { Err(format!("unexpected `{}`", self.rest())) }
}
}
/*
* the toml config.toml is written in, as (table.key, value): [tables], key = value,
* dotted and quoted keys, strings, numbers, booleans and one-line arrays. lines it
* can't read come back as "line N: why"; the rest still count
*/
pub fn toml(text: &str) -> (Vec<(String, Value)>, Vec<String>) {
let mut table = String::new();
let mut values = Vec::new();
let mut errors = Vec::new();
for (number, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut reader = Reader { text: line, at: 0 };
let read = if reader.eat('[') {
reader.key().and_then(|name| if reader.eat(']') { reader.line_done().map(|()| table = name) } else { Err(String::from("a table name needs ]")) })
} else {
reader.key().and_then(|key| {
if !reader.eat('=') {
return Err(String::from("a key needs = and a value"));
}
let value = reader.value()?;
reader.line_done()?;
values.push((if table.is_empty() { key } else { format!("{table}.{key}") }, value));
Ok(())
})
};
if let Err(why) = read {
errors.push(format!("line {}: {why}", number + 1));
}
}
(values, errors)
}
// a value as toml writes it; floats keep their point, so they read back as floats
pub fn toml_text(value: &Value) -> String {
match value {
Value::Null => String::from("\"\""),
Value::Bool(on) => on.to_string(),
Value::Int(number) => number.to_string(),
Value::Float(number) => format!("{number:?}"),
Value::Text(text) => quoted(text),
Value::List(items) => format!("[{}]", items.iter().map(toml_text).collect::<Vec<_>>().join(", ")),
Value::Table(pairs) => format!("{{ {} }}", pairs.iter().map(|(key, value)| format!("{key} = {}", toml_text(value))).collect::<Vec<_>>().join(", ")),
}
}
// a string in double quotes, as both toml and lua read it
pub fn quoted(text: &str) -> String {
let mut out = String::from("\"");
for letter in text.chars() {
match letter {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
_ => out.push(letter),
}
}
out.push('"');
out
}
// every json value in the text, one after another (`hyprctl --batch` prints one object per command);
// text between them that isn't json (an error for one command) is skipped
pub fn json(text: &str) -> Vec<Value> {
let mut reader = Reader { text, at: 0 };
let mut values = Vec::new();
loop {
reader.skip_space();
if reader.rest().is_empty() {
return values;
}
let start = reader.at;
match reader.value() {
Ok(value) => values.push(value),
Err(_) => match text[start..].char_indices().skip(1).find(|(_, letter)| matches!(letter, '{' | '[')) {
Some((next, _)) => reader.at = start + next,
None => return values,
},
}
}
}
// the kinds of value a Hyprland option holds, as `hyprctl -j getoption` names them
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum HyprKind {
Bool,
Int,
Float,
Text,
Gradient,
Vec2,
Css,
}
/*
* one `hyprctl -j getoption` answer as (option, kind, the text the config window edits,
* whether Hyprland's own config sets it): gradients read "rgba(80d9ffff) rgba(b18cffff) 45deg",
* points "0 0", gaps "6 6 6 6"
*/
pub fn hypr_option(answer: &Value) -> Option<(String, HyprKind, String, bool)> {
let name = answer.get("option")?.text()?.to_string();
let set = answer.get("set") == Some(&Value::Bool(true));
let kinds = [("bool", HyprKind::Bool), ("int", HyprKind::Int), ("float", HyprKind::Float), ("str", HyprKind::Text), ("gradient", HyprKind::Gradient), ("vec2", HyprKind::Vec2), ("css", HyprKind::Css)];
let (kind, value) = kinds.into_iter().find_map(|(key, kind)| Some((kind, answer.get(key)?)))?;
Some((name, kind, hypr_text(kind, value)?, set))
}
// a Hyprland value (getoption's, or a default in `hyprctl descriptions`) as the text the window edits
pub fn hypr_text(kind: HyprKind, value: &Value) -> Option<String> {
Some(match kind {
HyprKind::Bool => (value == &Value::Bool(true) || value == &Value::Int(1)).to_string(),
HyprKind::Int => (value.number()? as i64).to_string(),
HyprKind::Float => number_text(value.number()?),
HyprKind::Text => value.text()?.to_string(),
HyprKind::Css => value.text().map(String::from).or_else(|| value.number().map(|number| (number as i64).to_string()))?,
HyprKind::Gradient => gradient_text(value.text()?),
HyprKind::Vec2 => match value {
Value::List(point) => point.iter().filter_map(Value::number).map(number_text).collect::<Vec<_>>().join(" "),
_ => return None,
},
})
}
// 3 as "3.0", 0.25 as "0.25": floats keep a point so they read as floats
pub fn number_text(number: f64) -> String {
let rounded = (number * 1e6).round() / 1e6;
if rounded.fract() == 0.0 { format!("{rounded:.1}") } else { rounded.to_string() }
}
// hyprctl's "ff80d9ff ffb18cff 45deg" (argb) as "rgba(80d9ffff) rgba(b18cffff) 45deg"; an angle of 0 is left out
fn gradient_text(printed: &str) -> String {
printed
.split_whitespace()
.filter(|word| *word != "0deg")
.map(|word| match word.len() == 8 && word.chars().all(|digit| digit.is_ascii_hexdigit()) {
true => format!("rgba({}{})", &word[2..], &word[..2]),
false => word.to_string(),
})
.collect::<Vec<_>>()
.join(" ")
}
// a colour the way the config window takes it, as Hyprland's "rgba(rrggbbaa)"
fn hypr_color(word: &str) -> Option<String> {
let hex = |digits: &str| digits.chars().all(|digit| digit.is_ascii_hexdigit());
let rgba = if let Some(inner) = word.strip_prefix("rgba(").and_then(|rest| rest.strip_suffix(')')) {
inner.to_string()
} else if let Some(inner) = word.strip_prefix("rgb(").and_then(|rest| rest.strip_suffix(')')) {
format!("{inner}ff")
} else if let Some(digits) = word.strip_prefix('#') {
if digits.len() == 6 { format!("{digits}ff") } else { digits.to_string() }
} else {
let argb = Some(word.strip_prefix("0x").unwrap_or(word)).filter(|digits| digits.len() == 8 && digits.is_ascii())?;
format!("{}{}", &argb[2..], &argb[..2])
};
(rgba.len() == 8 && hex(&rgba)).then(|| format!("rgba({})", rgba.to_lowercase()))
}
/*
* what the config window took, as the lua `hl.config` wants it for a kind: gradients as
* { colors = {..}, angle = N } (one colour alone as its string), points as { x, y }, gaps
* as one number or { top, right, bottom, left } from css-style 1 to 4 numbers
*/
pub fn hypr_lua(kind: HyprKind, text: &str) -> Result<String, String> {
let text = text.trim();
let numbers = || text.split_whitespace().map(|word| word.parse::<f64>().map_err(|_| format!("`{word}` isn't a number"))).collect::<Result<Vec<_>, _>>();
match kind {
HyprKind::Bool => match text {
"true" | "yes" | "on" | "1" => Ok(String::from("true")),
"false" | "no" | "off" | "0" => Ok(String::from("false")),
_ => Err(String::from("needs true or false")),
},
HyprKind::Int => text.parse::<i64>().map(|number| number.to_string()).map_err(|_| String::from("needs a whole number")),
HyprKind::Float => text.parse::<f64>().map(number_text).map_err(|_| String::from("needs a number")),
HyprKind::Text => Ok(quoted(text)),
HyprKind::Gradient => {
let mut colors = Vec::new();
let mut angle = None;
for word in text.split_whitespace() {
if let Some(degrees) = word.strip_suffix("deg") {
angle = Some(degrees.parse::<f64>().map_err(|_| format!("`{word}` isn't an angle"))?);
} else {
colors.push(quoted(&hypr_color(word).ok_or(format!("`{word}` isn't a colour like rgba(80d9ffff) or #80d9ff"))?));
}
}
match (colors.as_slice(), angle) {
([], _) => Err(String::from("needs a colour")),
([color], None) => Ok(color.clone()),
_ => Ok(format!("{{ colors = {{ {} }}, angle = {} }}", colors.join(", "), number_text(angle.unwrap_or(0.0)))),
}
}
HyprKind::Vec2 => match numbers()?.as_slice() {
[x, y] => Ok(format!("{{ {}, {} }}", number_text(*x), number_text(*y))),
_ => Err(String::from("needs two numbers, like 0 0")),
},
HyprKind::Css => {
let whole = numbers()?.iter().map(|number| number.round() as i64).collect::<Vec<_>>();
let [top, right, bottom, left] = match whole.as_slice() {
[all] => [*all; 4],
[vertical, horizontal] => [*vertical, *horizontal, *vertical, *horizontal],
[top, horizontal, bottom] => [*top, *horizontal, *bottom, *horizontal],
[top, right, bottom, left] => [*top, *right, *bottom, *left],
_ => return Err(String::from("needs 1 to 4 numbers, like 6 or 6 10")),
};
if top == right && right == bottom && bottom == left {
Ok(top.to_string())
} else {
Ok(format!("{{ top = {top}, right = {right}, bottom = {bottom}, left = {left} }}"))
}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -844,7 +1305,7 @@ Sink #80
fn writes_equalizers() { fn writes_equalizers() {
let gains = [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, -1.0, 0.0, 3.5]; let gains = [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, -1.0, 0.0, 3.5];
let config = eq_config("fake0", "Lo\"-Fi ${x} Lab", &gains, "bluez_output.00_1B.1"); let config = eq_config("fake0", "Lo\"-Fi ${x} Lab", &BANDS, &gains, "bluez_output.00_1B.1");
assert!(config.contains("node.name = \"magic_island_eq.fake0\" media.class = Audio/Sink")); assert!(config.contains("node.name = \"magic_island_eq.fake0\" media.class = Audio/Sink"));
assert!(config.contains("target.object = \"bluez_output.00_1B.1\"")); assert!(config.contains("target.object = \"bluez_output.00_1B.1\""));
@@ -854,7 +1315,7 @@ Sink #80
// a hostile player name can't close the quoted string // a hostile player name can't close the quoted string
assert!(config.contains("node.description = \"Lo-Fi x Lab EQ\"")); assert!(config.contains("node.description = \"Lo-Fi x Lab EQ\""));
assert!(!eq_config("fake0", "x", &gains, "").contains("target.object")); assert!(!eq_config("fake0", "x", &BANDS, &gains, "").contains("target.object"));
assert!(eq_params(&gains).starts_with("{ params = [ \"b1:Gain\" 6.0 \"b2:Gain\" 5.0")); assert!(eq_params(&gains).starts_with("{ params = [ \"b1:Gain\" 6.0 \"b2:Gain\" 5.0"));
@@ -983,4 +1444,76 @@ Sink #80
assert!(!safe_name("openvpn.x; reboot")); assert!(!safe_name("openvpn.x; reboot"));
assert!(!safe_name("")); assert!(!safe_name(""));
} }
#[test]
fn reads_and_writes_toml() {
let text = "# top\nfont = \"Fira Code\" # a comment\n\n[island.pill]\nwidth = 220.0\nrows = 10\nshow = true\n\"quoted key\" = 'raw \\n'\n\
[hyprland.general.col]\nactive_border = \"rgba(80d9ffff) 45deg\"\ngains = [5.0, -1.5, 0]\nbad = \nworse ] = 1\n[unclosed\n";
let (values, errors) = toml(text);
assert_eq!(values, vec![
(String::from("font"), Value::Text(String::from("Fira Code"))),
(String::from("island.pill.width"), Value::Float(220.0)),
(String::from("island.pill.rows"), Value::Int(10)),
(String::from("island.pill.show"), Value::Bool(true)),
(String::from("island.pill.quoted key"), Value::Text(String::from("raw \\n"))),
(String::from("hyprland.general.col.active_border"), Value::Text(String::from("rgba(80d9ffff) 45deg"))),
(String::from("hyprland.general.col.gains"), Value::List(vec![Value::Float(5.0), Value::Float(-1.5), Value::Int(0)])),
]);
assert_eq!(errors.len(), 3);
assert!(errors[0].starts_with("line 12:"));
// what toml_text writes reads back the same
for value in [Value::Float(220.0), Value::Float(0.66), Value::Int(-3), Value::Text(String::from("a \"b\"\\c\nd")), Value::List(vec![Value::Float(1.0), Value::Bool(false)])] {
let (read, errors) = toml(&format!("key = {}", toml_text(&value)));
assert!(errors.is_empty());
assert_eq!(read, vec![(String::from("key"), value)]);
}
}
#[test]
fn reads_hyprland_json_and_writes_lua() {
let printed = "{\"option\": \"general:border_size\", \"int\": 2, \"set\": true }\n\ninvalid type (internal error) é\n{\"option\": \"decoration:rounding_power\", \"float\": 3.000000, \"set\": true }\n\n\
{\"option\": \"general:col.active_border\", \"gradient\": \"ff80d9ff ffb18cff 45deg\", \"set\": true }\n\
{\"option\": \"general:col.inactive_border\", \"gradient\": \"ff30374f 0deg\", \"set\": false }\n\
{\"option\": \"decoration:shadow:offset\", \"vec2\": [0,-2.5], \"set\": false }\n{\"option\": \"general:gaps_in\", \"css\": \"6 6 6 6\", \"set\": true }\n\
{\"option\": \"animations:enabled\", \"bool\": true, \"set\": false }\n{\"option\": \"general:layout\", \"str\": \"dwind\\u006ce\", \"set\": true }\n";
let options: Vec<_> = json(printed).iter().filter_map(hypr_option).map(|(name, kind, text, set)| (name, kind, text, set)).collect();
assert_eq!(options, vec![
(String::from("general:border_size"), HyprKind::Int, String::from("2"), true),
(String::from("decoration:rounding_power"), HyprKind::Float, String::from("3.0"), true),
(String::from("general:col.active_border"), HyprKind::Gradient, String::from("rgba(80d9ffff) rgba(b18cffff) 45deg"), true),
(String::from("general:col.inactive_border"), HyprKind::Gradient, String::from("rgba(30374fff)"), false),
(String::from("decoration:shadow:offset"), HyprKind::Vec2, String::from("0.0 -2.5"), false),
(String::from("general:gaps_in"), HyprKind::Css, String::from("6 6 6 6"), true),
(String::from("animations:enabled"), HyprKind::Bool, String::from("true"), false),
(String::from("general:layout"), HyprKind::Text, String::from("dwindle"), true),
]);
// descriptions: one array of objects with nulls and nested lists
let described = json("[ {\"name\": \"a:b\", \"min\": null, \"map\": [{\"off\": 0}, {\"on\": 1}]} ]");
assert_eq!(described[0], Value::List(vec![Value::Table(vec![
(String::from("name"), Value::Text(String::from("a:b"))),
(String::from("min"), Value::Null),
(String::from("map"), Value::List(vec![Value::Table(vec![(String::from("off"), Value::Int(0))]), Value::Table(vec![(String::from("on"), Value::Int(1))])])),
])]));
// every text the window shows goes back to lua in the form hl.config takes
assert_eq!(hypr_lua(HyprKind::Gradient, "rgba(80d9ffff) rgba(b18cffff) 45deg").unwrap(), "{ colors = { \"rgba(80d9ffff)\", \"rgba(b18cffff)\" }, angle = 45.0 }");
assert_eq!(hypr_lua(HyprKind::Gradient, "#30374f").unwrap(), "\"rgba(30374fff)\"");
assert_eq!(hypr_lua(HyprKind::Gradient, "ee080d18").unwrap(), "\"rgba(080d18ee)\"");
assert!(hypr_lua(HyprKind::Gradient, "red").is_err());
assert_eq!(hypr_lua(HyprKind::Vec2, "0 -2.5").unwrap(), "{ 0.0, -2.5 }");
assert_eq!(hypr_lua(HyprKind::Css, "6 6 6 6").unwrap(), "6");
assert_eq!(hypr_lua(HyprKind::Css, "6 10").unwrap(), "{ top = 6, right = 10, bottom = 6, left = 10 }");
assert_eq!(hypr_lua(HyprKind::Bool, "yes").unwrap(), "true");
assert!(hypr_lua(HyprKind::Int, "2.5").is_err());
assert_eq!(hypr_lua(HyprKind::Float, "3").unwrap(), "3.0");
assert_eq!(hypr_lua(HyprKind::Text, "say \"hi\"").unwrap(), "\"say \\\"hi\\\"\"");
}
} }
+41 -44
View File
@@ -18,26 +18,23 @@ use amane::{
Zone, children, 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::glyph::{Glyph, icon};
use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, HOVER_MS, SHADOW, TOP_GAP, backdrop, veiled}; use crate::island::{self, backdrop, veiled};
use crate::parse::{self, Pairing}; use crate::parse::{self, Pairing};
use crate::system; use crate::system;
use crate::theme; use crate::theme;
const POPUP_W: f32 = 360.0; // the popup's sizes are settings (config.rs: popups.*)
const POPUP_PAD: f32 = 16.0; fn inner_w() -> f32 {
const INNER_W: f32 = POPUP_W - 2.0 * POPUP_PAD; layout::width() - 2.0 * layout::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;
// a bluetoothctl session that hasn't ended by then is stopped // a bluetoothctl session that hasn't ended after popups.timing.session_s is stopped
const SESSION_S: u64 = 90;
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub enum Ask { 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.request = Some(Request { ask, wifi, name: name.to_string(), target: target.to_string(), input: new_input() });
prompt.typed.clear(); prompt.typed.clear();
prompt.copied = None; 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); 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 // stopped if it hasn't ended in time, which ends the reading below too
thread::spawn(move || { 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); 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() Prompt::write()
.hover .hover
.entry(spot) .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 }); .to(if inside { 1.0 } else { 0.0 });
} }
} }
@@ -508,13 +505,13 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
}; };
let header = Row::new(children![ 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![ Column::new(children![
Text::new(title).size(13.0).weight(Weight::Bold).color(theme.text).elide(), Text::new(title).size(text::popup()).weight(Weight::Bold).color(theme.text).elide(),
Text::new(question).size(11.0).color(theme.dim).elide(), Text::new(question).size(text::small()).color(theme.dim).elide(),
]) ])
.gap(3.0) .gap(3.0)
.width(INNER_W - HEADER_H - 12.0), .width(inner_w() - layout::header_height() - 12.0),
]) ])
.gap(12.0) .gap(12.0)
.align(Center); .align(Center);
@@ -525,7 +522,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
match &request.ask { match &request.ask {
Ask::Password | Ask::Pin | Ask::Passkey => { Ask::Password | Ask::Pin | Ask::Passkey => {
let field = TextInput::new(request.input) let field = TextInput::new(request.input)
.size(13.0) .size(text::popup())
.color(theme.text) .color(theme.text)
.placeholder(match request.ask { .placeholder(match request.ask {
Ask::Password => "password", Ask::Password => "password",
@@ -540,22 +537,22 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
rows.push(Box::new( rows.push(Box::new(
Rectangle::new() Rectangle::new()
.width(INNER_W) .width(inner_w())
.height(FIELD_H) .height(layout::field_height())
.radius(10.0) .radius(10.0)
.fill(CHIP) .fill(overlays::chip())
.border(1.0, theme.outline) .border(1.0, theme.outline)
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 12.0 }) .padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 12.0 })
.align_child(Start, Center) .align_child(Start, Center)
.child(field), .child(field),
)); ));
body_h = FIELD_H; body_h = layout::field_height();
} }
Ask::Confirm(code) | Ask::Show(code) if !code.is_empty() => { Ask::Confirm(code) | Ask::Show(code) if !code.is_empty() => {
rows.push(Box::new(big_line(&spaced(code), 26.0, theme.accent))); rows.push(Box::new(big_line(&spaced(code), 26.0, theme.accent)));
body_h = CODE_H; body_h = layout::code_height();
} }
Ask::Saved(password) => { Ask::Saved(password) => {
let password = password.clone(); let password = password.clone();
@@ -563,12 +560,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
// a click on it copies it too // 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))))); 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, .. } => { 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(_) => {} Ask::Confirm(_) | Ask::Show(_) => {}
} }
@@ -582,9 +579,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let password = password.clone(); let password = password.clone();
let label = if prompt.copied.is_some() { "Copied" } else { "Copy" }; 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 { .. }) { } 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 { let primary = match request.ask {
@@ -598,7 +595,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
if let Some(label) = primary { if let Some(label) = primary {
let ok = ready(&request.ask, &prompt.typed); 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 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)); let primary = button(label, fill, ink, if ok { lit(1) } else { 0.0 }).on_hover(hovering(1));
@@ -615,27 +612,27 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
buttons.push(Box::new(primary)); buttons.push(Box::new(primary));
} }
rows.push(Box::new(Row::new(vec![Box::new(Row::new(buttons).gap(10.0)) as Box<dyn Widget>]).justify(End).width(INNER_W).height(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 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 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() let card = Rectangle::new()
.width(POPUP_W) .width(layout::width())
.height(card_h) .height(card_h)
.radius(22.0) .radius(22.0)
.fill(backdrop(&colors, 255)) .fill(backdrop(&colors, 255))
.border(1.0, Color::blend(theme.outline, theme.accent, 0.5)) .border(1.0, Color::blend(theme.outline, theme.accent, 0.5))
.shadow(Shadow::drop(theme.accent).opacity(0.45).blur(22.0)) .shadow(Shadow::drop(theme.accent).opacity(0.45).blur(22.0))
.translate(0.0, 8.0 * (1.0 - appear)) .translate(0.0, 8.0 * (1.0 - appear))
.child(veiled(content, POPUP_W, card_h, 22.0, appear, &colors)); .child(veiled(content, layout::width(), card_h, 22.0, appear, &colors));
// over the middle of the map menu, a little below the control centre's top // 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 * the keyboard only while there's something to type: the other popups come on their
@@ -644,7 +641,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
*/ */
let typing = matches!(request.ask, Ask::Password | Ask::Pin | Ask::Passkey); 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 }) .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 }) .keyboard(if typing { Keyboard::Exclusive } else { Keyboard::OnDemand })
.on_key(|key| match key { .on_key(|key| match key {
@@ -660,9 +657,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 // a code spaced in threes, the way devices show it
@@ -671,9 +668,9 @@ fn spaced(code: &str) -> String {
} }
fn big_line(text: &str, size: f32, color: Color) -> Rectangle { 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 { 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))
} }
+705
View File
@@ -0,0 +1,705 @@
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>,
}
impl Service for Ui {
fn new() -> Self {
Self { page: String::from("appearance"), query: String::new(), message: None, hover: None }
}
fn listen() {}
}
pub fn open() {
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();
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(&note.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)
.on_key(|key| {
if key == Key::Escape {
close_window(NAME);
}
})
.child(
Rectangle::new()
.width(width)
.height(height)
.fill(colors.surface_low)
.child(Row::new(children![sidebar(&ui, &hypr, height, &colors), main])),
)
}
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> 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(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;
}));
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::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)())));
}
let _ = colors;
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
fn choices(option: &HyprOption, colors: &Colors) -> Row {
let name = option.name;
let chips: Vec<Box<dyn Widget>> = option
.choices
.iter()
.map(|(label, value)| {
let chosen = option.current == value.to_string();
let value = value.to_string();
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 |_| hypr::set(name, &value))
.child(Text::new(label.replace('_', " ")).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
View File
@@ -7,23 +7,23 @@ use std::{fs, thread};
use amane::{Argument, Bus, Media, Service}; use amane::{Argument, Bus, Media, Service};
use crate::art; use crate::art;
use crate::config::sound::{equalizer, presets as gains, timing};
use crate::parse::{self, EQ_PREFIX, Sink, Stream}; use crate::parse::{self, EQ_PREFIX, Sink, Stream};
use crate::theme; use crate::theme;
// how far a band can lift or cut, in dB // the presets with their gains for the 10 bands, low to high (config.rs: sound.presets.*)
pub const MAX_GAIN: f32 = 12.0; pub fn presets() -> [(&'static str, [f32; 10]); 8] {
[
// gains for BANDS (31 Hz .. 16 kHz) ("Flat", gains::flat()),
pub const EQ_PRESETS: [(&str, [f32; 10]); 8] = [ ("Bass", gains::bass()),
("Flat", [0.0; 10]), ("Treble", gains::treble()),
("Bass", [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]), ("Vocal", gains::vocal()),
("Treble", [-2.0, -1.5, -1.0, 0.0, 1.0, 2.0, 3.0, 4.5, 5.5, 6.0]), ("Pop", gains::pop()),
("Vocal", [-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.5, 2.0, 0.0, -1.0]), ("Rock", gains::rock()),
("Pop", [-1.0, 1.0, 2.5, 3.5, 2.5, 0.5, -0.5, -0.5, 1.0, 2.0]), ("Jazz", gains::jazz()),
("Rock", [5.0, 4.0, 2.5, -0.5, -1.5, -0.5, 2.0, 3.5, 4.5, 5.0]), ("Classic", gains::classic()),
("Jazz", [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.5, 2.5, 3.0]), ]
("Classic", [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0]), }
];
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub struct Eq { pub struct Eq {
@@ -101,7 +101,7 @@ impl Service for Sound {
poll(&mut known); poll(&mut known);
save(); save();
thread::sleep(Duration::from_secs(1)); thread::sleep(Duration::from_millis(timing::poll_ms()));
} }
} }
} }
@@ -185,7 +185,7 @@ impl Sound {
} }
fn hold(&mut self) { fn hold(&mut self) {
self.held_until = Some(Instant::now() + Duration::from_millis(2500)); self.held_until = Some(Instant::now() + Duration::from_millis(timing::hold_ms()));
} }
// from input handlers: sets and unmutes every stream of one player // from input handlers: sets and unmutes every stream of one player
@@ -261,7 +261,7 @@ impl Sound {
// from a dragged equalizer band; half-dB steps keep pw-cli calls few // from a dragged equalizer band; half-dB steps keep pw-cli calls few
pub fn set_band(player: &str, identity: &str, band: usize, gain: f32) { pub fn set_band(player: &str, identity: &str, band: usize, gain: f32) {
let gain = ((gain * 2.0).round() / 2.0).clamp(-MAX_GAIN, MAX_GAIN); let gain = ((gain * 2.0).round() / 2.0).clamp(-equalizer::max_gain(), equalizer::max_gain());
let gains = { let gains = {
let mut sound = Self::write(); let mut sound = Self::write();
@@ -287,18 +287,18 @@ impl Sound {
// false when there is no preset by that name // false when there is no preset by that name
pub fn preset(player: &str, identity: &str, name: &str) -> bool { pub fn preset(player: &str, identity: &str, name: &str) -> bool {
let Some((preset, gains)) = EQ_PRESETS.iter().find(|(preset, _)| preset.eq_ignore_ascii_case(name)) else { let Some((preset, gains)) = presets().into_iter().find(|(preset, _)| preset.eq_ignore_ascii_case(name)) else {
return false; return false;
}; };
{ {
let mut sound = Self::write(); let mut sound = Self::write();
sound.eqs.insert(identity.to_string(), Eq { preset: preset.to_string(), gains: *gains }); sound.eqs.insert(identity.to_string(), Eq { preset: preset.to_string(), gains });
sound.unsaved = true; sound.unsaved = true;
} }
apply(player, identity, gains); apply(player, identity, &gains);
true true
} }
@@ -445,7 +445,7 @@ fn start_eq(key: &str, identity: &str, gains: &[f32; 10], target: &str) {
let config = eq_file(key); let config = eq_file(key);
if fs::write(&config, parse::eq_config(key, identity, gains, target)).is_ok() { if fs::write(&config, parse::eq_config(key, identity, &equalizer::bands(), gains, target)).is_ok() {
run(&["setsid", "-f", "pipewire", "-c", &config.display().to_string()]); run(&["setsid", "-f", "pipewire", "-c", &config.display().to_string()]);
} }
} }
+6 -5
View File
@@ -9,10 +9,10 @@ use amane::{Media, Service};
use crate::art; use crate::art;
use crate::island::{self, Island}; use crate::island::{self, Island};
use crate::parse; use crate::parse;
use crate::config::sound::spectrum as settings;
use crate::sound::Sound; use crate::sound::Sound;
// cava's bars, low to high frequencies; the ring mirrors them // cava's bars (sound.spectrum.bars), low to high frequencies; the ring mirrors them
pub const BARS: usize = 24;
/* /*
* the shown player's own sound as bars: `pw-record --target <its stream>` * the shown player's own sound as bars: `pw-record --target <its stream>`
@@ -58,17 +58,18 @@ impl Service for Spectrum {
running = wanted.and_then(|stream| Capture::start(stream, &config)); running = wanted.and_then(|stream| Capture::start(stream, &config));
} }
thread::sleep(Duration::from_millis(200)); thread::sleep(Duration::from_millis(settings::check_ms()));
} }
} }
} }
fn cava_config() -> String { fn cava_config() -> String {
format!( format!(
"[general]\nframerate = 60\nbars = {BARS}\n\ "[general]\nframerate = 60\nbars = {}\n\
[input]\nmethod = fifo\nsource = /dev/stdin\nsample_rate = 44100\nsample_bits = 16\n\ [input]\nmethod = fifo\nsource = /dev/stdin\nsample_rate = 44100\nsample_bits = 16\n\
[output]\nmethod = raw\nraw_target = /dev/stdout\ndata_format = ascii\nascii_max_range = 100\nchannels = mono\n\ [output]\nmethod = raw\nraw_target = /dev/stdout\ndata_format = ascii\nascii_max_range = 100\nchannels = mono\n\
[smoothing]\nnoise_reduction = 70\n" [smoothing]\nnoise_reduction = 70\n",
settings::bars()
) )
} }
+9 -9
View File
@@ -16,7 +16,7 @@ use amane::{Argument, Bluetooth, Bus, Service, Value};
use crate::control::Control; use crate::control::Control;
use crate::parse; use crate::parse;
use crate::theme::Theme; use crate::config::control::{polling, services};
const IWD: &str = "net.connman.iwd"; const IWD: &str = "net.connman.iwd";
const DEVICE: &str = "net.connman.iwd.Device"; const DEVICE: &str = "net.connman.iwd.Device";
@@ -129,7 +129,7 @@ impl Service for Links {
loop { loop {
refresh(); refresh();
thread::sleep(Duration::from_secs(2)); thread::sleep(Duration::from_millis(polling::links_ms()));
} }
} }
} }
@@ -188,7 +188,7 @@ fn wifi_rate(bus: &Bus, device: &str, network: &str) -> Option<u32> {
if let Some((asked, on, rate)) = last.as_ref() if let Some((asked, on, rate)) = last.as_ref()
&& on == network && on == network
&& asked.elapsed() < Duration::from_secs(60) && asked.elapsed() < Duration::from_secs(polling::wifi_rate_s())
{ {
return *rate; return *rate;
} }
@@ -360,11 +360,11 @@ pub fn toggle_wifi() {
/* /*
* one radio: while the device is an access point it isn't a wi-fi client. the hotspot * 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() { pub fn toggle_hotspot() {
let wifi = Links::read().wifi.clone(); let wifi = Links::read().wifi.clone();
let name = Theme::read().hotspot.clone(); let name = services::hotspot();
if !wifi.service { if !wifi.service {
root(&["rc-service", "iwd", "start"]); root(&["rc-service", "iwd", "start"]);
@@ -634,7 +634,7 @@ impl Service for Power {
loop { loop {
refresh_power(); refresh_power();
thread::sleep(Duration::from_secs(2)); thread::sleep(Duration::from_millis(polling::power_ms()));
} }
} }
} }
@@ -676,7 +676,7 @@ fn charge() -> Option<Charge> {
let mut estimate = ESTIMATE.lock().unwrap_or_else(PoisonError::into_inner); let mut estimate = ESTIMATE.lock().unwrap_or_else(PoisonError::into_inner);
let minutes = match &*estimate { 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); let minutes = parse::battery_minutes(&status, now, full, rate);
@@ -807,7 +807,7 @@ impl Service for Gpu {
loop { loop {
if !Control::read().expanded() { if !Control::read().expanded() {
thread::sleep(Duration::from_millis(400)); thread::sleep(Duration::from_millis(polling::gpu_wait_ms()));
continue; continue;
} }
@@ -820,7 +820,7 @@ impl Service for Gpu {
*Self::write() = gpu; *Self::write() = gpu;
} }
thread::sleep(Duration::from_secs(2)); thread::sleep(Duration::from_millis(polling::gpu_ms()));
} }
} }
} }
+53 -55
View File
@@ -6,13 +6,14 @@ use std::{env, fs, thread};
use amane::{Animation, Blend, Color, Service}; 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 * 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 * matugen/amane.colors, which writes ~/.config/amane/theme. config.toml (config.rs) says
* says whether the open media card takes the song cover's colours instead, and how long * whether the open media card takes the song cover's colours instead, how long a change
* a change cross-fades. without a theme file the shell keeps its original pink on purple * 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)] #[derive(Clone, Copy, PartialEq)]
pub struct Colors { pub struct Colors {
@@ -28,18 +29,23 @@ pub struct Colors {
pub surface_low: Color, pub surface_low: Color,
} }
const PINK_ON_PURPLE: Colors = Colors { // the colours without a theme file, from config.toml
accent: Color::rgb(247, 168, 200), fn fallback() -> Colors {
on_accent: Color::rgb(40, 18, 32), use config::appearance::fallback as colors;
accent_soft: Color::rgb(150, 82, 122),
flame: Color::rgb(255, 228, 240), Colors {
text: Color::rgb(241, 236, 246), accent: colors::accent(),
dim: Color::rgba(205, 192, 218, 160), on_accent: colors::on_accent(),
outline: Color::rgba(255, 255, 255, 26), accent_soft: colors::accent_soft(),
surface: Color::rgb(24, 18, 30), flame: colors::flame(),
surface_high: Color::rgb(35, 26, 44), text: colors::text(),
surface_low: Color::rgb(13, 10, 18), dim: colors::dim(),
}; outline: colors::outline(),
surface: colors::surface(),
surface_high: colors::surface_high(),
surface_low: colors::surface_low(),
}
}
impl Colors { impl Colors {
// the background gradient's three stops, light to dark // the background gradient's three stops, light to dark
@@ -70,42 +76,33 @@ impl Blend for Colors {
pub struct Theme { pub struct Theme {
colors: Animation<Colors>, 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,
} }
impl Service for Theme { impl Service for Theme {
fn new() -> Self { fn new() -> Self {
Self { Self { colors: Animation::new(fallback()) }
colors: Animation::new(PINK_ON_PURPLE),
cover_colors: false,
hotspot: String::new(),
extra_output: String::new(),
}
} }
/* /*
* waits on the kernel instead of polling: amane::watch_file watches the folder, so * 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. * 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; either change re-reads both
*/ */
fn listen() { fn listen() {
reload(); reload();
thread::spawn(|| follow("shell.conf")); thread::spawn(|| follow("config.toml"));
follow("theme"); follow("theme");
} }
} }
// what was read last, (theme, shell.conf); none until the first read // redraws every view, for a setting changed in the config window
pub fn touch() {
let _ = Theme::write();
}
// what was read last, (theme, config.toml); none until the first read
static LAST: Mutex<Option<(String, String)>> = Mutex::new(None); static LAST: Mutex<Option<(String, String)>> = Mutex::new(None);
fn follow(name: &str) { fn follow(name: &str) {
@@ -119,7 +116,7 @@ fn follow(name: &str) {
// a deleted theme file sends no event: the defaults come back at the next start // a deleted theme file sends no event: the defaults come back at the next start
fn reload() { fn reload() {
let read = (read("theme"), read("shell.conf")); let read = (read("theme"), read("config.toml"));
let mut last = LAST.lock().unwrap_or_else(PoisonError::into_inner); let mut last = LAST.lock().unwrap_or_else(PoisonError::into_inner);
@@ -128,8 +125,12 @@ fn reload() {
return; return;
} }
if last.as_ref().is_none_or(|(_, config)| *config != read.1) {
config::apply_text(&read.1);
}
// the colours at startup show at once, later changes cross-fade // the colours at startup show at once, later changes cross-fade
apply(&read.0, &read.1, last.is_none()); apply(&read.0, last.is_none());
*last = Some(read); *last = Some(read);
} }
@@ -138,18 +139,21 @@ fn read(name: &str) -> String {
fs::read_to_string(config_file(name)).unwrap_or_default() fs::read_to_string(config_file(name)).unwrap_or_default()
} }
// ~/.config/amane/<name>, or under $XDG_CONFIG_HOME // $XDG_CONFIG_HOME, or ~/.config
pub fn config_file(name: &str) -> PathBuf { pub fn config_home() -> PathBuf {
let config = env::var_os("XDG_CONFIG_HOME") env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.unwrap_or_default(); .unwrap_or_default()
config.join("amane").join(name)
} }
fn apply(theme: &str, config: &str, startup: bool) { // ~/.config/amane/<name>, or under $XDG_CONFIG_HOME
let mut colors = PINK_ON_PURPLE; pub fn config_file(name: &str) -> PathBuf {
config_home().join("amane").join(name)
}
fn apply(theme: &str, startup: bool) {
let mut colors = fallback();
// a missing or broken line keeps that colour's default // a missing or broken line keeps that colour's default
for (key, value) in parse::settings(theme) { for (key, value) in parse::settings(theme) {
@@ -174,17 +178,10 @@ fn apply(theme: &str, config: &str, startup: bool) {
} }
} }
let settings = parse::settings(config); let fade = if startup { 0 } else { config::appearance::colors::transition_ms() };
let setting = |name: &str| settings.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str());
let fade = if startup { 0 } else { setting("transition_ms").and_then(|ms| ms.parse().ok()).unwrap_or(400) };
let mut theme = Theme::write(); 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();
// a change in the middle of a fade starts from the colours on screen now // a change in the middle of a fade starts from the colours on screen now
let now = theme.colors.value(); let now = theme.colors.value();
@@ -194,19 +191,20 @@ fn apply(theme: &str, config: &str, startup: bool) {
// the colours of the frame being drawn, set by the view that draws it // the colours of the frame being drawn, set by the view that draws it
thread_local! { 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 * 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 { pub fn begin() -> bool {
let theme = Theme::read(); let theme = Theme::read();
NOW.set(theme.colors.value()); NOW.set(theme.colors.value());
theme.cover_colors config::appearance::colors::cover_colors()
} }
pub fn now() -> Colors { pub fn now() -> Colors {
+43 -51
View File
@@ -20,8 +20,11 @@ use amane::{
Start, Text, Value, Vertical, Weight, Widget, Zone, 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::glyph::{Glyph, icon};
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded, veiled}; use crate::island::{CLEAR, backdrop, faded, veiled};
use crate::{edge, parse, png, theme}; use crate::{edge, parse, png, theme};
const WATCHER: &str = "org.kde.StatusNotifierWatcher"; const WATCHER: &str = "org.kde.StatusNotifierWatcher";
@@ -30,18 +33,7 @@ const ITEM: &str = "org.kde.StatusNotifierItem";
const MENU: &str = "com.canonical.dbusmenu"; const MENU: &str = "com.canonical.dbusmenu";
const PROPERTIES: &str = "org.freedesktop.DBus.Properties"; const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
// the tray's pill: a slot per icon, like the workspaces' slots // the tray's pill (a slot per icon, like the workspaces' slots) and its menu are settings (config.rs: tray.*)
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;
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"> 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> <node>
@@ -504,7 +496,7 @@ fn hovering(spot: String) -> impl Fn(bool) + 'static {
TrayUi::write() TrayUi::write()
.hover .hover
.entry(spot.clone()) .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 }); .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(); let mut ui = TrayUi::write();
ui.open = Some(Open { item, monitor, x, trail: Vec::new(), entries }); 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.appear.to(1.0);
ui.moves += 1; ui.moves += 1;
ui.hover.retain(|spot, _| !spot.starts_with("row:")); ui.hover.retain(|spot, _| !spot.starts_with("row:"));
@@ -633,7 +625,7 @@ fn leave_soon() {
}; };
thread::spawn(move || { thread::spawn(move || {
thread::sleep(Duration::from_millis(400)); thread::sleep(Duration::from_millis(menu::close_ms()));
let gone = { let gone = {
let ui = TrayUi::read(); let ui = TrayUi::read();
@@ -663,7 +655,7 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
let theme = theme::now(); let theme = theme::now();
let count = shown.len() as f32; 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(); 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 open = ui.open.as_ref().is_some_and(|open| open.item.key == item.key && open.monitor == monitor);
let picture = match &item.icon { 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 // no icon to find: its first letter
None => Rectangle::new() None => Rectangle::new()
.width(ICON) .width(pill::icon())
.height(ICON) .height(pill::icon())
.align_child(Center, Center) .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() let slot = Rectangle::new()
.width(SLOT) .width(pill::slot())
.height(SLOT) .height(pill::slot())
.radius(Full) .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) .scale(1.0 + 0.08 * lit)
.align_child(Center, Center) .align_child(Center, Center)
.cursor(Pointer) .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 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 (clicked, scrolled, monitor) = ((*item).clone(), (*item).clone(), monitor.to_string());
let slot = slot let slot = slot
@@ -712,15 +704,15 @@ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> {
let pill = Rectangle::new() let pill = Rectangle::new()
.width(width) .width(width)
.height(COMPACT_H) .height(island_pill::height())
.radius(Full) .radius(Full)
.fill(backdrop(&theme.surfaces(), 255)) .fill(backdrop(&theme.surfaces(), 255))
.border(1.0, theme.outline) .border(1.0, theme.outline)
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0)) .shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(INSET) .padding(pill::inset())
.align_child(Start, Center) .align_child(Start, Center)
.on_hover(edge::over_bar) .on_hover(edge::over_bar)
.child(Row::new(slots).gap(SLOT_GAP).align(Center)); .child(Row::new(slots).gap(pill::slot_gap()).align(Center));
Some((pill, width)) Some((pill, width))
} }
@@ -749,10 +741,10 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
let theme = theme::now(); let theme = theme::now();
let colors = theme.surfaces(); 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 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 // the app's name, or the submenu's with the way back
let lit_back = ui.hover.get("row:back").map_or(0.0, Animation::value); let lit_back = ui.hover.get("row:back").map_or(0.0, Animation::value);
@@ -761,26 +753,26 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
Some((_, label)) => Box::new( Some((_, label)) => Box::new(
Rectangle::new() Rectangle::new()
.width(inner_w) .width(inner_w)
.height(ROW_H) .height(menu::row_height())
.radius(9.0) .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 }) .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 6.0 })
.align_child(Start, Center) .align_child(Start, Center)
.cursor(Pointer) .cursor(Pointer)
.on_hover(hovering(String::from("row:back"))) .on_hover(hovering(String::from("row:back")))
.on_click(|_| enter(None)) .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); 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 { 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))))); 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 += SEPARATOR_H; height += menu::separator_height();
continue; continue;
} }
@@ -795,18 +787,18 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
_ => Box::new(Rectangle::new().width(14.0).height(14.0)), _ => 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() let row = Rectangle::new()
.width(inner_w) .width(inner_w)
.height(ROW_H) .height(menu::row_height())
.radius(9.0) .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 }) .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 6.0 })
.align_child(Start, Center) .align_child(Start, Center)
.child( .child(
Row::new(vec![ 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, more,
]) ])
.justify(SpaceBetween) .justify(SpaceBetween)
@@ -823,15 +815,15 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
}; };
rows.push(Box::new(row)); rows.push(Box::new(row));
height += ROW_H; height += menu::row_height();
} }
let appear = ui.appear.value(); 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() let card = Rectangle::new()
.width(MENU_W) .width(menu::width())
.height(height) .height(height)
.radius(16.0) .radius(16.0)
.fill(backdrop(&colors, 255)) .fill(backdrop(&colors, 255))
@@ -848,17 +840,17 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
leave_soon(); leave_soon();
} }
}) })
.child(veiled(content, MENU_W, height, 16.0, appear, &colors)); .child(veiled(content, menu::width(), height, 16.0, appear, &colors));
let left = (open.x - 8.0 - SHADOW).max(0.0); let left = (open.x - 8.0 - island_pill::shadow()).max(0.0);
let top = TOP_GAP + COMPACT_H + 6.0 - SHADOW; 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 }) .margin(Margin { top: top.max(0.0) as i32, right: 0, bottom: 0, left: left as i32 })
.input_region(vec![region]) .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 // 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
+74 -44
View File
@@ -8,18 +8,15 @@ use amane::{
Rectangle, Row, Service, Shadow, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone, Rectangle, Row, Service, Shadow, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
}; };
use crate::edge::{self, Edge, HIDE_H, Part}; use crate::config::appearance::{motion, overlays, text};
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded, mix}; use crate::config::island::pill as island_pill;
use crate::config::workspaces::pill;
use crate::edge::{self, Edge, Part};
use crate::island::{CLEAR, backdrop, faded, mix};
use crate::{theme, tray}; use crate::{theme, tray};
// one round slot per workspace, inside the pill's inset; the pill sits LEFT px from the monitor's edge // one round slot per workspace, inside the pill's inset, the pill pill::left() px from the monitor's edge,
const SLOT: f32 = 28.0; // the tray pill::tray_gap() after it (config.rs: workspaces.*)
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;
// each hovered workspace's highlight by workspace id, fading between 0 and 1 // each hovered workspace's highlight by workspace id, fading between 0 and 1
#[derive(Default)] #[derive(Default)]
@@ -51,7 +48,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let edge = Edge::read(); let edge = Edge::read();
let shown = edge.shown(&monitor.name); 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) (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)
}; };
// special workspaces (scratchpads) aren't places to switch to // special workspaces (scratchpads) aren't places to switch to
@@ -63,12 +60,32 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.collect(); .collect();
let count = here.len() as f32; let count = here.len() as f32;
let width = 2.0 * INSET + count * SLOT + (count - 1.0).max(0.0) * SLOT_GAP; 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);
let width = 2.0 * pill::inset() + count * pill::slot() + (count - 1.0).max(0.0) * pill::slot_gap() + left * step;
// the window keeps the ghost's full room while it closes, so it isn't resized every frame
let room = if gone.is_some() { width + (1.0 - left) * step } else { width };
let window = |window_w: f32| { let window = |window_w: f32| {
LayerWindow::new() LayerWindow::new()
.width(window_w) .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_vertical(Vertical::Top)
.anchor_horizontal(Horizontal::Left) .anchor_horizontal(Horizontal::Left)
// Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them // Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them
@@ -78,35 +95,34 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
}; };
// empty and click-through rather than hidden: see the island's view for why // empty and click-through rather than hidden: see the island's view for why
if here.is_empty() || slide <= -HIDE_H { if here.is_empty() || slide <= -edge::hide_h() {
return window(LEFT + width + SHADOW).click_through().child(Rectangle::new().width(0.0).height(0.0)); 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 // 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 = 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)| TRAY_GAP + tray_w); 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(); 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 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, after a closing ghost by what's left of it
*/ */
let theme = theme::now(); let theme = theme::now();
let slot_x = |index: usize| index as f32 * (SLOT + SLOT_GAP); let slot_x = |index: usize| index as f32 * step + gone.as_ref().filter(|(at, _, _)| index >= *at).map_or(0.0, |_| left * step);
let focused_at = here.iter().position(|workspace| workspace.focused()); 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((at, _, _)) => Some((*at as f32 * step, t)),
None => here.iter().position(|workspace| workspace.id() == from).map(|at| (slot_x(at), t)),
});
let highlight_x = match (focused_at, came_from) { let highlight_x = match (focused_at, came_from) {
(Some(to), Some((from, t))) => { (Some(to), Some((from_x, t))) => Some(mix(from_x, slot_x(to), eased(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), None) => Some(slot_x(to)), (Some(to), None) => Some(slot_x(to)),
_ => None, _ => None,
}; };
@@ -117,7 +133,20 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
amane::request_frame(); amane::request_frame();
} }
for workspace in &here { // the ghost: its number shrinking into the gap it leaves
if let Some((at, label, left)) = &gone {
slots.push(Box::new(
Rectangle::new()
.width(pill::slot())
.height(pill::slot())
.align_child(Center, Center)
.scale(*left)
.translate(*at as f32 * step - (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 id = workspace.id();
let lit = hover.0.get(&id).map_or(0.0, Animation::value); let lit = hover.0.get(&id).map_or(0.0, Animation::value);
@@ -137,13 +166,13 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
} else { } else {
let resting = if workspace.windows() > 0 { theme.text } else { theme.dim }; 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( slots.push(Box::new(
Rectangle::new() Rectangle::new()
.width(SLOT) .width(pill::slot())
.height(SLOT) .height(pill::slot())
.radius(Full) .radius(Full)
.fill(fill) .fill(fill)
.align_child(Center, Center) .align_child(Center, Center)
@@ -151,41 +180,42 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.cursor(Pointer) .cursor(Pointer)
.on_hover(move |inside| hovering(id, inside)) .on_hover(move |inside| hovering(id, inside))
.on_click(move |_| focus(id)) .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(); let mut layers: Vec<Box<dyn Widget>> = Vec::new();
if let Some(x) = highlight_x { 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() let pill = Rectangle::new()
.width(width) .width(width)
.height(COMPACT_H) .height(island_pill::height())
.radius(Full) .radius(Full)
.fill(backdrop(&theme.surfaces(), 255)) .fill(backdrop(&theme.surfaces(), 255))
.border(1.0, theme.outline) .border(1.0, theme.outline)
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0)) .shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(INSET) .padding(pill::inset())
.align_child(Start, Center) .align_child(Start, Center)
.on_hover(edge::over_bar) .on_hover(edge::over_bar)
.child(Stack::new(layers).width(row_w).height(SLOT)); .child(Stack::new(layers).width(row_w).height(pill::slot()));
// clicks next to the pills reach the windows underneath // 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))]; 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 // 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 { 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))); pills.push(Box::new(tray.translate(0.0, tray_slide)));
} }
@@ -193,9 +223,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
Rectangle::new() Rectangle::new()
.width(Parent) .width(Parent)
.height(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) .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 +264,6 @@ fn hovering(id: i64, inside: bool) {
hover hover
.0 .0
.entry(id) .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 }); .to(if inside { 1.0 } else { 0.0 });
} }