Design Decisions
Two ratcn behaviors draw questions from anyone evaluating the library seriously: declaration mistakes panic, and scopes must promise their focusable descendants up front. Neither is an accident or a shortcut. This page records what each decision buys, what it costs, and which alternatives were rejected.
Fail Loud: Panics as the Validation Channel
Declaration mistakes panic. Duplicate sibling ids, an ordinary root declaration or root paint after a modal, children declared without composite(), a descendant promise that does not match the declarations, modal root ids that differ from the bound ModalState — all of these unwind the render pass instead of returning an error value.
The reasoning starts from what these conditions are. Every one of them is a bug in the app's declaration code, not a runtime condition the app could meaningfully handle. There is no sensible recovery branch for "two children share an id": the fix is editing the declaration, not matching on an error. Rust draws the same line elsewhere — out-of-bounds indexing and RefCell double-borrows panic for the same reason.
A Result channel was rejected on two grounds:
- Declaration is immediate: components paint as they are declared. By the time a duplicate id is detectable, earlier pixels are already in the frame. An error return could not undo the paint, so it would report a corrupted frame while appearing recoverable.
- Fallible declaration calls invite
?andlet _ =. A bug that can be silently propagated or discarded will be, and the failure would surface later as misrouted events — far from its cause. A panic surfaces at the exact call site, in the first frame that exercises the mistake.
What a panic guarantees
The pass runs under unwind protection, and surface replacement is the last step of a successful render. A panic anywhere in declaration, component paint, validation or deferred paint therefore leaves the previous successful interaction surface fully intact. Events keep routing through the last good declaration; a subsequent successful render replaces it normally. Writes already made to the Ratatui Frame are immediate and are not rolled back, so a failed frame may show partial pixels — but it never becomes a partially routable surface. There is no state in which half of a declaration receives events.
Hosts that want to keep running through a declaration bug can catch the unwind around the draw call and keep dispatching events; the retained surface makes that safe. See Rendering and Event Routing for the full timing contract.
The same stance, without panics
The refusal to guess extends past validation. A focused component that becomes unfocusable stays parked and can still receive the focused render signal; built-in disabled controls ignore input, and Tab moves on rather than being silently retargeted to a "nearby" control. A focus path that matches nothing stays parked the same way, on both the paint side and the routing side. Silent repair was rejected because any fallback applied by one side and not the other produces the worst failure mode this architecture can have: pixels that disagree with routing. Parking is visible and recoverable; guessing is neither.
A semantic modal bound with Ratcn::modals is an explicit focus boundary, not a silent repair. While it is open, focus input outside the top modal is aligned to that modal root so focus paint and routing are confined from the beginning of the frame. Parked paths within the top modal remain exact.
The Descendant Promise
A scope that will declare at least one structurally focusable descendant must say so before its children exist: ScopeOptions::focusable_descendants() on a scope, Dialog::focusable_descendants() for custom dialog children, scope_options() for a composite component. After the scope's declarations complete, the runtime validates the promise in both directions — promised-but-absent and present-but-unpromised each fail the pass. A focusable declaration with zero geometry still fulfills this structural contract, but does not participate in traversal or pointer interaction for that surface.
This exists because rendering is single-pass. A parent paints before its children are declared, and its paint may depend on whether the focus path can descend into it: startup focus resolution, focus-within highlighting, and a freshly opened modal claiming focus all need the answer does this scope contain anything focusable? before the children run. In a single immediate pass, that fact can come from only two places: a declaration made ahead of time, or a guess.
Three alternatives were rejected:
- A second pass. Declare everything, learn the tree, then paint. This doubles per-frame work and breaks the identity between declaration and paint that makes the model simple — declaration closures could no longer paint immediately, and every side effect inside them would run twice or need deferral.
- Deferred focus paint. Paint components first, patch focus decoration afterward. Focus styling is not an overlay: a focused border changes what a component draws, not what is drawn on top of it. Patching would require re-rendering the affected components — a second pass by another name.
- Deriving the answer from the previous frame. Accurate except when it matters: the first frame, and any frame where focusability changed. Both would produce a one-frame window where focus paints somewhere events will not route — precisely the silent divergence the engine is built to exclude.
An upfront promise is the remaining option, and the two-way validation is what keeps its structural claim honest. A promise that overstates focusability means focus was resolved on a declaration the frame did not fulfill; one that understates it means the scope was skipped while containing focus candidates. Either mismatch fails the pass. Geometry can still collapse a valid candidate after its parent has painted in this single pass. The successful surface therefore retains both the modal-aligned focus input used for the frame and the focus path actually used to paint it. While that focus input is unchanged, event routing reuses the painted path instead of independently choosing another participating control.
The cost, and how to keep it low
The promise is real API surface, and a state-dependent promise duplicates knowledge: a scope whose children are all disabled together must derive its promise from that same state. The way to keep the two from drifting is to compute them from one place —
let interactive = !state.controls_disabled;
let options = if interactive {
ScopeOptions::default().focusable_descendants()
} else {
ScopeOptions::default()
};
// The same flag drives each child's disabled state below.— so the promise and the children's focusability cannot disagree without disagreeing in the source. Library components carry their own promise in scope_options(), so the cost falls only on hand-built scopes whose focusability varies at runtime. Dialog resolves its standard actions itself and combines them with the custom-children promise; only custom content and footer children need declaring, applying the promise conditionally in the state-dependent case.
The promise is validated on every pass, so a drift bug cannot ship silently: it fails the first frame that exercises it, with the scope's id in the message.