Breaking Up LuxApp

LuxApp in app.rs had grown into a 2,200-line struct. It held the graph, the window, the render state, the texture engine, the editor UI state, the undo manager, the autosave timer, the search index, the welcome modal, the command palette, the output window, the profiler, the preferences, the sequence export state, the file dialog state, and a handful of pending_* fields used as makeshift action queues. Every method was either a handle_* on an input event or a render_* that painted something. Nothing was isolated from anything.

It was working. It was also the reason the graph engine rewrite couldn’t land cleanly. Every algorithmic change to the evaluator forced a change in app.rs because app.rs was wired directly to internals. The app decomposition was, in many ways, the prerequisite for the evaluator changes — it’s here so the code has somewhere to go.

This post is the seven-commit refactor that made LuxApp livable again. No new features. No algorithmic changes. Just structure.

The symptom

The canonical diagnostic: open app.rs in the editor, jump to LuxApp::new, scroll. Twelve screens later, you reach LuxApp::drop. In between: one giant struct, twenty methods, six event handlers, four render methods, and about eighteen fields that were state smears from whatever feature I’d last shipped.

The real diagnostic: the codebase had four different staging mechanisms for deferred actions. pending_palette_action, pending_menu_action, pending_welcome_choice, pending_output_window. Each one was “a modal UI populated this value, and I can’t apply it inside the egui closure because of borrow rules, so I’m stashing it and applying it after the closure returns.” Four separate fields that all did the same thing. Zero shared logic. Four places to forget to drain.

Nothing about this was dangerous. Nothing about it was also right. Refactoring it was step one.

EditorAction

The consolidating abstraction is a two-tier action enum:

pub enum ImmediateAction {
    OpenWelcome,
    CloseWelcome,
    ToggleBrowser,
    ToggleInspector,
    ToggleOutput,
    // ... all the view/UI toggles
}

pub enum UndoableAction {
    AddNode { type_name: String, position: Vec2 },
    RemoveNodes { ids: Vec<NodeId> },
    Connect { src: PinRef, dst: PinRef },
    SetPinValue { node: NodeId, pin: PinId, value: PinValue },
    // ... every mutation the user can undo
}

pub enum EditorAction {
    Immediate(ImmediateAction),
    Undoable(UndoableAction),
}

Two tiers. Immediate actions don’t touch the graph; they toggle panels, open dialogs, change view state. Undoable actions mutate the graph and go through the undo manager.

Every UI entry point — menu items, keyboard shortcuts, command palette, welcome cards, F-key handlers — produces an EditorAction and pushes it onto a single queue. A single drain_actions() method at the end of the frame pops every action in order and calls either apply_immediate (simple match + mutation) or apply_undoable (through the undo manager’s recorder).

The four pending_* fields get unified. Every UI entry point becomes a one-liner: “build an action, push it.” The action dispatcher is the one place that knows what every action does.

This is where I’d been wanting to get for months and hadn’t. The reason it’s easier now than it was a year ago is that the PinId work means action payloads carry integer pin IDs instead of strings, so the serialized form of SetPinValue is small and cheap. And the slot-map layout means NodeId references in actions stay valid across frames, so the queue can defer actions without worrying about stale keys.

The seven modules

With the action queue as the central spine, the rest of LuxApp slices cleanly into responsibilities. The seven commits extract seven modules:

1. InputRouter

Everything mouse- and keyboard-related. handle_cursor_moved, handle_mouse_button, handle_key, the input state tracking, the modifier tracking, the double-tap detection. Becomes a InputRouter that takes winit events in and pushes EditorActions out.

The input router doesn’t know about the graph, doesn’t know about the renderer, doesn’t know about the UI. It knows about input events and shortcut tables. ~450 lines. Testable in isolation — feed it events, read the action queue, assert.

2. FrameScheduler

The timing side. Instant::now() bookkeeping, target frame time, the profiler from the last block, the tripwire logger, hitch detection. Becomes a FrameScheduler that knows when the next frame should start and whether the current frame’s budget was exceeded.

Owns the FrameProfiler and the PerfGuard (which gets more interesting in the PerfGuard post). ~320 lines. Pure timing logic; no graph, no UI.

3. ProjectDocument

The graph and its state. The Graph, the UndoManager, the current .lux file path, the dirty flag, the autosave timer, the serialization path. Becomes a ProjectDocument — think of it as the “model” in MVC if MVC meant anything in Rust.

Every mutation to the graph goes through document.apply(undoable_action). The document tracks dirtiness based on whether any undoable has been applied since the last save. The autosave timer lives here because it watches the dirty flag. ~380 lines.

4. PresentationLayer

The render stack. RenderState, egui_winit::State, the output-window handle, the TextureEngine. Becomes a PresentationLayer that owns the GPU device, the surfaces, and the texture execution.

This is the module that later gets wired to the multi-window fleet and the HDR toggle and the encode queue — all three depend on PresentationLayer being a coherent thing rather than fields smeared across LuxApp. ~420 lines.

5. EditorChrome

The editor UI. The welcome modal, the cheatsheet, the command palette, the dialogs, the recent-files list, the preferences, the search index, the sequence export UI. Everything that’s egui-facing but isn’t the canvas itself.

Why “chrome” and not “UI”? Because the canvas (nodes, wires, zoom, pan) is its own thing and doesn’t live here; the canvas is its own module that the chrome renders around. Chrome means “the frame around the window” — menu bar, panels, modals. ~650 lines.

6. FrameLoop

The actual render_frame method. Takes PresentationLayer, ProjectDocument, EditorChrome, and the frame’s input events, runs one iteration: evaluate the graph, execute texture ops, build the egui frame, submit the wgpu command encoder, present. The single method is ~150 lines; everything it calls is in the other modules.

This is the method the manifesto doc flagged as needing to fit under 600 lines total. It’s currently 140. The ~460 lines of headroom are deliberate — the multi-window work and the GPU profiler timestamp work both add a small amount of per-frame bookkeeping that’ll slot in here.

7. Panels as free functions

The last commit wasn’t structural — it was cosmetic. The dialogs and progress overlays used to be methods on LuxApp, which meant they borrowed &mut self wholesale. After the decomposition, only the Chrome owns the dialog state, so the render methods become free functions: render_export_dialog(&mut chrome, ui), render_progress_overlay(&mut chrome, ui).

Free functions don’t get the whole struct’s borrow; they take the specific fields they need. In practice this matters because egui’s immediate-mode API likes to hold closures that capture references, and it’s easier to reason about which references are captured when the function’s parameter list is explicit.

The egui_dock scaffold

The last commit in the decomposition series added egui_dock = "0.19" to the dependency list, with a stub integration that hasn’t been fully wired yet. egui_dock is the library that turns floating panels into real docked panels — think Blender’s editor, where every panel can be split, tabbed, resized, and rearranged.

Lux’s current layout is fixed: canvas in the middle, optional browser on the left, optional inspector on the right, optional output in various modes. Users have asked for “can I put the output on the bottom” and “can I have two inspector panels side-by-side” more than once. egui_dock is the answer.

The scaffold is just a DockState field on EditorChrome with the default layout, plus a commented-out integration that replaces the panel rendering. The reason it’s stubbed rather than fully wired is that the existing panels have layout assumptions baked in (the canvas assumes a specific rectangle, not an egui_dock-supplied one) and unwinding them is non-trivial. I’m going to land the dock in a future session as its own feature, but the dependency and scaffold are here now so the rest of the decomposition can assume the interfaces.

I think this is the right call. I also might be premature with the dependency; if the dock integration doesn’t land for another six months, having the dep in place is silly. But docking panels is something every serious creative tool has, and saying “yes, it’s coming” in the form of a real dependency feels better than saying it in a comment.

Tests moved to integration

One refactor I’d been dreading: app.rs had a #[cfg(test)] mod tests at the bottom with ~30 tests that exercised LuxApp directly, calling its private methods with private fields. After the decomposition, half of those methods were gone and the other half lived in different modules.

The migration path is to move the tests to tests/ at the crate root, which Rust treats as integration tests — compiled as separate crates, only able to see the crate’s pub surface. That forced me to expose LuxApp::new_headless as a public constructor (it was private before), along with a few other methods that the tests genuinely needed. The side effect is that the real API surface is cleaner now: if the integration tests can’t reach a method, no third party will either, so I don’t have to pretend it’s an API.

Removing ~700 lines of #[cfg(test)] mod tests from app.rs felt great.

What it adds up to

app.rs went from 2,200 lines to about 400. The seven new modules sum to about 2,400 lines because a decomposition isn’t free — every module has its own imports, own public surface, own tests. But every module is small, testable, and responsible for one thing.

The action queue is the central spine. Every input path lands there, every drain loop processes from there. There are no more pending_* fields; there is a queue and a drainer.

None of this changes the app’s observable behaviour. Same features, same performance, same tests. Structural only.

And it’s the thing that lets Pearce-Kelly land cleanly, because the algorithmic change to the graph connects through ProjectDocument::apply(UndoableAction::Connect) which goes to a single well-defined method on the graph, which calls the new Pearce-Kelly incremental topo. Before the refactor, that same change would have touched app.rs in three places.


I have no idea what I’m doing or if any of this is right, but it’s fun. Follow along.

← Back to devlog