The Trust Pass: Autosave, Async, and a Progress Bar
Until this session, Lux had a handful of behaviours that gave it away as a tool still under construction rather than one you’d trust with a real project. Loading an image froze the UI for however long the decode took. Exporting a sequence froze the UI for however long the whole render took. The window title never told you whether you had unsaved changes. There was no autosave. If the app crashed, your patch was gone.
This was a session of fixing all of that. The theme, if there is one, is trust: the set of small behaviours that make the difference between a demo you show off and a tool you actually use.
The title bar tells you
The window title now reports the current project state. If you have a file open, it shows Lux, yourfile.lux. If the graph has unsaved changes, it appends an asterisk: Lux, yourfile.lux *. Save the project and the asterisk goes away. Edit something and it comes back.
The dirty tracking rides on top of the undo manager, which already knows about every mutation that’s happened since the last save. A mutation_count gets bumped on every recorded action, a saved snapshot value tracks where it was the last time the project hit disk, and if the two differ, the title shows the asterisk. Simple, cheap, always correct.
Autosave
Every 60 seconds, if the graph is dirty, Lux writes the current state to ~/.config/lux/recovery.lux (or the platform-appropriate equivalent on macOS and Windows, via the dirs crate). If the app crashes, or your laptop dies, or you quit without saving, the next launch finds the recovery file and asks whether you want to restore it.
60 seconds is the sweet spot I’ve landed on. Shorter and the autosave writes feel noticeable on large projects. Longer and you risk losing meaningful work when something goes wrong. The autosave path is separate from whatever file you’re actually working on, so it can’t accidentally clobber your real project.
Combined with the dirty indicator, the practical effect is that you stop having to think about saving. The title tells you when you’re behind. The autosave catches you when you forget. Ctrl+S still exists, still works, but it’s a deliberate action now instead of a constant low-grade anxiety.
Recent files
The file menu (well, the Ctrl+O flow) now remembers the last ten projects you opened. The list persists to ~/.config/lux/recent_files.json, gets updated on every save and every open, and survives across launches. No more hunting through your filesystem every time you want to pick up where you left off yesterday.
Nothing blocks the frame
The async story is where the session got the most interesting.
LoadImage used to decode its file synchronously during process(). For a 4K PNG, that’s hundreds of milliseconds of blocking while the image crate chews through the bytes, runs the PNG decoder, and converts to RGBA8. The whole app froze for the duration. Drop a big image into a patch and the UI locked up.
Now LoadImage decodes on a background thread. Change the path pin and the node spawns a worker via an mpsc channel, which hands back the decoded pixels when it’s done. Until then, the node keeps outputting the previous texture (or nothing, on first load). The main thread never blocks, the graph keeps evaluating, the UI stays responsive. When the decode finishes, the new texture pops in on the next frame.
LoadImageSequence got the same treatment, with an extra trick: it pre-loads the next two adjacent frames in the background while the current frame is displaying. At 30fps playback of a 1080p PNG sequence, that’s enough lead time that you never see a missing frame, even on slower disks. The pixel data is shared via Arc<Vec<u8>> so passing frames around the system doesn’t clone the bytes, only the refcount.
Sequence export was the worst offender. Ctrl+Shift+E would lock the app for the entire duration of the export. Want to render 150 frames? Enjoy staring at a frozen window for however long that takes. Hit Escape to cancel? You can’t, the event loop isn’t running.
The export pipeline is now a proper state machine. Each tick of the main loop processes exactly one frame of the export, updates a progress bar overlaid on the canvas, and returns control to the event loop. You can see what frame it’s on. You can see a percentage. You can press Escape to cancel halfway through. You can resize the window while it’s running. The app never stops being an app just because you asked it to render something expensive.
Before starting the export, an ExportConfig dialog lets you pick the framerate and duration instead of the old hardcoded 30fps for 5 seconds. Want a 10-second loop at 60fps? Type the numbers and go. The settings get remembered for next time.
GPU noise, GPU solid colour
Two sources used to do their work on the CPU and hand the results to the GPU. Both moved fully into shaders this session.
NoiseTexture was using the noise crate to generate Perlin, Simplex, and Worley noise one pixel at a time on the CPU, then uploading the whole buffer as a texture. For a 512x512 noise field, that’s a quarter of a million noise evaluations plus a texture upload, every time an input changed. I caught it using real CPU time on any patch that tweaked the scale or seed live.
Now all three noise algorithms are WGSL fragment shaders. The node dispatches a fullscreen triangle, the shader samples the noise function per-pixel in parallel, and the GPU writes the result straight into the output texture. Zero CPU work, zero uploads, and the noise crate got removed from lux-texture-source entirely.
The Perlin and Simplex shaders are standard implementations, the kind you can find in any procedural-graphics textbook. Worley (cellular noise) was the interesting one, it scatters feature points across a tiled grid and the shader computes the distance to the nearest neighbour. All three support scale, seed, and the familiar parameter set from the old CPU version, so existing patches just get a massive speedup without needing to change anything.
SolidColor had a similar shape of problem. The CPU code was building a width * height * 4 byte vector filled with the chosen colour and uploading it as a texture. For a 1080p solid fill, that’s 8.3 megabytes of memset-and-upload every time the colour picker moved. Totally absurd for a flat colour.
Replaced with a one-line fragment shader that outputs the colour as a uniform. No byte vector, no upload, no CPU allocation at all. The whole operation is now a single shader dispatch, and the texture handle stays pinned instead of bouncing.
Inspector sliders
A small one that makes a big difference. Number and Int pins in the inspector used to always render as DragValue widgets, tiny text boxes you drag to change the value. That’s fine for unbounded numbers, but lots of pins have meaningful ranges: a radius pin that runs 0 to 500, an amount pin that runs 0 to 1, an opacity that runs 0 to 100.
When a pin has both a min and a max declared, the inspector now renders a real egui::Slider instead of a DragValue. You get visual feedback on where the current value sits in the range, you can click anywhere on the bar to jump, and the drag interaction is smooth across the full range instead of requiring repeated pixel-precise drags. Pins without bounds keep the old DragValue, since a slider with no endpoints is nonsense.
One correctness fix
While hardening the compound undo path for copy/paste, I found that redo was broken for compound steps that included both node additions and wire connections. The bug: redo was iterating the sub-actions in reverse order (mirroring the way undo unwinds them), which meant it tried to re-create the connections before the nodes they connected to existed yet. The reconnection would silently fail, and you’d end up with the nodes back but the wires missing.
The fix is to iterate in forward order on redo, the reverse of undo. AddNode actions run first, then Connect actions can find their endpoints, and everything re-appears as it should. Added a compound_paste_undo_redo_with_wires test that creates three nodes, connects them with two wires, deletes them as a selection, undoes, redoes, and asserts that all three nodes and both wires come back exactly as they were.
The kind of bug that only shows up when you combine two features that were written months apart.
The shape of a real tool
None of this is a feature you’d put in a changelog headline. “Autosave.” “Background image loading.” “Progress bar on export.” These aren’t things anyone brags about. They’re also exactly the things that decide whether you trust a tool with a project that matters.
Before this session, using Lux for serious work meant remembering to save constantly, avoiding big images, not exporting sequences until you were ready to walk away from your desk, and accepting that a crash meant losing your patch. After this session, none of that is true. The title bar watches your back, the autosave catches the mistakes, the async loads keep the UI alive, and the progress bar means you can actually see what the export is doing.
Lux is starting to behave like a tool you can forget you’re using. Which, for a creative coding environment, is the highest compliment I can think of.