Architecture
Noir implements Flutter-inspired widgets, elements, and integer-cell layout in Dart, then uses OpenTUI’s native renderer for terminal output. It separates what an application declares from the state and native resources the framework must preserve. That separation lets a callback change one value while keeping the terminal session.
Widgets declare. Elements preserve identity. RenderObjects layout and record paint. The compositor talks to OpenTUI. The native layer owns FFI, memory, ABI, and binaries.
This page is for readers who extend the framework, host it, or need to know which layer owns a problem. To build an application, start with Your first app.
Follow one increment through Noir
Start with the counter’s increment method:
void _increment() => setState(() => _count++);That call crosses the framework in a defined order:
- The event changes State
_incrementchanges_countand marks its element dirty. - The Element preserves identity
The existing State stays attached while type, key, and position still match.
- Widgets declare the next tree
buildreturns immutable configuration containing the new count. - RenderObjects resolve the frame
Layout and paint recording run from the render root; constraints become cell sizes, positions, and hit-test geometry.
- The compositor reaches OpenTUI
Display lists update buffers; the native layer owns FFI handles and memory.
Rebuilding is scoped to the dirty elements. Layout and paint recording are not: a dirty frame walks the render tree from its root. That is a deliberate, recorded trade-off, not an optimization the framework promises to make later.
Identity is separate from configuration
A StatefulWidget is replaceable configuration. Its element owns the stable
place in the tree, and that place retains the State object. If a rebuild puts
the same widget type and key at the same position, Noir updates the
configuration and keeps the state. During a successful replacement, Noir
deactivates the old element and mounts the new one. At the end of the build
pass, it unmounts inactive elements and disposes their state. The replacement’s
initState can therefore run before the old state’s dispose.
Inherited dependencies also belong to the element tree. When an
InheritedWidget reports a meaningful update, the dependent elements rebuild
without application code maintaining a second subscription graph.
Hot reload uses the same boundary. The Dart VM swaps compatible code, Noir
calls reassemble on retained state, then rebuilds, lays out, and paints. It
does not rerun main() or initState().
Paint and FFI stay below widgets
A widget never calls FFI directly. A custom render object lays out and hit
tests in its own layer, then records paint through PaintingContext; it does
not take a Buffer as its normal paint surface. The compositor is the bridge
that applies those recordings to OpenTUI buffers.
Explicit lifecycle methods remain the primary resource owner. Finalizers can
limit damage after a missed cleanup, but they do not replace deterministic
dispose or native handle release.
Where to go next
- API — the four package surfaces and what each one accepts responsibility for.
- State, identity, and ownership — the same boundary, from an application’s point of view.
- Platform support — what the native layer and the reader’s terminal decide.
- GOALS.md — the durable architecture and quality bar this page summarizes.
- CONTRIBUTING.md — repository setup, the framework test harnesses, and drive mode.