Architecture deep dive
The architecture overview gives the map. This page goes into the machinery: why Fleury keeps several retained trees, what each tree owns, how a state change becomes changed cells, and where terminal/browser targets begin.
Fleury is not an ANSI string builder with widgets on top. It is a retained UI
engine for a cell grid. Every visual target consumes the same framework output:
a CellBuffer with changes derived from the previous frame. Semantics are a parallel product of the same
mounted tree, projected or shipped by tests, browser hosts, served sessions,
agents, and debug tooling. That distinction is deliberate: the terminal
renderer should not need an accessibility tree to write ANSI, and the browser
should not need to scrape visual rows to be accessible.
The short version
Section titled “The short version”- Widgets are cheap configuration values.
- Elements hold identity, state, dependencies, and the dirty build queue.
- Render objects lay out integer cells and paint styled graphemes into a
CellBuffer. - Semantics describe meaning, state, geometry, and actions for tests, accessibility, and agents.
- Hosts own platform concerns: terminal drivers, browser DOM surfaces, input sources, frame scheduling, clipboard, sockets, and focus integration.
That split is the main architectural decision. It gives app authors a Flutter- style programming model, while letting Fleury optimize for terminal realities: cell widths, ANSI bytes, scroll reuse, terminal capabilities, browser cell metrics, and machine-readable semantics.
The retained trees
Section titled “The retained trees”Widget tree: configuration
Section titled “Widget tree: configuration”Widgets are immutable descriptions of the UI. A rebuild creates new widget objects freely; the framework decides whether the mounted tree underneath can be reused.
The compatibility rule is intentionally small: a new widget can update an old
one in place when runtimeType and Key match. That preserves the element,
state, and render object below it. Fleury also has an internal
WidgetUpdatePruner hook for widgets that can prove a new configuration is
equivalent, so the reconciler can skip work without changing the public mental
model.
This is why keys matter. A local key tells the parent which child identity should
survive. A GlobalKey can move a subtree to a different parent in the same build
pass while preserving its Element, State, and render subtree.
Element tree: identity and state
Section titled “Element tree: identity and state”Elements are the durable spine. State.setState mutates app state immediately,
then marks the owning element dirty. The BuildOwner keeps a dirty set, sorts it
shallow-first, rebuilds only those elements, and finalizes any deactivated
subtrees that were not reclaimed by a global-key move.
That shallow-first rule matters: if a parent rebuild replaces a child subtree, the framework should not also spend time rebuilding a now-discarded child. If new dirt appears during a rebuild, the owner picks it up in the next flush pass.
The element tree also owns dependency and lifecycle behavior: mounted vs
deactivated vs unmounted, didUpdateWidget, didChangeDependencies, hot reload
reassemble, and the bridge from widgets to render objects.
Render tree: cell layout and paint
Section titled “Render tree: cell layout and paint”Render objects implement the constraints-down, sizes-up protocol using integer
cell geometry. A parent calls layout(CellConstraints), the child returns a
CellSize, and the parent later paints the child at an absolute CellOffset.
There is no pixel canvas in the core; paint writes cells.
The render layer has two invalidation paths:
markNeedsLayoutis for anything that can affect size, child constraints, child offsets, or layout-derived paint.markNeedsPaintOnlyis for audited visual-only changes such as color, cursor blink, and style.
Both paths request a frame and dirty every enclosing repaint boundary. Fleury keeps invalidation conservative by default. A layout-affecting change must recompute size and placement; a paint-only change can reuse valid layout. For a visual frame, painting starts at the root and fills a cleared back buffer. Culling and repaint caches can skip work inside that traversal. The presenter gets changes derived by comparing the finished buffers, including cells that disappeared or moved.
RenderRepaintBoundary is also deliberately different from a Flutter layer. It
is a CPU paint cache for a subtree: on a clean frame it blits cached cells into
the next frame buffer instead of re-walking the subtree. It is not a GPU layer,
and it is not a blanket performance answer. It is useful for paint-expensive
subtrees that change rarely.
Where a render object sits on screen is derived from layout state, never
recorded during paint. Every container declares where it put each child
(childOffsetOf), what it clips it to (childClipOf), and whether it presents
it (presentsChild); RenderObject.screenGeometry() composes those up the
parent chain, memoized per invalidation epoch. Pointer hit-testing walks the
tree with that contract, focus rectangles and carets are getters over it,
semantic bounds derive from it at collection and are re-derived when a paint
pass ends, and BoundsAnchor reads the observed widget’s live geometry. Paint
itself is a non-virtual template — paint(buffer, offset) checks each child’s
placement against the contract in debug mode and delegates to
performPaint — so every painted frame of every test verifies the two agree.
See RFC 0024.
CellBuffer: frame truth and derived damage
Section titled “CellBuffer: frame truth and derived damage”CellBuffer is the frame image: a two-dimensional grid where each cell is a
grapheme plus style and role metadata. It enforces wide-grapheme invariants, so
writing over one half of a wide character repairs the neighboring cell instead
of leaving an impossible grid.
For each visual frame, the frame loop clears the back buffer, paints the next
image, and calls diffAgainst(previous) to derive changed rows, bounds, and cell
counts. A layout change that removes content is therefore visible in the diff
even when no render object paints over the old location. The comparison also
covers inline image placements, which live beside the grid rather than in its
cells.
The frame buffers do not record per-write paint damage. Repaint boundaries still use that tracking inside their own caches to identify the cells they painted. Presentation damage comes from comparing complete frame images, so it does not need a fallback for missing or unsafe paint hints.
Semantics tree: meaning and actions
Section titled “Semantics tree: meaning and actions”The semantics tree is a typed model of what the UI means. A SemanticNode
contains an id, role, label, value, state, optional bounds, supported actions,
and child nodes. Tests query it. The browser target projects it into an
accessible DOM. Served sessions ship it over the wire next to visual frames.
The current producer can still rebuild a snapshot by walking the element tree, but the model is intentionally delta-ready:
SemanticsOwnerretains the last snapshot and reports added, removed, and updated node ids.SemanticDirtyTrackerrecords full-rebuild dirt or retained leaf updates perBuildOwner.- Retained leaf replacement is checked against a full semantic rebuild in debug mode, so a missed escalation becomes a loud divergence instead of a silent accessibility bug.
- Semantic actions can be invoked back into the mounted element tree, which is how tests and the served browser accessibility tree drive the real app.
That is why semantics are documented as architecture, not a testing add-on. They are one of the retained products of the framework. They are not globally incremental yet: leaf-only updates can take the retained path, while structural changes intentionally escalate to a full semantic rebuild.
One update through the engine
Section titled “One update through the engine”Here is the visual path for a normal state change:
- An input event, timer, animation tick, stream update, or semantic action changes state.
setStateor a render-object setter marks the relevant element or render object dirty.- The host
FrameSchedulercoalesces pending frame requests. With the defaultDuration.zerointerval it flushes as soon as possible; hosts may opt into a minimum frame interval to merge high-rate streams. TuiFrameLoopclears the back buffer for the next image. On a full repaint it also blanks the shown buffer it will compare against, because the presenter is about to wipe the screen.- Through the frame loop’s paint callback,
BuildOwner.renderFramerebuilds dirty elements shallow-first, finds the root render object, runs layout with loose root constraints, and paints into that buffer. - The frame loop compares the previous and next buffers, optionally detects a
beneficial scroll, and returns both buffers with a
TuiFrameDamage:FrameUnchanged,FrameChanged(the exact changed rows and bounds),FrameScrolled, orFrameFullRepaint. A full repaint happens on the first frame, after a resize, and when the host forces one, such as after a suspended terminal resumes or when an error screen replaces a frame that failed to render. - A presenter turns that into output:
- The terminal target calls
AnsiRenderer.renderDiff(previous, next, ...)with the frame’s damage: the changed rectangle bounds the comparison, and a scroll can become a terminal scroll. - The embedded browser target builds a
FramePresentationPlanand replaces only dirty retained DOM rows. - The served browser target encodes a binary plan, the browser client applies it to its mirror buffer, then uses the same retained DOM presenter.
- The terminal target calls
- The frame loop commits the next buffer as the new visible buffer only after presentation consumes it.
- Semantic presentation runs on the semantic path. Browser hosts can defer it outside the visual frame budget, coalesce several frames, and still force a flush before dispatching a semantic action.
Idle is a first-class case. If the buffer pool is warm, no element has scheduled build work, and no render object recorded visual change, hosts can skip build/layout/paint/present entirely.
The target seam
Section titled “The target seam”The public seam is split across two libraries:
package:fleury/fleury_core.dartexports the platform-neutral framework: widgets, elements, render objects, cell model, semantics, focus, animation, and related primitives.package:fleury/fleury_host.dartre-exports the core plus host-facing runtime contracts:TuiRuntime,TuiFrameLoop,FrameScheduler,FramePresentationPlanner,RenderDamageTracker, semantic ownership, and shared scroll detection.
Both are dart:io-free. Terminal setup, POSIX/Windows drivers, native process
work, external editors, filesystem access, and runApp live above that seam in
the native umbrella. Browser hosts import the host SPI instead of the native
umbrella so the same app code can compile with dart2js.
The seam is not only about imports. A host must provide the platform facts the core cannot know:
- viewport size and resize events
- cell metrics in the browser
- input source and focus handoff
- clipboard implementation
- terminal capabilities or browser surface capabilities
- frame scheduling policy
- visual surface and semantic presenter
This is why page layout can affect an embedded Fleury widget. The widget tree owns UI behavior, but the browser host still depends on the containing element having real size, correct cell metrics, and unobstructed input/focus routing.
Terminal target
Section titled “Terminal target”The terminal host owns a native TerminalDriver, input parsing, raw mode,
alternate screen setup, terminal capability detection, and ANSI output. It uses
the shared frame loop to get previous/next buffers, then diffs them through
AnsiRenderer.
When a frame requires a full repaint, the presenter clears its owned terminal area and redraws it by diffing against the blanked previous buffer. Otherwise it passes the changed rectangle to the renderer, which compares only the cells inside it; an unchanged frame writes no cells. Layout changes use the same buffer comparison as paint-only changes; they do not require a separate fallback diff.
Scroll is handled as an optimization over buffers, not as a special list API.
Shared scroll-up detection looks for a row shift that reduces residual dirty
cells; the frame loop runs it once per frame, only when at least a row’s worth
of cells changed, and reports a hit as FrameScrolled. The ANSI renderer can
emit a terminal scroll and then rewrite what the shift did not fix; the DOM
presenter can move retained row elements. Keeping that detection shared
prevents the targets from learning different ideas of what a scroll frame is.
Embedded browser target
Section titled “Embedded browser target”mountApp runs the app itself in the browser. The DOM host creates a
TuiRuntime, measures cell geometry, dispatches browser input as Fleury events,
builds frame presentation plans, and paints a retained DOM grid.
The retained visual grid is intentionally separate from semantics. The grid is
aria-hidden and optimized for visual cell fidelity. The semantic presenter owns
the accessible DOM projection beside it. That split lets the visual surface use
row/span replacement while the semantic surface exposes roles, labels, values,
states, bounds, and actions.
Browser semantics are deferred from the visual frame by default. The host keeps the last presented buffer and dirty coverage rows so it can patch text fallback and semantic DOM state without lengthening the critical paint path. If an assistive technology or agent activates a semantic node, the host flushes pending semantics first so the peer’s view is current — the shared FrameSemanticsPipeline enforces this on the browser and serve paths alike — then dispatch resolves against a fresh tree built from the live root (never stale). Both paths return the invocation status to the peer.
Served browser target
Section titled “Served browser target”fleury serve keeps the app running as a native Dart process. The browser is a
thin client. The native side renders frames and sends structured protocol
frames; the browser side keeps a mirror CellBuffer, applies remote plans, and
presents the same retained DOM grid as the embedded host.
The protocol carries more than pixels:
PLANframes encode changed cells, style tables, scroll hints, inline image placements, and full-repaint state.SEMANTICSframes carry full semantic snapshots or patches.INPUT_EVENTframes carry structured browser input back to the app.SEMANTIC_ACTIONframes let the browser’s accessible DOM invoke actions on the live tree.
This is different from streaming xterm output into a page. The browser client does not parse ANSI to infer UI state; it receives the frame plan and semantic model Fleury already produced.
Correctness machinery
Section titled “Correctness machinery”Incremental systems are easy to make fast and wrong. Fleury uses explicit oracles around the places drift would be subtle:
- Renderer equivalence: ANSI diff output must reproduce the same visible buffer as a full repaint.
- Frame presentation tests: derived changed rows and bounds, full repaints, and scroll residual rows are tested directly.
- Transport parity: a server-produced frame must survive the wire and reconstruct the same client mirror buffer, including scroll and overlay cases.
- DOM parity: the retained DOM rendered from a remote mirror must match the intended cell output.
- Semantic divergence checks: retained semantic leaf updates are asserted against full semantic tree rebuilds in debug mode.
- Public boundary tests: web-safe libraries stay behind the host SPI instead of accidentally importing native runtime code.
The important principle is that bounded diffs, retained DOM, and semantic deltas are optimization paths. The tests keep them equivalent to the simpler full-buffer or full-tree truth.
Tradeoffs and pressure points
Section titled “Tradeoffs and pressure points”The architecture is optimized for app-grade, semantic, cross-target TUIs. It is not a claim that Fleury has the smallest possible runtime for one-off CLIs, the fastest possible large-grid browser renderer, or a free incremental path for every tree mutation. These are the pressure points worth keeping visible.
Damage is derived, not reported
Section titled “Damage is derived, not reported”Fleury does not ask widgets or render objects to declare which cells they changed. Reported damage is only as good as its least careful writer: a write nobody declares, or a cell that content stops covering, stays stale on screen. Comparing complete frames is ground truth, so nothing upstream can under-report. The comparison is exact in the other direction too: a subtree that repaints identical cells produces no damage, so presenters touch only rows whose cells differ. The price is one pass over every cell per rendered frame, linear in the grid size.
Invalidation still matters
Section titled “Invalidation still matters”Buffer comparison catches content that moved or disappeared, but it cannot repair an incorrectly reused layout or a stale repaint cache. A setter that can move children or change size must invalidate layout. Paint-only invalidation is for changes that preserve geometry. Repaint caches must also invalidate when their subtree changes; the debug cache verifier compares a cache hit with a fresh paint to catch stale reuse.
Full buffers remain the correctness boundary
Section titled “Full buffers remain the correctness boundary”CellBuffer remains the frame truth. Dirty rectangles, dirty rows, scroll hints,
and semantic patches are acceleration data around that truth, not replacements
for it. Fleury has already tested broader ideas such as public dirty-span buffer
handoff and style-aware same-row gap encoding; they were correct but neutral or
slower on the measured workloads.
The current conclusion is narrow: keep the full-buffer diff contract, continue private renderer/output-path improvements where measurements point, and avoid adding public damage metadata until a benchmark shows it unlocks real wins.
Repaint boundaries are not magic
Section titled “Repaint boundaries are not magic”A repaint boundary avoids re-walking expensive paint code, but it still has to copy cells into the next frame, and the frame loop still compares them. It is a tool for stable, expensive paint subtrees, not a default wrapper for every component.
Semantics are retained, but not fully incremental
Section titled “Semantics are retained, but not fully incremental”The semantic pipeline has retained owners and leaf-update paths, but it escalates to full rebuilds for structural ambiguity and fallback-bearing cases. That is intentional. Assistive technology and agents need correct meaning more than they need the smallest possible semantic patch.
The pressure point is large semantic trees with frequent structural churn. If that becomes a real workload, the fix is deeper retained semantic production and clearer dirty-source attribution, not weakening the correctness oracle.
DOM is the default backend, not a forever bet
Section titled “DOM is the default backend, not a forever bet”The embedded and served browser paths currently present a retained DOM grid plus a separate semantic DOM. That is the right default for compatibility, cell-sized viewports, browser accessibility, and developer ergonomics. It is not a claim that DOM will always beat canvas or WebGL on raw large-grid throughput.
The visual surface is intentionally behind FrameSurface: input, clipboard,
metrics, scheduling, remote protocol, and semantics live outside the DOM grid.
If a future canvas or WebGL renderer earns its keep, it should reuse those host
contracts instead of forcing a framework rewrite.
Browser layout remains a host contract
Section titled “Browser layout remains a host contract”Fleury can own the cell grid only after the host gives it reliable geometry. The DOM host has to measure cell width/height, snap rows, observe resizes, sync caret geometry, and keep the input target available. A bad containing layout can starve the surface of size or intercept events even though the Fleury widget tree is correct.
Flutter instincts need translation
Section titled “Flutter instincts need translation”Fleury borrows Flutter’s retained model, but terminal performance has different failure modes. Moving text by scroll detection can beat rebuilding keyed rows. Writing fewer ANSI bytes can matter more than minimizing object churn. A cell-grid renderer has to respect grapheme widths and terminal capability fallbacks in ways a pixel renderer does not.
Data-heavy widgets need data architecture too
Section titled “Data-heavy widgets need data architecture too”When a workload builds a huge search index or eagerly mounts a very large data shape, the renderer is not automatically the bottleneck. The retained render pipeline can make visible rows cheap, but filtering, indexing, and lazy data source boundaries still belong to the widget or model layer. Treat those costs as data-architecture pressure before proposing a render-tree rewrite.
The runtime floor is accepted
Section titled “The runtime floor is accepted”Fleury is pure Dart so the same core can run natively and compile to JavaScript. That keeps the target story simple and preserves the browser path, but it means Dart’s startup and memory floor are part of the product envelope. The architecture focuses on making Fleury’s own retained work proportional to the change, not on winning every tiny native-memory or cold-start comparison.
Where to read the code
Section titled “Where to read the code”| Area | Files |
|---|---|
| Widget, element, state, reconciliation | packages/fleury/lib/src/widgets/framework.dart |
| Render objects and invalidation | packages/fleury/lib/src/rendering/render_object.dart |
| Basic render objects | packages/fleury/lib/src/rendering/render_objects.dart |
| Repaint-boundary cache | packages/fleury/lib/src/rendering/render_repaint_boundary.dart |
| Cell frame and buffer diff | packages/fleury/lib/src/rendering/cell_buffer.dart |
| Shared scroll detection | packages/fleury/lib/src/rendering/scroll_detection.dart |
| Runtime owner | packages/fleury/lib/src/runtime/tui_runtime.dart |
| Frame buffer lifecycle | packages/fleury/lib/src/runtime/tui_frame_loop.dart |
| Row-oriented presentation planning | packages/fleury/lib/src/runtime/frame_presentation.dart |
| Native terminal host | packages/fleury/lib/src/runtime/run_app.dart |
| Host SPI exports | packages/fleury/lib/fleury_host.dart |
| Semantic model and actions | packages/fleury/lib/src/semantics/semantics.dart |
| Retained semantic owner | packages/fleury/lib/src/semantics/semantics_owner.dart |
| Embedded web host | packages/fleury_web/lib/src/run_tui_surface.dart |
| Retained DOM grid | packages/fleury_web/lib/src/dom_grid/dom_grid_surface.dart |
| Served browser client | packages/fleury_web/web/remote_client.dart |
| Remote protocol | packages/fleury/lib/src/remote/remote_protocol.dart |
For the neighboring public docs, read Core and targets for the package/import split, Serving and embedding for the two browser paths, Built for agents for the semantic graph, and Performance for the benchmark contract.