Architecture
This chapter explains how Wijjit is organized under the hood so contributors can reason about changes confidently. Each subsection calls out the major modules involved and the responsibilities they own.
Runtime overview
High-level flow:
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ Templates (Jinja) │────▶ │ Layout Engine │────▶ │ Rendering Pipeline │
└────────┬───────────┘ └────────┬───────────┘ └────────┬───────────┘
│ │ │
│ state / data context │ bounds & elements │ ANSI cells
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌────────────────┐
│ Wijjit │◀──────────────│ Event │◀──────────────│ Terminal (PTK) │
│ core │ actions/keys │ Loop │ input events │ Screen/Mouse │
└──────────┘ └──────────┘ └────────────────┘
App construction –
wijjit.core.app.Wijjitwires together the renderer, layout engine, state, event registry, overlay manager, focus/hover managers, mouse router, notification manager, and terminal adapters. Arguments such astemplate_dirandinitial_stateare stored on the instance.View registration –
@app.viewstores lazywijjit.core.view_router.ViewConfigobjects insidewijjit.core.view_router.ViewRouter. A view returns awijjit.core.templating.RenderedView(viawijjit.render_template_string()/wijjit.render_template()) carrying an inline template or atemplate_fileplus its context; lifecycle hooks are declared on the decorator. Synchronous views are re-invoked every render so derived context stays live.Event loop –
app.run()delegates towijjit.core.event_loop.EventLoopwhich switches to the alternate screen, hides the cursor, enables mouse mode, renders the default view, and enters the main async loop.Frame processing – every iteration reads input (keyboard/mouse), dispatches events via
wijjit.core.events.HandlerRegistry, updates notifications/overlays, and triggers a render ifapp.needs_renderor the dirty region manager requests it.Shutdown – when
app.quit()orCtrl+Cfires, the loop unwinds: overlays close, handlers are cleared, the cursor is restored, mouse tracking and raw mode are disabled, and the screen buffer exits alternate mode. Kill paths that bypass this unwind (SIGTERM/SIGHUP, or an abnormalatexit) are covered bywijjit.terminal.cleanup.TerminalCleanup, a last-resort net that runs the same terminal restore and then chains to the previous signal disposition so the process still exits normally.
Module map
The src/wijjit tree is intentionally segmented:
core/– orchestration (app, event loop, events, state, focus, hover, mouse router, overlay manager, notification manager, wiring).layout/– size math and container primitives (bounds, engine, frames, scroll, dirty tracking).elements/– UI widgets.base.pydefinesElement; subpackagesinput/anddisplay/implement concrete components plus modals/menus.tags/– Jinja extensions mapping template tags to element/layout nodes.rendering/– paint context and ANSI adapters bridging Rich / prompt-toolkit.terminal/– input handling, screen buffer, ANSI utilities.styling/– theme + style resolver, plus the CSS parser.inline/– non-alternate-screen output (render_inline,InlineApp).autocomplete/– suggestion popup for text inputs and the code editor.devtools/– static analysis behind the CLI (validator, VNode-tree dump, LLM briefing,wijjit newscaffolding).testing/– the headlessWijjitHarness,app_from_template, and thepytest11plugin.plugins.py– the public third-party element seam (register_element+wijjit.pluginsentry-point discovery).cli.py/config.py/helpers.py– thewijjitconsole script, Flask-style config, and shared utilities.
Renderer & layout pipeline
Template render –
wijjit.core.renderer.Rendererconfigures a Jinja environment with the custom tags fromwijjit.tags.Wijjit._renderevaluates the view, then callsRenderer.render_with_layout(orrender_stringfor a tagless template). The template context is the view’s keyword arguments flattened to top level, plus an auto-injectedstate;paramsand a wrappingdataobject are not placed in the context.VNode tree – tags such as
{% vstack %},{% frame %}, and{% button %}do not build elements directly; they emit immutablewijjit.core.vdom.VNodedescriptions via aVNodeBuilder. The template render therefore produces a VNode tree, not an element tree.Reconcile –
wijjit.core.reconciler.Reconcilerdiffs the new VNode tree against the previous one and creates/updates/replaces/deletes the corresponding statefulwijjit.elements.base.Elementobjects, reusing existing elements (and their ephemeral UI state) where possible. The reconciler also wires the resulting elements into the layout tree (wijjit.layout.engine).Constraint pass –
wijjit.layout.engine.LayoutNode.calculate_constraints()recursively computes minimum/preferred sizes based on element content and width/height specs. Scrollable frames consultwijjit.layout.scrollto measure overflow.Assign bounds –
assign_boundswalks top-down assigning concreteBoundsrectangles, respecting padding/margin/spacing rules.Painting – each element’s
render_towrites towijjit.rendering.paint_context.PaintContext, which wraps awijjit.terminal.screen_buffer.ScreenBuffer. Styles are resolved viawijjit.styling.resolver.StyleResolver.Terminal flush –
wijjit.terminal.screen.ScreenManagerdiffs the buffer against the previous frame and writes ANSI commands to the alternate screen for flicker-free updates.
Because the flush is a diff against the last displayed buffer, anything written to the terminal out-of-band – a foreign library’s stdout, a subprocess, a stray traceback – is invisible to the differ and lingers on screen until a full redraw. For that reason Wijjit never prints to the shared TTY mid-frame: wijjit.core.app.Wijjit._handle_error() buffers non-fatal error tracebacks while the alternate screen is active and flushes them to stderr only after the terminal is restored on exit (fatal errors propagate and print once on the normal screen). To evict foreign bytes, wijjit.core.app.Wijjit.request_full_repaint() discards the cached buffer so the next frame is a complete repaint, and the opt-in FULL_REPAINT_INTERVAL config drives that on a heartbeat. The heartbeat is disabled by default so the diff renderer’s byte savings are preserved unless requested.
Virtual DOM & Reconciliation
Wijjit uses a React-style Virtual DOM for efficient UI updates. This system lives in wijjit.core.vdom and wijjit.core.reconciler.
VNode (wijjit.core.vdom.VNode)
Immutable description of what the UI should look like:
type– Element type name (e.g., “TextInput”, “Button”, “VStack”)key– Stable identity for list reconciliationprops– Immutable properties tuplechildren– Child VNodeslayout_spec– Layout configuration (width, height, margin, etc.)
VNodes are frozen dataclasses, ensuring reliable comparison during diffing.
VNodeBuilder (wijjit.core.vdom.VNodeBuilder)
Mutable builder used during template execution. Template tags call methods like add_child() and set_prop(). At the end of rendering, call freeze() to convert to an immutable VNode tree.
Reconciler (wijjit.core.reconciler.Reconciler)
Compares old and new VNode trees and efficiently updates the Element tree:
Diff phase – Compares trees, produces
DiffResultwith changesPatch phase – Creates/updates/deletes elements based on diff
Ephemeral state preservation – Cursor, scroll, selection state survives re-renders
Diff types (wijjit.core.reconciler.DiffType):
CREATE– New element neededDELETE– Element removedUPDATE– Props changed, reuse elementREPLACE– Type changed, recreate element
Key-based reconciliation matches elements by key prop for stable identity in lists. Elements with matching keys are considered the “same” and will be updated rather than replaced.
Keying elements inside {% for %} loops
By default an element’s reconciliation key is its id, and an element with no explicit id gets an auto-generated positional one (textinput_0, textinput_1, …). Inside a {% for %} loop that means each row’s identity is its position, not the data it represents. Insert a row at the head of the list and every element shifts down one slot, so the reconciler reuses the element that used to sit there — silently migrating that element’s state (a text input’s typed value and cursor, a tree’s scroll and selection) to the wrong logical row. Because the positional id is also the state-binding key, a bound input’s value migrates too.
Give looped elements a stable key to fix this:
{% for row in rows %}
{% textinput key=row.id placeholder=row.label %}{% endtextinput %}
{% endfor %}
key is a first-class attribute, separate from id: it sets reconciliation identity so each row keeps its state across inserts and reorders. For an input, when no explicit id is given, the key also derives a stable per-row state id (textinput_<key>) so the bound value stays with the row as well — you do not have to hand-manage an id. Passing an explicit stable id (e.g. id="row_" ~ row.id) works too and is equivalent for keying purposes.
wijjit validate flags a stateful element inside a loop that has neither key nor id with an unkeyed-loop-element warning. Stateless elements (charts, {% text %}, spinners, progress bars) are repainted from props each render, so positional reuse is invisible and no key is needed.
Thread-safe RenderContext
wijjit.core.render_context.RenderContext provides thread-safe, reentrant state management during template processing. It replaces the previous pattern of storing state in Jinja2’s environment.globals, which was not thread-safe.
The context holds:
layout_context– LayoutContext for building VNode treestemplate_context– Template variables including ‘state’focused_id– Currently focused element IDradiogroup_stack/menu_stack– For nested component buildingframe_counter– Auto-increment counter for generating frame IDsoverlays– Overlay info for dialogs/menusstatusbar– StatusBar element if present
Usage in template extensions:
from wijjit.core.render_context import get_render_context
def _render_button(self, ...):
ctx = get_render_context()
layout_ctx = ctx.layout_context
focused_id = ctx.focused_id
# ...
The renderer uses render_context_scope() context manager to establish the context:
with render_context_scope(layout_context, template_context) as ctx:
output = template.render(**template_context)
The context uses Python’s contextvars module for thread safety and reentrancy.
Ephemeral State Pattern
Elements preserve transient UI state across re-renders using the ephemeral state pattern. This is critical for maintaining cursor position, scroll offset, and selection state when templates re-execute.
Protected props (defined in wijjit.core.vdom.EPHEMERAL_PROPS):
Cursor state:
cursor_pos,cursor_row,cursor_colSelection state:
selection_anchor,selection_start,selection_endScroll state:
scroll_position,scroll_x_positionUI interaction:
highlighted_index,focused,hovered
These props are excluded from template-to-element syncing during reconciliation.
Framework-only props (defined in wijjit.core.vdom.FRAMEWORK_ONLY_PROPS):
A separate, smaller category: props that live on the base
wijjit.elements.base.Element but are deliberately not threaded through
every element constructor. Currently just autofocus.
These need special handling because the registry filters creation props to the
element’s __init__ signature (ElementRegistry._filter_props_for_factory),
so a prop no constructor accepts would be silently dropped on the render that
creates the element - which, for autofocus, is exactly the render that
matters. Reconciler._apply_framework_props therefore applies them by
setattr immediately after construction. Updates on an existing element flow
through _apply_prop_changes as normal, since the attribute exists by then.
Contrast tab_index, which predates this and is an explicit parameter on
each focusable element’s __init__. Prefer FRAMEWORK_ONLY_PROPS for new
framework props: it is one edit rather than one per element, and cannot be
half-applied across the element set.
Implementation:
Elements implement two methods to participate in ephemeral state preservation:
class MyElement(Element):
def get_ephemeral_state(self) -> dict:
"""Return state that should survive re-renders."""
return {
"cursor_pos": self.cursor_pos,
"scroll_position": self.scroll_position,
}
def restore_ephemeral_state(self, state: dict) -> None:
"""Restore state after reconciliation."""
if "cursor_pos" in state:
self.cursor_pos = state["cursor_pos"]
if "scroll_position" in state:
self.scroll_position = state["scroll_position"]
Elements that implement these methods include: TextInput, TextArea, Menu, Tree, TabbedPanel, and all scrollable elements.
Clip Region System
Nested frames require proper clipping to prevent content from rendering outside boundaries. The wijjit.rendering.paint_context.PaintContext manages clip regions.
When rendering nested content:
# Create sub-context with clipped bounds
inner_ctx = ctx.sub_context(x, y, width, height)
# All writes to inner_ctx are clipped to the sub-region
child_element.render_to(inner_ctx)
The sub_context() method:
Creates a new PaintContext with adjusted bounds
Inherits the style resolver and screen buffer
Applies accumulated scroll offsets from parent frames
Clips any writes that fall outside the region
The renderer uses clip_region during frame rendering to ensure scrollable content doesn’t overflow borders. Nested frames accumulate their offsets, so deeply nested scrollable content is correctly clipped to all ancestor frames.
Event system
Input layer –
wijjit.terminal.input.InputHandlercaptures keys and mouse events from prompt-toolkit. Mouse events are wrapped inwijjit.terminal.mouse.MouseEvent.Event types – defined in
wijjit.core.events:KeyEvent,ActionEvent,ChangeEvent,FocusEvent,MouseEvent. Each has metadata (key, modifiers, element id, etc.).Handler registry –
HandlerRegistrystoresHandlerobjects tagged withHandlerScope(GLOBAL / VIEW / ELEMENT), optional view/element ids, and priority.dispatchlooks up matching handlers, runs sync callbacks, and awaits async ones.Convenience decorators –
@app.on_keywrapsHandlerRegistry.registeratHandlerScope.GLOBAL;@app.on_actionbypassesHandlerScopeentirely and stores its callback in a separate action-handler map. View-scoped handlers (the ones that clear automatically during navigation) come from the lower-levelapp.on(..., scope=HandlerScope.VIEW, view_name=...).Mouse routing –
wijjit.core.mouse_router.MouseEventRouterperforms hit-testing (overlays first, then base layout), updateswijjit.core.hover.HoverManager, and forwards events to elements withhandle_mousemethods.Focus management –
wijjit.core.focus.FocusManagertracks focusable elements, handles Tab/Shift+Tab, and marks dirty regions when focus changes. Focus starts unset by design (focusing element 0 unconditionally would make the first Tab appear to skip it); an element may claim it declaratively withautofocus=True, whichset_elementsapplies whenever focus would otherwise beNone. Overlays can trap focus and restore the previous state upon closing.
State & wiring
Reactive state –
wijjit.core.state.StateextendsUserDictwith change detection, attribute access, global and per-key watchers, and async callback support.Wijjitregistersstate.on_changeto setapp.needs_renderwhen any key changes.Element wiring –
wijjit.core.wiring.ElementWiringManagerbinds template-generated elements (forms, lists) to state keys/actions by id. For example,{% textinput id="username" %}automatically syncsstate["username"]and emitsChangeEventwhen edits occur.Notifications –
wijjit.core.notification_manager.NotificationManagerleverages overlays to display toast-like alerts, auto-expiring them with the help of the event loop’s periodic checks.
Overlays & modals
wijjit.core.overlay.OverlayManagermanages stacked overlays grouped byLayerType(BASE / MODAL / DROPDOWN / TOOLTIP). Each overlay stores focus state, z-index, and dismissal behavior.Template tags in
wijjit.tags.dialogsandwijjit.tags.menuemit overlay descriptors (confirm dialogs, alerts, dropdown menus, context menus). During rendering the overlay manager instantiates the appropriate elements and pushes them withtrap_focusordimmed_backgroundas needed.Mouse clicks and keyboard events route through overlays before reaching the base layout, ensuring modals behave like first-class screens.
Terminal adapters
ANSI utilities –
wijjit.terminal.ansiprovides color helpers, cursor movement, and width-aware string functions (visible_length,wrap_text).Screen management –
wijjit.terminal.screen.ScreenManagertoggles alternate-screen mode, hides/shows the cursor, and flushes buffers.Cells & buffers –
wijjit.terminal.cell.Cellandwijjit.terminal.screen_buffer.ScreenBufferrepresent styled characters; the paint context writes to these objects instead of printing directly.Exit-time restore –
wijjit.terminal.cleanup.TerminalCleanupis a process-wide coordinator of idempotent, signal-safe terminal-restore callbacks, invoked from a singleatexithandler and fromSIGTERM/SIGHUPhandlers. The event loop registers a restore callback for its lifetime;InputHandler.restore_terminalprovides the mouse/raw-mode half without joining the reader thread.
Extending Wijjit
New elements – subclass
wijjit.elements.base.ElementorScrollableElement. Implementrender_to,get_intrinsic_size, and optionalhandle_key/handle_mouse. Expose the element via a new tag inwijjit.tagsfor templated usage.Themes – add entries to the styles dict of
wijjit.styling.theme.Theme, keyed by element style class (e.g.button,button.label). Elements resolve their style by callingctx.style_resolver.resolve_style(self, "<base_class>")insiderender_to. Register a theme at runtime viaapp.renderer.theme_manager.register_theme(theme)and activate it withapp.renderer.theme_manager.set_theme(theme.name).View helpers – store shared macros or template fragments in
templates/ordocs/examplesand load them withtemplate_file.Background tasks – use
asyncio.create_taskfor async work, or setapp.config["RUN_SYNC_IN_EXECUTOR"] = True(with optionalapp.config["EXECUTOR_MAX_WORKERS"]) so blocking sync handlers run on aThreadPoolExecutorand keep the UI responsive.
Use this architecture map as a starting point before touching multiple subsystems. When in doubt:
Locate the subsystem under
src/wijjit.Read the relevant docstring/tests (the test suite mirrors the runtime tree).
Update the accompanying documentation so future contributors inherit accurate context.