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   │
    └──────────┘               └──────────┘               └────────────────┘
  1. App constructionwijjit.core.app.Wijjit wires together the renderer, layout engine, state, event registry, overlay manager, focus/hover managers, mouse router, notification manager, and terminal adapters. Arguments such as template_dir and initial_state are stored on the instance.

  2. View registration@app.view stores lazy wijjit.core.view_router.ViewConfig objects inside wijjit.core.view_router.ViewRouter. A view returns a wijjit.core.templating.RenderedView (via wijjit.render_template_string() / wijjit.render_template()) carrying an inline template or a template_file plus its context; lifecycle hooks are declared on the decorator. Synchronous views are re-invoked every render so derived context stays live.

  3. Event loopapp.run() delegates to wijjit.core.event_loop.EventLoop which switches to the alternate screen, hides the cursor, enables mouse mode, renders the default view, and enters the main async loop.

  4. Frame processing – every iteration reads input (keyboard/mouse), dispatches events via wijjit.core.events.HandlerRegistry, updates notifications/overlays, and triggers a render if app.needs_render or the dirty region manager requests it.

  5. Shutdown – when app.quit() or Ctrl+C fires, 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 abnormal atexit) are covered by wijjit.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.py defines Element; subpackages input/ and display/ 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 new scaffolding).

  • testing/ – the headless WijjitHarness, app_from_template, and the pytest11 plugin.

  • plugins.py – the public third-party element seam (register_element + wijjit.plugins entry-point discovery).

  • cli.py / config.py / helpers.py – the wijjit console script, Flask-style config, and shared utilities.

Renderer & layout pipeline

  1. Template renderwijjit.core.renderer.Renderer configures a Jinja environment with the custom tags from wijjit.tags. Wijjit._render evaluates the view, then calls Renderer.render_with_layout (or render_string for a tagless template). The template context is the view’s keyword arguments flattened to top level, plus an auto-injected state; params and a wrapping data object are not placed in the context.

  2. VNode tree – tags such as {% vstack %}, {% frame %}, and {% button %} do not build elements directly; they emit immutable wijjit.core.vdom.VNode descriptions via a VNodeBuilder. The template render therefore produces a VNode tree, not an element tree.

  3. Reconcilewijjit.core.reconciler.Reconciler diffs the new VNode tree against the previous one and creates/updates/replaces/deletes the corresponding stateful wijjit.elements.base.Element objects, reusing existing elements (and their ephemeral UI state) where possible. The reconciler also wires the resulting elements into the layout tree (wijjit.layout.engine).

  4. Constraint passwijjit.layout.engine.LayoutNode.calculate_constraints() recursively computes minimum/preferred sizes based on element content and width/height specs. Scrollable frames consult wijjit.layout.scroll to measure overflow.

  5. Assign boundsassign_bounds walks top-down assigning concrete Bounds rectangles, respecting padding/margin/spacing rules.

  6. Painting – each element’s render_to writes to wijjit.rendering.paint_context.PaintContext, which wraps a wijjit.terminal.screen_buffer.ScreenBuffer. Styles are resolved via wijjit.styling.resolver.StyleResolver.

  7. Terminal flushwijjit.terminal.screen.ScreenManager diffs 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 reconciliation

  • props – Immutable properties tuple

  • children – Child VNodes

  • layout_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:

  1. Diff phase – Compares trees, produces DiffResult with changes

  2. Patch phase – Creates/updates/deletes elements based on diff

  3. Ephemeral state preservation – Cursor, scroll, selection state survives re-renders

Diff types (wijjit.core.reconciler.DiffType):

  • CREATE – New element needed

  • DELETE – Element removed

  • UPDATE – Props changed, reuse element

  • REPLACE – 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 trees

  • template_context – Template variables including ‘state’

  • focused_id – Currently focused element ID

  • radiogroup_stack / menu_stack – For nested component building

  • frame_counter – Auto-increment counter for generating frame IDs

  • overlays – Overlay info for dialogs/menus

  • statusbar – 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_col

  • Selection state: selection_anchor, selection_start, selection_end

  • Scroll state: scroll_position, scroll_x_position

  • UI 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:

  1. Creates a new PaintContext with adjusted bounds

  2. Inherits the style resolver and screen buffer

  3. Applies accumulated scroll offsets from parent frames

  4. 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 layerwijjit.terminal.input.InputHandler captures keys and mouse events from prompt-toolkit. Mouse events are wrapped in wijjit.terminal.mouse.MouseEvent.

  • Event types – defined in wijjit.core.events: KeyEvent, ActionEvent, ChangeEvent, FocusEvent, MouseEvent. Each has metadata (key, modifiers, element id, etc.).

  • Handler registryHandlerRegistry stores Handler objects tagged with HandlerScope (GLOBAL / VIEW / ELEMENT), optional view/element ids, and priority. dispatch looks up matching handlers, runs sync callbacks, and awaits async ones.

  • Convenience decorators@app.on_key wraps HandlerRegistry.register at HandlerScope.GLOBAL; @app.on_action bypasses HandlerScope entirely and stores its callback in a separate action-handler map. View-scoped handlers (the ones that clear automatically during navigation) come from the lower-level app.on(..., scope=HandlerScope.VIEW, view_name=...).

  • Mouse routingwijjit.core.mouse_router.MouseEventRouter performs hit-testing (overlays first, then base layout), updates wijjit.core.hover.HoverManager, and forwards events to elements with handle_mouse methods.

  • Focus managementwijjit.core.focus.FocusManager tracks 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 with autofocus=True, which set_elements applies whenever focus would otherwise be None. Overlays can trap focus and restore the previous state upon closing.

State & wiring

  • Reactive statewijjit.core.state.State extends UserDict with change detection, attribute access, global and per-key watchers, and async callback support. Wijjit registers state.on_change to set app.needs_render when any key changes.

  • Element wiringwijjit.core.wiring.ElementWiringManager binds template-generated elements (forms, lists) to state keys/actions by id. For example, {% textinput id="username" %} automatically syncs state["username"] and emits ChangeEvent when edits occur.

  • Notificationswijjit.core.notification_manager.NotificationManager leverages overlays to display toast-like alerts, auto-expiring them with the help of the event loop’s periodic checks.

Overlays & modals

  • wijjit.core.overlay.OverlayManager manages stacked overlays grouped by LayerType (BASE / MODAL / DROPDOWN / TOOLTIP). Each overlay stores focus state, z-index, and dismissal behavior.

  • Template tags in wijjit.tags.dialogs and wijjit.tags.menu emit overlay descriptors (confirm dialogs, alerts, dropdown menus, context menus). During rendering the overlay manager instantiates the appropriate elements and pushes them with trap_focus or dimmed_background as needed.

  • Mouse clicks and keyboard events route through overlays before reaching the base layout, ensuring modals behave like first-class screens.

Terminal adapters

  • ANSI utilitieswijjit.terminal.ansi provides color helpers, cursor movement, and width-aware string functions (visible_length, wrap_text).

  • Screen managementwijjit.terminal.screen.ScreenManager toggles alternate-screen mode, hides/shows the cursor, and flushes buffers.

  • Cells & bufferswijjit.terminal.cell.Cell and wijjit.terminal.screen_buffer.ScreenBuffer represent styled characters; the paint context writes to these objects instead of printing directly.

  • Exit-time restorewijjit.terminal.cleanup.TerminalCleanup is a process-wide coordinator of idempotent, signal-safe terminal-restore callbacks, invoked from a single atexit handler and from SIGTERM/SIGHUP handlers. The event loop registers a restore callback for its lifetime; InputHandler.restore_terminal provides the mouse/raw-mode half without joining the reader thread.

Extending Wijjit

  • New elements – subclass wijjit.elements.base.Element or ScrollableElement. Implement render_to, get_intrinsic_size, and optional handle_key / handle_mouse. Expose the element via a new tag in wijjit.tags for 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 calling ctx.style_resolver.resolve_style(self, "<base_class>") inside render_to. Register a theme at runtime via app.renderer.theme_manager.register_theme(theme) and activate it with app.renderer.theme_manager.set_theme(theme.name).

  • View helpers – store shared macros or template fragments in templates/ or docs/examples and load them with template_file.

  • Background tasks – use asyncio.create_task for async work, or set app.config["RUN_SYNC_IN_EXECUTOR"] = True (with optional app.config["EXECUTOR_MAX_WORKERS"]) so blocking sync handlers run on a ThreadPoolExecutor and keep the UI responsive.

Use this architecture map as a starting point before touching multiple subsystems. When in doubt:

  1. Locate the subsystem under src/wijjit.

  2. Read the relevant docstring/tests (the test suite mirrors the runtime tree).

  3. Update the accompanying documentation so future contributors inherit accurate context.