11 KiB
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
masteronly 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|outputcall or volume change, runamane ipc call island statusand make sureplayer=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
amaneand the Rust standard library:amane compilegeneratesCargo.tomland no other crates can be added. External tools run as processes (std::process::Command,amane::output). - Amane docs:
/root/amane/docs/src/*.mdon 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 <its stream> | 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, key = value settings and #rrggbb. Unit-tested on its own. |
theme.rs |
Theme Service, 500 ms poll |
The shell's 10 colours from ~/.config/amane/theme (rendered by Skwd-wall from matugen/amane.colors) and shell.conf (cover_colors, transition_ms), faded together on one Animation<Colors>. Every view starts with theme::begin() (reads Theme, so it redraws on a change; returns cover_colors) and draws with theme::now(). No theme file: the original pink on purple. |
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). Draws with theme::now(), reuses the island's backdrop() and HOVER_MS. Redraws only on Hyprland events and hover. |
The equalizer: one setsid -f pipewire -c eq-<key>.conf process per player with libpipewire-module-filter-chain, 10 biquads (low shelf 31 Hz, peaking 63 Hz–8 kHz, high shelf 16 kHz). The player's streams are moved into sink magic_island_eq.<key>; gains change live with pw-cli set-param magic_island_eq.<key> Props '{ params = [ "b1:Gain" 6.0 ... ] }'. A player only gets one when its curve or output is first changed, or it has a saved non-flat curve.
The view (island.rs view)
- One
LayerWindowper monitor (window_per_monitor, namespacemagic-island,Layer::Overlaybecause the user's windows are fullscreen) sized for the open card; anInputArealets 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, returnwindow.click_through().child(Rectangle::new().width(0.0).height(0.0)). - All windows share the
Islandstate.opened_onnames the monitor the card is open on (others show their pill),pointer_onthe monitor whose pill is hovered. Only the focused monitor's window (and the one with the card) readsSpectrum, 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 fromWorkspaces, which follows the mouse on Hyprland. Spectrum::listenfirstpkill -KILL -fs any cava running ourcava.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 alpha1 - showninstead of.opacity(): same picture, no GPU layer.backdrop_colors()/backdrop(): one 150° three-stop gradient from the palette (dominant,colors()[1],background()), each darkened bydeepen()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 frommirrored()(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 fpsandanim 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
Pathper 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::childholds one child; stack layers withStack.
Develop
-
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):[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 clippyin that crate. (It is at/root/.hermes/cache/scratch/amane-check/on the dev host.) -
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:
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 nextuntilstatusnames it, before anything that changes sound. - The island follows the focused monitor: read its geometry from
hyprctl layers -j(namespacemagic-island) right before eachgrimcapture. - With one window per monitor,
fpsandanimcount 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, whichamane-remote.shnow picks. A shell started with a dead signature gets an emptyWorkspaceslist. - 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
statusand capture right afteropen. - To tune a visualiser, sample the real levels read-only (
pw-record --target <stream> | cava, withtimeout -s KILLon each stage; a plaintimeouton a pipeline orphans cava) and look at the distribution first. Typical music sits at 0.15–0.35. - In
pkill -fpatterns over SSH, use[p]ython3 ...so the pattern doesn't match the remote shell itself.
After testing
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".