Previewing a Scenic UI on the host, with no screen and no board

An hour spent trying to open a window that was never going to be drawn, then the realisation that a Scenic graph is just data. Walking it and rendering to SVG found two layout bugs in a minute that had survived 49 tests and a review.

I spent an hour today trying to look at a window that was, it turns out, never going to be drawn. The fix was not a fix. It was realising I did not need the window at all.

The setup

Last time I worked out that Scenic renders through cairo in software, straight into /dev/fb0, and that the GPU on this board is irrelevant. On a desktop the same driver uses cairo-gtk instead and opens a normal window, which means the same UI code runs on my Mac and on the panel. That is the whole point of the host preview: tweak a colour, restart, look — two seconds instead of a firmware build and a flash.

So I wrote the UI, started the app, and looked at my screen.

Nothing.

The process was alive. The driver logged its startup line with the right window title and the right dimensions. The port process was running. And the screen was empty.

Chasing it properly

The temptation here is to start changing things. Try a different backend. Reinstall GTK. Add a delay. I have wasted entire evenings that way.

Instead: does the window exist? That is a question with an actual answer, because macOS will tell you. A dozen lines of Swift against CGWindowListCopyWindowInfo:

let list = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] ?? []
for w in list {
let owner = (w[kCGWindowOwnerName as String] as? String) ?? ""
guard owner.lowercased().contains("scenic") else { continue }
print(w[kCGWindowNumber as String] ?? 0, w[kCGWindowBounds as String] ?? [:])
}
39584 800x512 layer=0 owner=scenic_driver_local

There it is. 800x512 — my 800x480 plus a title bar. Real window, correct size, registered with the WindowServer, layer 0. Nothing wrong with it.

So why can I not see it? Ask for a screenshot of exactly that window:

$ screencapture -x -o -l 39584 out.png
could not create image from window

And then, listing what is on screen, the answer:

39578 owner=loginwindow
39579 owner=loginwindow
38993 owner=Control Center

loginwindow. Confirmed directly:

$ ioreg -n Root -d1 -a | grep -A1 CGSSessionScreenIsLocked
<key>CGSSessionScreenIsLocked</key>
<true/>

The screen was locked. macOS does not composite application windows while the session is locked — the window exists, it just has no backing store, which is exactly why screencapture could not produce an image from it either.

Mundane. But worth the ten minutes, because the alternative was an evening of reinstalling GTK to fix a problem that was not GTK.

The lesson I actually needed

Here is the thing I had been getting wrong, and it is more embarrassing than the locked screen.

I had been treating this as proof the UI was working:

[info] Scenic.Driver.Local: start: [..., window: [title: "DK2 Pomodoro"]]

plus a live port process in ps. Driver started, process alive, no crash — therefore it is on screen, right?

No. Every one of those facts is entirely compatible with a window that exists and is never painted. "The process is running" is not "the user can see it." I had been asserting something I had not checked, which is a bad habit in general and a worse one when the whole point of the exercise is looking at pixels.

Not needing the screen at all

Then the useful realisation. A Scenic graph is not a drawing. It is data:

graph = Scene.build_graph(state, vitals)
1 Rectangle id= data={800, 480}
styles: %{fill: {:color, {:color_rgba, {22, 22, 26, 255}}}}
5 Group id= data=[2 children]
transforms: %{translate: {230, 250}, rotate: -1.5707963267948966}
6 Circle id= data=150
styles: %{stroke: {18, {:color, {:color_rgba, {46, 46, 54, 255}}}}}
7 Arc id=progress_arc data={150, 3.8955748904513436}
styles: %{stroke: {18, {:color, {:color_rgba, {229, 75, 75, 255}}}}}
8 Text id=time_text data="15:30"
transforms: %{translate: {230, 282}}
styles: %{font: :roboto_mono, font_size: 96, text_align: :center}

Every coordinate, every colour, every transform, sitting in a map. The driver's job is to turn that into pixels — but it is not the only thing that can. SVG has rects, circles, arcs, text, groups and transforms. It is close enough to a one-to-one mapping that the translation is mostly mechanical:

defp render(%{module: Scenic.Primitive.Arc} = p) do
{r, angle} = p.data
x = r * :math.cos(angle)
y = r * :math.sin(angle)
large = if angle > :math.pi(), do: 1, else: 0
~s|<path d="M #{r} 0 A #{r} #{r} 0 #{large} 1 #{x} #{y}" fill="none" #{stroke(p.styles)}#{xform(p.transforms)}/>|
end

Walk the graph from the root, recurse into groups under their transforms, emit an element per primitive. Then headless Chrome turns the SVG into a PNG. No display, no driver, no board, no window server. It works over SSH. It works on a locked machine — which is how I got the picture in the end.

The important property is that it reads the actual graph. My first attempt at this transcribed the coordinates by hand into an SVG file, which worked exactly once and would have silently rotted the moment I changed the layout. Walking graph.primitives cannot drift from the code, because it is the code's output.

It found two bugs in about a minute

I did not build this expecting to find anything. I built it because I wanted to see the thing. Both of these were sitting in code that compiled cleanly, passed 49 tests, and had been through review:

The countdown did not fit inside the ring. Roboto Mono has a 0.6em advance width. At font_size: 96, five characters is 5 × 57.6 = 288px. The ring's inner diameter is 2 × (150 - 18/2) = 282px. So "25:00" — the default idle state, the very first thing anyone sees — overlapped the ring stroke. That is arithmetic I simply never did, and no test would ever have caught it, because the test asserts the text says "25:00" and it does.

The finished state had a dead button. Timer hits zero, the screen offers DONE and NEXT. NEXT advances the phase. DONE does nothing whatsoever — the toggle is a no-op in that state. A 330x88 touch target that silently ignores you. On a desk toy that is annoying; on a control panel it is the kind of thing that makes someone tap harder and conclude the hardware is broken.

The fix was to stop rendering the toggle button in that state entirely and move NEXT up into its slot, so the finished screen offers exactly one action and no hole in the layout. One state, one thing to do.

Neither defect is subtle once you look. Both are invisible if you only read.

Keep it

The script lives in the repo now as mix preview.ui — the SVG logic in a Preview module, a thin Mix task on top, one HTML file per state dropped into preview/. The one subtlety worth knowing: the task must not start the application. mix run boots the supervision tree, which starts Scenic, which tries to open a window, which hangs forever on a headless or locked machine. Running only compile keeps it usable in exactly the situations it was built for.

It has become the fastest loop on the project — faster than the host window it was standing in for, because it renders every state at once instead of making me click into each one.

That last part is the bit I did not anticipate. Being able to see idle, running, finished and break side by side is genuinely more useful than a live window, where I would have to drive the timer to zero to check what the finished screen looks like. The break screen I might not have looked at for another week.

It is a layout check, not a pixel-accurate proof — cairo rasterises glyphs, the browser rasterises glyphs, and they will not agree to the pixel. Geometry is exact; type is approximate. For catching "the digits do not fit" and "that button does nothing", exact geometry is all you need.

Still no pixels on the actual panel. That is next: flashing the board, finding out whether orientation: :left rotates the framebuffer or just the window hint, and persuading a Goodix touchscreen to agree with a landscape layout.


Next: first light. Flashing v1.4.0, the rotation problem, and calibrating a touchscreen that disagrees with your coordinate system.