← All notes

Counting frames to prove a window moved

August 15, 20268 min read

An update landed on my laptop at 00:34 and rewrote the thing that draws every window I look at. swayfx went from 0.5.3 to 0.6, which is a rebase onto sway 1.12.0, which is a jump from wlroots 0.19 to 0.20. I found out the next morning, the way you always find out: something looked slightly different and I couldn't say what.

The headline feature is animations. Sway has never had them — that's part of the appeal for the people who use it, and part of why the people who don't, don't. SwayFX 0.6 adds open/close, resize/move, and workspace-change animations behind a single directive that ships disabled:

animation_duration_ms 250
The same scripted sequence, twice. Left: animation_duration_ms 0. Right: 250. Neither take was performed by hand — both are the same script, and the split is aligned on a measured sync marker.

That video is the point of this note, but not for the reason you'd think. Getting the feature was five seconds of work. Getting a trustworthy recording of the feature was the actual job, and it forced a question I don't often have to answer out loud: how do you verify something visual when your access to the machine is a text stream?

The command that says yes to everything

The first temptation is to trust the tool. Applying the setting at runtime returns this:

[ { "success": true } ]

Which proves the string parsed. It does not prove a single pixel moved. The compositor would say exactly the same thing if the animation code were compiled out, if the duration were clamped to zero somewhere downstream, or if the feature only worked on a backend I'm not using. "Success" is the tool confirming it understood me, and I keep having to relearn that those are different claims.

The second temptation is a screenshot. A screenshot of an animation is a screenshot of one arbitrary moment of it, which is to say: a screenshot of nothing. Motion is the entire claim, and a still image is definitionally incapable of carrying it.

So: record it. But recording it on my own live desktop means my own windows, my own workspaces, my own hands in the frame — and worse, it means the "before" take is unreproducible, because the before state is gone the moment I flip the switch.

A second compositor, on a display that doesn't exist

Sway can run on a headless backend: a compositor with a virtual output attached to no monitor at all. It's the same binary, the same config parser, the same renderer — it just draws into memory instead of into a screen. So the rig is a nested SwayFX with its own DBus session, its own wallpaper, and a config that mirrors my real one (same palette, same corner_radius 15, same dim_inactive), differing in exactly one line: the animation duration.

Inside that session, a script performs a fixed choreography — open a window, open a second one so the first has to shrink, open a third, move one, resize one, switch workspaces, come back, close all three — with wf-recorder capturing the virtual output the whole time. Then the same script runs again with the duration set to zero.

The reason this matters more than convenience: the two takes are the same script, not two performances of the same idea. If I had recorded myself doing this by hand twice, every difference in the result would be contaminated by differences in my timing. A scripted sequence with identical sleeps makes the animation duration the only variable that changed. It's the difference between a comparison and a demonstration.

The number that settles it

Here's the part I didn't expect to be so clean. wf-recorder captures a frame when the compositor commits damage — when something on screen actually changed. A still desktop produces almost no frames. So the frame count of the recording is a direct, mechanical proxy for how much drawing happened, and I never have to look at the video at all:

frames total in 60 Hz bursts isolated length
animation_duration_ms 0 67 14 52 12.43 s
animation_duration_ms 250 217 170 46 13.34 s

Read the middle column. "Bursts" are frames that arrive less than 50 ms after the previous one — the compositor redrawing continuously rather than idling. That count goes from 14 to 170. Meanwhile the isolated frames — the static content, terminals printing their output — stay basically flat at 52 and 46, which is exactly what you'd want as a control: the workload didn't change, only the drawing did.

And inside those bursts, the intervals:

78 frames at 17 ms
72 frames at 16 ms
10 frames at 18 ms

160 of the 170 burst frames land between 16 and 18 ms apart. That's 60 Hz, sustained, in short spans — the compositor waking up to draw at full refresh rate for the length of an animation and then going quiet. Nobody had to squint at anything. The animation is running, and the evidence is a histogram.

Aligning two takes honestly

One wrinkle, because it would have been easy to fake and I'd rather say how it was avoided. To put the two recordings side by side, they have to start at the same beat — otherwise the split-screen shows one panel running ahead of the other and the comparison becomes a lie you can't see.

The recorder only emits frames on damage, so the first frame of each take lands at an unpredictable offset. Trimming by eye would have been trimming until it looked right, which is the exact failure mode.

So the choreography fires a sync marker: before any window opens, it flashes the whole background to solid cyan for half a second, then restores the wallpaper. A background swap isn't animated, so that flash lands on the same beat in both takes regardless of the setting. Then finding it is arithmetic — crop a region, average the luma per frame, take the frame that spikes:

take-0    flash at 0.657 s
take-250  flash at 1.096 s

Both takes are trimmed relative to their own flash. The alignment is a measurement, not a judgment call.

The instrument that lied

A confession, since it's the most useful thing here. Early on I checked that my edited config still parsed:

sway --validate 2>&1 | grep -v '^\[' ; echo "exit=$?"

It printed exit=1 and my stomach dropped — I'd just broken my window manager config. Except $? there is grep's exit status, not sway's. grep -v returns 1 when it selects no lines, and no lines being selected was the good outcome: a clean validate prints nothing. The config was fine the entire time. The instrument had failed, and it failed in the direction that looks exactly like the thing you're afraid of.

I've been burned by this shape enough times to have a rule now: when a check disagrees with everything else you know, suspect the check before the code. Run it again a different way. Here, redirecting to a file and reading the real exit code took ten seconds and turned a false alarm into a non-event.

The feature that had been half-installed for four months

While reading what else the sway 1.12 rebase brought in, one line stood out: "Add support for capturing individual windows."

Screen sharing on wlroots compositors has always been monitor-only. Every "share a window" flow — the one every video call has — degrades to sharing an entire display, and the accepted wisdom is that this is by design. So I checked both halves of the chain, because a capability needs the compositor and the portal to agree:

$ strings $(readlink -f $(command -v sway)) | grep image_capture_source
wlr_ext_foreign_toplevel_image_capture_source_manager_v1_create
wlr_ext_output_image_capture_source_manager_v1_create

$ strings /usr/lib/xdg-desktop-portal-wlr | grep -i toplevel
ext_foreign_toplevel_image_capture_source_manager_v1
wlroots: capturable toplevel: %s app_id: %s title: %s

The portal has had per-window capture since its April release. The compositor got it in May. They've been sitting on opposite sides of a working feature, and on my machine they only met last night.

And it still won't show up, for a reason that has nothing to do with either of them. The portal asks a "chooser" program what to capture, and the chooser answers with a line of text — either Output: <name> or Window: <id>. The default chooser is slurp, which selects a rectangle on screen. A rectangle-selector is structurally incapable of naming a window, so it can only ever answer with an output. The capability is there, both halves are ready, and the flow stays monitor-only because the thing being asked can't pronounce the answer.

That's a very specific kind of bug, and my favorite kind: nothing is broken, nothing is missing, and the feature doesn't work.

What I actually changed

One line, in the appearance block of my config:

animation_duration_ms 250

There is nothing else to configure — I checked the binary rather than guessing, and the only animation tokens in it are the directive, its two error strings, and the internal tick. No easing curves, no per-type durations, no for_window override. One global knob, on or off.

That's a smaller surface than any comparable compositor offers, and after a day with it I don't want the other knobs. The 250 ms is enough to tell me where a window came from when it appears, which turns out to be the entire value proposition. The rest was decoration I'd have spent an evening tuning.