# AGENTS.md Notes for agents continuing work on the Magic Island shell. Read README.md first for what it does and how it's laid out. ## Ground rules from the user - **Branch per big change, and ask before merging.** Create a new branch for each big change, show the result, and merge into `master` only after the user explicitly approves. Small follow-ups the user asks for in the same breath can go on the same branch. - **Never touch the user's real players' sound.** Cider and Firefox are real; their volume, equalizer and output are the user's. Before any `island preset|output` call or volume change, run `amane ipc call island status` and make sure `player=` is a fake test player. Test audio goes to inaudible null sinks (see Testing). - **The desktop is reached only by `ssh -o BatchMode=yes fabian@192.168.2.169`** (Hyprland, Intel iGPU, monitors DP-2 and eDP-1). Don't probe the network, SSH keys or other hosts; if SSH fails, report it and wait. - **Leave the desktop clean:** stop fake players and null sinks after testing, and restart the shell without frame logging (see "After testing"). - Be honest in summaries about what was and wasn't checked on the real desktop. ## Constraints of Amane itself - The shell can only depend on `amane` and the Rust standard library: `amane compile` generates `Cargo.toml` and no other crates can be added. External tools run as processes (`std::process::Command`, `amane::output`). - Amane docs: `/root/amane/docs/src/*.md` on the dev host; library source in `/root/amane/src/`. - Amane redraws the whole window every frame, so every effect in the view is paid at the frame rate. ## Architecture | Piece | Kind | Job | |---|---|---| | `island.rs` `Island` | input-only Service | `open: Animation` (0 pill, 1 card), `expanded`, `selected` player (bus name, `None` follows the playing one), open dropdown, time-bar scrub. Also the view and the `island` IPC handler (`command`). | | `sound.rs` `Sound` | Service, 1 s poll | `pactl list sinks` + `sink-inputs` → each player's streams (matched by process ancestry, then app name), volume, mute, output, equalizer. Curves saved by player identity to `~/.config/amane/island-eq`. | | `spectrum.rs` `Spectrum` | Service, 200 ms check | While the shown player plays: `pw-record --target \| cava -p $XDG_RUNTIME_DIR/magic-island/cava.conf` → 24 levels. Stops when it pauses. | | `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. | | `parse.rs` | plain functions, no amane | pactl/cava/proc parsing, filter-chain config, `pw-cli` params, EQ store, middle ellipsis. Unit-tested on its own. | | `workspaces.rs` | view + input-only `Hover` Service | Workspace pill per monitor (namespace `magic-workspaces`, top left): `Workspaces` filtered by `output() == monitor.name`, special workspaces skipped, click = `hyprctl dispatch 'hl.dsp.focus({ workspace = N })'` (Hyprland 0.56 with a Lua config rejects the `dispatch workspace N` that amane's `Workspaces::focus` sends). Reuses the island's `pub` colours, `backdrop()` and `HOVER_MS`. Redraws only on Hyprland events and hover. | The equalizer: one `setsid -f pipewire -c eq-.conf` process per player with `libpipewire-module-filter-chain`, 10 biquads (low shelf 31 Hz, peaking 63 Hz–8 kHz, high shelf 16 kHz). The player's streams are moved into sink `magic_island_eq.`; gains change live with `pw-cli set-param magic_island_eq. Props '{ params = [ "b1:Gain" 6.0 ... ] }'`. A player only gets one when its curve or output is first changed, or it has a saved non-flat curve. ### The 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. - **Never `visible(false)`.** Hyprland 0.56 configures a layer surface before its first commit, so a window that starts hidden gets a size, Amane logs "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))`. - All windows share the `Island` state. `opened_on` names the monitor the card is open on (others show their pill), `pointer_on` the monitor whose pill is hovered. Only the focused monitor's window (and the one with the card) reads `Spectrum`, so the others don't redraw for every cava frame: with music, pill ~11% of a core and open card ~21%, as with one island. `focused_monitor()` comes from `Workspaces`, which follows the mouse on Hyprland. - `Spectrum::listen` first `pkill -KILL -f`s any cava running our `cava.conf`: a stopped shell leaves its cava behind, and cava ignores SIGTERM. - `phases(open)` splits the one linear 560 ms timeline: pill fades out over 0–0.18, the empty shape grows over 0–0.6 (cubic ease-out), the card fades in over 0.6–1 (smoothstep). Closing runs it backwards. At most one face (pill or card) is built per frame. - `veiled()` fades a face by laying the backdrop gradient over it with alpha `1 - shown` instead of `.opacity()`: same picture, no GPU layer. - `backdrop_colors()` / `backdrop()`: one 150° three-stop gradient from the palette (dominant, `colors()[1]`, `background()`), each darkened by `deepen()` to a max brightness of 0.22 / 0.15 / 0.07. - `pill_levels()`: 7 bars, bar *i* = loudest of spectrum slice *i* of 7, × 1.4, linear, drawn up and down from the middle line. - `ring()`: 40 candles around the CD, levels from `mirrored()` (bass at the top, highs down both sides), `powf(0.75)`. Drawn as 3 paths (glow, body, flame tips). - Moving the pointer off the island collapses it (`on_hover`). ## Performance rules (measured on the Intel iGPU) Current numbers: card open with music 60 fps, about 3.1 ms GPU per frame; pill with music 60 fps, about 1.7 ms GPU, about 9% of one core for shell + cava; paused, about 1 frame a second. Opening has 1 frame over 20 ms (where the card first appears), closing 0. - **Measure before and after every visual change** with `tools/amane-remote.sh HOST fps` and `anim island`, baseline first. - Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in. - `.clip()` and `.opacity(<1)` each add a GPU layer. A clip over the whole card once took the iGPU from 47 to 9 fps. - Gradients are expensive in numbers: a gradient background plus 10 gradient slider fills halved the frame rate; the single backdrop gradient is affordable. - A veil over a gradient must be the same gradient at the same size as the shape (inset 1 px for the rim): Amane places a gradient by each rectangle's own bounds, so a smaller veil shows patches. - Blurred shadows cost every frame: the drop shadow only exists on the pill and during the animation. - Many shapes cost: 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 only got about 11 redraws a second from Hyprland. - `Rectangle::child` holds one child; stack layers with `Stack`. ## Develop 1. Type-check on the dev host without the desktop. A scratch crate outside the repo (don't commit a `Cargo.toml`, Amane generates its own): ```toml [package] name = "amane-shell" version = "0.1.0" edition = "2024" [[bin]] name = "amane-shell" path = '/root/amane-config/src/main.rs' [dependencies] amane = { path = '/root/amane' } [workspace] ``` Then `cargo clippy` in that crate. (It is at `/root/.hermes/cache/scratch/amane-check/` on the dev host.) 2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (5 tests). ## Testing on the desktop `tools/amane-remote.sh HOST COMMAND`; the full command list is in the comment at the top of the script. The main ones: ```sh H=fabian@192.168.2.169 tools/amane-remote.sh $H deploy src # back up the remote src once, then replace it tools/amane-remote.sh $H build # amane compile tools/amane-remote.sh $H start # runs the shell WITH frame logging (AMANE_FRAMES=1) tools/amane-remote.sh $H fps 3 # frames, average gap, gpu wait tools/amane-remote.sh $H anim island # open/close 3x, every frame gap tools/amane-remote.sh $H shot magic-island out.png tools/amane-remote.sh $H sinks start # null sinks island_test_a/_b tools/amane-remote.sh $H players start 4 # fake MPRIS players (tools/fake_mpris.py), "Synth Test" makes real sound 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 ``` - Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound. - The island follows the focused monitor: read its geometry from `hyprctl layers -j` (namespace `magic-island`) right before each `grim` capture. - With one window per monitor, `fps` and `anim` count every window's frames together; resting windows add ~1000 ms gaps (one frame a second for the time bar). Those aren't stalls. - The desktop's `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead Hyprland instances; the live one is the one with `.socket.sock`, which `amane-remote.sh` now picks. A shell started with a dead signature gets an empty `Workspaces` list. - Once, a fresh shell exited with "lost the gpu (Device is lost)" (Mesa warns the Xe driver is experimental); starting it again worked. - The user's pointer may be over the island and open or collapse it during a test; check `status` and capture right after `open`. - To tune a visualiser, sample the real levels read-only (`pw-record --target | cava`, with `timeout -s KILL` on each stage; a plain `timeout` on a pipeline orphans cava) and look at the distribution first. Typical music sits at 0.15–0.35. - In `pkill -f` patterns over SSH, use `[p]ython3 ...` so the pattern doesn't match the remote shell itself. ### After testing ```sh tools/amane-remote.sh $H players stop tools/amane-remote.sh $H sinks stop tools/amane-remote.sh $H sh 'kill $(cat shell.pid); sleep 0.5; rm -f shell.log setsid -f sh -c "echo \$\$ > shell.pid; exec ~/.cache/amane/project/target/debug/amane-shell" > /dev/null 2>&1' ``` The desktop's `amane` CLI is older than the docs: no `~/.cache/amane/amane-shell` is saved and `amane run` doesn't apply, so the shell runs from `~/.cache/amane/project/target/debug/amane-shell`. ## Open threads - The candle ring is still mirrored (`mirrored()`); the user had the pill bars unmirrored and may want the same for the ring. - See README "Known limits".