"""Template rendering with Jinja2 for Wijjit applications.
This module provides a renderer that processes Jinja2 templates with
custom extensions and filters for terminal UI rendering.
"""
import os
from collections.abc import Callable
from copy import copy
from typing import TYPE_CHECKING, Any
from jinja2 import (
BaseLoader,
DictLoader,
Environment,
FileSystemLoader,
StrictUndefined,
Template,
TemplateNotFound,
Undefined,
)
from jinja2.ext import Extension
from wijjit.layout.bounds import Bounds
from wijjit.layout.dirty import DirtyRegionManager
from wijjit.terminal.size import get_terminal_size
if TYPE_CHECKING:
from wijjit.terminal.cell import Cell
from wijjit.core.element_registry import ElementRegistry
from wijjit.core.reconciler import Reconciler
from wijjit.core.render_context import render_context_scope
from wijjit.core.state import State
from wijjit.core.vdom import VNode
from wijjit.elements.base import Element
from wijjit.layout.engine import (
Container,
FrameNode,
LayoutEngine,
LayoutNode,
)
from wijjit.layout.frames import BORDER_CHARS_UNICODE, BorderStyle
from wijjit.logging_config import get_logger
from wijjit.rendering.paint_context import PaintContext
from wijjit.styling.resolver import StyleResolver
from wijjit.styling.theme import ThemeManager
from wijjit.tags.charts import (
BarChartExtension,
ColumnChartExtension,
GaugeExtension,
HeatMapExtension,
LineChartExtension,
SparklineExtension,
)
from wijjit.tags.dialogs import (
AlertDialogExtension,
ConfirmDialogExtension,
TextInputDialogExtension,
)
from wijjit.tags.display import (
ContentViewExtension,
ImageViewExtension,
LinkExtension,
ListViewExtension,
LogViewExtension,
ModalExtension,
PageExtension,
PagerExtension,
ProgressBarExtension,
SpinnerExtension,
StatusBarExtension,
StatusIndicatorExtension,
TabbedPanelExtension,
TabExtension,
TableExtension,
TextExtension,
TreeExtension,
TreeItemExtension,
)
from wijjit.tags.input import (
ButtonExtension,
CheckboxExtension,
CheckboxGroupExtension,
CodeEditorExtension,
DataGridExtension,
RadioExtension,
RadioGroupExtension,
SelectExtension,
SelectItemExtension,
SliderExtension,
TextAreaExtension,
TextInputExtension,
ToggleExtension,
)
from wijjit.tags.layout import (
ColspanExtension,
FrameExtension,
GridExtension,
HStackExtension,
LayoutContext,
RowspanExtension,
SplitPanelExtension,
VStackExtension,
)
from wijjit.tags.menu import (
ContextMenuExtension,
DropdownExtension,
MenuItemExtension,
)
from wijjit.terminal.ansi import visible_length
from wijjit.terminal.screen_buffer import DiffRenderer, ScreenBuffer
# Get logger for this module
logger = get_logger(__name__)
# The built-in Jinja tag extensions, in registration order. Hoisted to a module
# constant so it is the single source of truth for both the Environment's
# ``extensions=`` list and :func:`builtin_tag_names` (used by the plugin seam to
# detect a plugin tag colliding with a built-in one).
BUILTIN_EXTENSIONS: tuple[type[Extension], ...] = (
FrameExtension,
VStackExtension,
HStackExtension,
GridExtension,
ColspanExtension,
RowspanExtension,
SplitPanelExtension,
TextInputExtension,
ButtonExtension,
CheckboxExtension,
RadioExtension,
CheckboxGroupExtension,
RadioGroupExtension,
SelectExtension,
SelectItemExtension,
TableExtension,
TreeExtension,
TreeItemExtension,
ProgressBarExtension,
SpinnerExtension,
StatusBarExtension,
TextExtension,
ListViewExtension,
LogViewExtension,
TextAreaExtension,
CodeEditorExtension,
ModalExtension,
ConfirmDialogExtension,
AlertDialogExtension,
TextInputDialogExtension,
MenuItemExtension,
DropdownExtension,
ContextMenuExtension,
TabExtension,
TabbedPanelExtension,
# Pager (linear pagination)
PageExtension,
PagerExtension,
# Link and unified content view
LinkExtension,
ContentViewExtension,
# Chart extensions
SparklineExtension,
BarChartExtension,
ColumnChartExtension,
LineChartExtension,
GaugeExtension,
HeatMapExtension,
# Image view
ImageViewExtension,
# New input elements
SliderExtension,
ToggleExtension,
DataGridExtension,
# Status indicator
StatusIndicatorExtension,
)
def builtin_tag_names() -> frozenset[str]:
"""Return the set of Jinja tag names owned by built-in extensions.
Used by :mod:`wijjit.plugins` for tag-collision checks without constructing a
Renderer (which would itself drain the plugin registry).
"""
names: set[str] = set()
for ext in BUILTIN_EXTENSIONS:
names.update(getattr(ext, "tags", ()) or ())
return frozenset(names)
def _invalidate_paint_memo(element: "Element") -> None:
"""Clear an element's skip-unchanged paint memo (review 2.5, option c).
Called when an element is culled from a frame (no bounds, scrolled/clipped
fully out of view): its previously-painted region is gone from the baseline,
so it must be repainted from scratch when it reappears rather than skipped.
"""
element._paint_signature = None
element._paint_geometry = None
element._paint_coverage = None
class WijjitEnvironment(Environment):
"""Jinja2 environment that resolves State keys ahead of State methods.
Jinja compiles ``{{ state.items }}`` to ``environment.getattr(state,
"items")``, whose default implementation tries ``getattr`` first. On a
:class:`~wijjit.core.state.State` (a ``UserDict``) that finds the bound
``items`` method and never reaches the data, which is why a whole family
of names - ``items``, ``keys``, ``values``, ``get``, ``data``, ... - used
to be banned as state keys outright.
Preferring the key when one exists frees every one of those names. The
fallback to normal attribute lookup is what keeps the escape hatches
working: with no ``items`` key, ``{% for k, v in state.items() %}`` still
iterates the mapping, and ``{{ state.get('k', d) }}`` - the documented
idiom for guarding optional keys under ``DEBUG`` / ``StrictUndefined`` -
still calls the method. A genuinely missing name still falls all the way
through to ``self.undefined(...)``, so strict mode still raises.
Only ``State`` is special-cased; a plain dict in the template context keeps
Jinja's normal semantics, so ``{% for k, v in mydict.items() %}`` is
unaffected.
Notes
-----
The cost, which is inherent and documented: in an app that *does* define a
key named ``get``, ``{{ state.get('k') }}`` resolves to that value and then
fails with ``TypeError: not callable``. Naming a key after a method makes
the method unreachable from templates in that app.
"""
def getattr(self, obj: Any, attribute: str) -> Any:
"""Look up ``attribute`` on ``obj``, preferring State keys.
Parameters
----------
obj : Any
The object being accessed in the template.
attribute : str
The attribute name.
Returns
-------
Any
The state value if ``obj`` is a State holding that key, otherwise
whatever Jinja's default lookup produces.
"""
if isinstance(obj, State) and attribute in obj.data:
return obj.data[attribute]
return super().getattr(obj, attribute)
[docs]
class Renderer:
"""Template renderer using Jinja2.
This class manages Jinja2 template rendering with support for
both string templates and file-based templates.
Parameters
----------
template_dir : str, optional
Directory containing template files
autoescape : bool, optional
Whether to enable autoescaping (default: False for terminal output)
auto_reload : bool, optional
Whether Jinja2 reloads file templates when they change on disk
(default: False). Useful during development; maps to the
``TEMPLATE_AUTO_RELOAD`` config key.
strict_undefined : bool, optional keyword-only
Whether the Jinja2 environment uses :class:`jinja2.StrictUndefined`
(default: False). In strict mode, referencing an undefined template
name (e.g. ``{{ typo_var }}``) raises :class:`jinja2.UndefinedError`
at render time, surfacing typos instead of silently rendering an empty
string. The production default is lenient (:class:`jinja2.Undefined`)
so that one bad key cannot crash a running TUI; strict mode is enabled
for development (``DEBUG``) and by the devtools validator.
Attributes
----------
env : jinja2.Environment
The Jinja2 environment
strict_undefined : bool
Whether undefined template names raise at render time.
_string_templates : dict
Cache of string templates
"""
[docs]
def __init__(
self,
template_dir: str | None = None,
autoescape: bool = False,
auto_reload: bool = False,
*,
strict_undefined: bool = False,
) -> None:
# Discover entry-point element plugins before the env / registry are
# built so both pick them up (idempotent, cheap after the first call).
from wijjit.plugins import ensure_plugins_loaded
ensure_plugins_loaded()
# Store template_dir for introspection
self.template_dir = template_dir
self.strict_undefined = strict_undefined
# Create loader based on template_dir
loader: BaseLoader
if template_dir:
# User specified a template_dir, validate it exists
if not os.path.isdir(template_dir):
raise FileNotFoundError(
f"Template directory '{template_dir}' does not exist. "
f"Please create the directory or use inline template strings instead. "
f"To use inline templates, don't pass template_dir to Wijjit()."
)
loader = FileSystemLoader(template_dir)
self.using_file_loader = True
logger.info(f"Using FileSystemLoader for templates from: {template_dir}")
else:
# No template_dir specified, use DictLoader for string templates
loader = DictLoader({})
self.using_file_loader = False
logger.debug("Using DictLoader for inline string templates")
# Create Jinja2 environment with custom extensions
self.env = WijjitEnvironment(
loader=loader,
autoescape=autoescape,
auto_reload=auto_reload,
undefined=StrictUndefined if strict_undefined else Undefined,
trim_blocks=True,
lstrip_blocks=True,
extensions=list(BUILTIN_EXTENSIONS),
)
# Cache for string templates
self._string_templates: dict[str, Template] = {}
# Theme management for cell-based rendering
self.theme_manager = ThemeManager()
# Global focus color override (set from config)
self.focus_color: tuple[int, int, int] | None = None
# Feature flag for diff rendering (now enabled by default with dirty region tracking)
# When False, forces full screen re-renders on every frame
# When True, only renders changed cells (more efficient with dirty region tracking)
#
# With DirtyRegionManager integrated, diff rendering can safely be enabled by default.
# State/Focus/Hover changes mark appropriate regions dirty, ensuring correct updates.
self.use_diff_rendering = True
# Two-buffer system for clean overlay compositing:
# - _last_base_buffer: The base view without overlays (always preserved)
# - _last_displayed_buffer: What's actually on screen (may include overlays)
# This separation allows clean diff rendering when overlays are added/removed
# without requiring full redraws or losing the base buffer state.
self._last_base_buffer: ScreenBuffer | None = None
self._last_displayed_buffer: ScreenBuffer | None = None
# Incremental (damage-tracked) base rendering (review 2.5). On the fast
# path the paint buffer starts as a copy of the previous frame and the
# write paths dirty only the cells that actually change, so the diff
# scans changes rather than the whole screen. _spare_base_buffer is the
# retired base buffer from the prior frame, reused as this frame's paint
# target (pooled to avoid reallocation). _last_coverage is the set of
# cells painted last frame (packed ``y * width + x``), used to blank
# vacated content. The path is only taken when it is provably safe (no
# overlays this or last frame, same size, not a forced full repaint) and
# can be disabled wholesale.
self.incremental_render = True
self._spare_base_buffer: ScreenBuffer | None = None
self._last_coverage: set[int] | None = None
# Skip re-painting unchanged elements (review 2.5, option c). On the
# incremental path, an element whose render_signature() and on-screen
# geometry match the previous frame is not re-painted - its cells are
# already correct in the copied-in baseline - and its previous painted
# region is re-recorded as coverage so the vacated-cell blanking spares
# it. Gated additionally on an unchanged theme epoch (_last_style_epoch);
# elements returning a None signature are always painted. _verify_skips
# is a debug switch that paints skippable elements anyway and asserts the
# blitted region is unchanged, proving signature completeness in tests.
self.skip_unchanged_elements = True
self._verify_skips = False
self._last_style_epoch: tuple[int, Any] | None = None
# Diff renderer for efficient incremental updates
self._diff_renderer = DiffRenderer()
# Dirty region manager for tracking screen areas that need redraw
self.dirty_manager = DirtyRegionManager()
# Virtual DOM reconciliation system
# Reconciler manages element lifecycle: create, update, delete, and reuse
self._reconciler = Reconciler(ElementRegistry())
self._last_vnode_tree: VNode | None = None
# Add custom filters
self._setup_filters()
# Add layout constants to globals for easier template usage
self.env.globals.update(
{
"fill": "fill",
"auto": "auto",
}
)
# Install third-party plugin tag extensions into this env. Runs after
# the env exists and before any template compiles, so the new tags are
# honored by the parser. The app renderer and the devtools validator's
# renderer both reach this, so both see plugin tags for free.
self._install_plugin_extensions()
def _install_plugin_extensions(self) -> None:
"""Add each registered plugin tag extension to this Jinja environment.
Idempotent: jinja2 keys ``env.extensions`` by each extension's
``identifier`` (its fully-qualified class name), so re-installing the
same class is skipped.
"""
from wijjit.plugins import plugin_extensions
for ext_cls in plugin_extensions():
if ext_cls.identifier not in self.env.extensions:
self.env.add_extension(ext_cls)
def _setup_filters(self) -> None:
"""Set up custom Jinja2 filters for terminal rendering."""
# Add common filters
self.env.filters["upper"] = str.upper
self.env.filters["lower"] = str.lower
self.env.filters["title"] = str.title
[docs]
def get_extension_tag_names(self) -> set[str]:
"""Return the set of Jinja2 tag names registered by Wijjit extensions.
Useful for callers that need to determine whether a template uses any
Wijjit-specific tag (and therefore must be routed through the layout
pipeline rather than plain Jinja2 string rendering).
Returns
-------
set[str]
Every tag name registered by the Wijjit extension classes (e.g.
``"frame"``, ``"textinput"``, ``"button"``).
"""
names: set[str] = set()
for ext in self.env.extensions.values():
tags = getattr(ext, "tags", None)
if tags:
names.update(tags)
return names
[docs]
def render_string(
self, template_string: str, context: dict[str, Any] | None = None
) -> str:
"""Render a template from a string.
Parameters
----------
template_string : str
The template string to render
context : dict, optional
Context variables for template rendering
Returns
-------
str
The rendered template output
"""
context = context or {}
# Check cache first
if template_string in self._string_templates:
template = self._string_templates[template_string]
else:
# Compile and cache
template = self.env.from_string(template_string)
self._string_templates[template_string] = template
return template.render(**context)
[docs]
def render_file(
self, template_name: str, context: dict[str, Any] | None = None
) -> str:
"""Render a template from a file.
Parameters
----------
template_name : str
Name of the template file
context : dict, optional
Context variables for template rendering
Returns
-------
str
The rendered template output
Raises
------
jinja2.TemplateNotFound
If the template file doesn't exist
"""
context = context or {}
template = self._get_file_template(template_name)
return template.render(**context)
def _get_file_template(self, template_name: str) -> Template:
"""Load a file template, raising actionable errors on common mistakes.
Parameters
----------
template_name : str
Name of the template file to load from ``template_dir``.
Returns
-------
jinja2.Template
The loaded (and Jinja-cached) template.
Raises
------
RuntimeError
If no template directory is configured (the renderer is using the
in-memory ``DictLoader``), so ``render_template()`` cannot resolve a
file. The message points at ``template_dir`` / the ``templates/``
convention and ``render_template_string()``.
jinja2.TemplateNotFound
If the file is not present in ``template_dir``; re-raised with the
searched directory included in the message.
"""
if not self.using_file_loader:
raise RuntimeError(
f"render_template({template_name!r}) needs a template directory, "
f"but none is configured. Create a 'templates/' directory next to "
f"your app module (it is auto-discovered), or pass "
f"Wijjit(template_dir='...'). To render an inline string instead, "
f"use render_template_string()."
)
try:
return self.env.get_template(template_name)
except TemplateNotFound as exc:
raise TemplateNotFound(
template_name,
message=(
f"Template {template_name!r} not found in template directory "
f"{self.template_dir}. Check the filename and that the file "
f"exists in that directory."
),
) from exc
[docs]
def add_filter(self, name: str, func: Callable[..., Any]) -> None:
"""Add a custom filter to the Jinja2 environment.
Parameters
----------
name : str
Name of the filter
func : Callable
Filter function
"""
self.env.filters[name] = func
[docs]
def add_global(self, name: str, value: Any) -> None:
"""Add a global variable to the Jinja2 environment.
Parameters
----------
name : str
Name of the global variable
value : Any
Value of the global variable
"""
self.env.globals[name] = value
[docs]
def render_with_layout(
self,
template_string: str = "",
context: dict[str, Any] | None = None,
width: int | None = None,
height: int | None = None,
template_name: str | None = None,
allow_incremental: bool = False,
) -> tuple[str, list[Element], "LayoutContext"]:
"""Render a template with layout engine support.
This method handles the full pipeline for layout-based templates:
1. Create layout context
2. Render template (building layout tree)
3. Run layout engine to calculate positions
4. Compose final output from positioned elements
Parameters
----------
template_string : str, optional
The template string to render (use this OR template_name)
context : dict, optional
Context variables for template rendering
width : int, optional
Available width (default: terminal width)
height : int, optional
Available height (default: terminal height)
template_name : str, optional
Name of template file to load from template_dir (use this OR template_string)
Returns
-------
tuple of (str, list of Element, LayoutContext)
Rendered output, list of elements with bounds, and layout context
containing overlay information from template tags
"""
context = context or {}
# Get terminal size if not provided
if width is None or height is None:
term_size = get_terminal_size()
width = width or term_size.columns
height = height or term_size.lines
logger.debug(f"Rendering template with layout (width={width}, height={height})")
# Create layout context
layout_ctx = LayoutContext()
# Get focused_id from context if available
focused_id = context.get("_wijjit_focused_id")
# Use RenderContext for thread-safe context passing
with render_context_scope(layout_ctx, context, focused_id) as render_ctx:
# Compile or load template
if template_name:
# Load template from file (via FileSystemLoader), with friendly
# errors when no template dir is set or the file is missing.
template = self._get_file_template(template_name)
elif template_string in self._string_templates:
# Use cached string template
template = self._string_templates[template_string]
else:
# Compile and cache new string template
template = self.env.from_string(template_string)
self._string_templates[template_string] = template
# Render template (this builds the layout tree)
# Capture output in case there are no layout tags (to avoid double-rendering)
rendered_output = template.render(**context)
# Transfer overlay info from RenderContext to LayoutContext
# for backward compatibility with existing overlay handling
if render_ctx.overlays:
layout_ctx._overlays = render_ctx.overlays
if render_ctx.statusbar is not None:
layout_ctx._statusbar = render_ctx.statusbar
# Check if we have a VNode tree (layout tags were used)
# Use vnode_root since it's the canonical source for VNode-based rendering
if layout_ctx.vnode_root is None:
# The template produced no layout/element tags. If it produced bare
# top-level text, wrap it in an implicit Text element so it actually
# paints - otherwise the text would bypass the cell buffer, diff
# renderer, and reconciler and never appear on the rendered screen
# (e.g. a view that returns render_template_string("Hello")). This is
# equivalent to the author writing {% text %}...{% endtext %}.
stripped_output = rendered_output.strip()
if not stripped_output:
# Genuinely empty output - nothing to paint. Return as-is.
return rendered_output, [], layout_ctx
from wijjit.core.vdom import IMPLICIT_TEXT_ROOT_KEY, VNodeBuilder
implicit_text = VNodeBuilder("Text", key=IMPLICIT_TEXT_ROOT_KEY)
implicit_text.set_prop("id", IMPLICIT_TEXT_ROOT_KEY)
implicit_text.set_prop("text", stripped_output)
implicit_text.set_prop("wrap", True)
implicit_text.set_layout(width="auto", height="auto")
layout_ctx.add_vnode(implicit_text)
# Fall through to the normal reconcile -> layout -> paint pipeline.
# === Virtual DOM Reconciliation ===
# Freeze the VNode tree built during template rendering
frozen_vnode_tree = layout_ctx.freeze_vnode_tree()
# Reconcile with previous VNode tree to reuse elements
old_tree = self._last_vnode_tree
new_tree = frozen_vnode_tree
if new_tree is not None:
# Reconcile: reuse, update, create, delete elements as needed
if old_tree is not None:
root_element, reconciled_elements = self._reconciler.reconcile(
old_tree, new_tree
)
logger.debug(
f"Reconciled {len(reconciled_elements)} elements "
f"({len(self._reconciler._element_cache)} cached)"
)
else:
# First render: create all elements
root_element, reconciled_elements = self._reconciler.reconcile(
None, new_tree
)
logger.debug(
f"First render: created {len(reconciled_elements)} elements"
)
# Use reconciler's element cache directly - it's already keyed by vnode.key
# This handles both elements with and without explicit IDs
reconciled_map = self._reconciler._element_cache
# Build LayoutNode tree from VNode tree + reconciled elements
# This replaces the old approach of swapping elements into template-created tree
# Extract state dict from context for scroll/tab state persistence
state_dict = context.get("state") if context else None
root_frame_tracker = [False] # Track if root frame found for auto-scroll
layout_ctx.root = self._build_layout_tree_from_vnode(
new_tree,
reconciled_map,
state_dict,
root_frame_found=root_frame_tracker,
)
logger.debug("Built layout tree from VNode + reconciled elements")
# If no root frame was found, wrap the content in an implicit scrollable frame
if not root_frame_tracker[0]:
from wijjit.layout.engine import FrameNode, VStack
from wijjit.layout.frames import BorderStyle, Frame, FrameStyle
implicit_frame = Frame(
width="fill",
height="fill",
style=FrameStyle(
border_style=BorderStyle.NONE,
padding=(0, 0, 0, 0),
scrollable=True,
show_scrollbar=True,
),
id="_implicit_root_frame",
)
implicit_frame_node = FrameNode(
frame=implicit_frame,
width="fill",
height="fill",
)
# Wrap existing root in a content container
content_container = VStack(width="fill", height="auto")
content_container.add_child(layout_ctx.root)
implicit_frame_node.content_container = content_container
layout_ctx.root = implicit_frame_node
logger.debug("Wrapped content in implicit scrollable root frame")
# Store for next render
self._last_vnode_tree = new_tree
# Check if a statusbar was created during template rendering
# If yes, reduce available height by 1 to make room for it
layout_height = height
has_statusbar = (
hasattr(layout_ctx, "_statusbar") and layout_ctx._statusbar is not None
)
if has_statusbar:
layout_height = height - 1
logger.debug("StatusBar detected, reducing layout height by 1")
# Run layout engine
logger.debug(f"Running layout engine (height={layout_height})")
engine = LayoutEngine(layout_ctx.root, width, layout_height)
elements = engine.layout()
logger.debug(f"Layout calculated for {len(elements)} elements")
# Get statusbar if present
statusbar = getattr(layout_ctx, "_statusbar", None)
# Compose output from positioned elements using cell-based rendering
# Use full height for buffer, statusbar will render to last row
logger.debug("Using cell-based rendering")
output, base_buffer = self._compose_output_cells(
elements,
width,
height,
layout_ctx.root,
statusbar,
allow_incremental=allow_incremental,
)
# Return layout context so caller can process overlays
# This allows app._render() to manage overlay lifecycle properly
# (separating template-declared overlays from programmatic ones)
return output, elements, layout_ctx
def _build_layout_tree_from_vnode(
self,
vnode: VNode,
reconciled_map: dict[str, Element],
state_dict: dict[str, Any] | None = None,
root_frame_found: list[bool] | None = None,
) -> LayoutNode:
"""Build LayoutNode tree from VNode tree and reconciled elements.
Parameters
----------
vnode : VNode
VNode tree with layout specifications
reconciled_map : dict
Map of element key to reconciled element
state_dict : dict, optional
Application state dict for scroll/tab state persistence
root_frame_found : list[bool], optional
Mutable flag to track if root frame has been found and marked
for auto-scrolling. Uses a list so the flag can be modified
across recursive calls.
Returns
-------
LayoutNode
Root of the layout tree
"""
from wijjit.core.vdom import LayoutSpec
from wijjit.layout.frames import Frame
# Get layout spec from VNode
layout_dict = vnode.layout_spec_dict()
# Build LayoutSpec from VNode layout_spec
# Use the actual fields defined in LayoutSpec dataclass
layout_spec = LayoutSpec()
for key, value in layout_dict.items():
if hasattr(layout_spec, key):
setattr(layout_spec, key, value)
# Handle container types (VStack, HStack, Frame)
if vnode.type == "VStack":
from wijjit.layout.engine import VStack
container = VStack(
width=layout_spec.width or "fill",
height=layout_spec.height or "auto",
spacing=layout_spec.spacing or 0,
padding=layout_spec.padding or 0,
margin=layout_spec.margin or 0,
align_h=layout_spec.align_h or "stretch",
align_v=layout_spec.align_v or "stretch",
)
for child_vnode in vnode.children:
child_node = self._build_layout_tree_from_vnode(
child_vnode,
reconciled_map,
state_dict,
root_frame_found=root_frame_found,
)
container.add_child(child_node)
return container
elif vnode.type == "HStack":
from wijjit.layout.engine import HStack
container = HStack(
width=layout_spec.width or "auto",
height=layout_spec.height or "auto",
spacing=layout_spec.spacing or 0,
padding=layout_spec.padding or 0,
margin=layout_spec.margin or 0,
align_h=layout_spec.align_h or "stretch",
align_v=layout_spec.align_v or "stretch",
justify=getattr(layout_spec, "justify", None) or "flex-start",
wrap=getattr(layout_spec, "wrap", False) or False,
row_gap=getattr(layout_spec, "row_gap", None),
column_gap=getattr(layout_spec, "column_gap", None),
)
for child_vnode in vnode.children:
child_node = self._build_layout_tree_from_vnode(
child_vnode,
reconciled_map,
state_dict,
root_frame_found=root_frame_found,
)
container.add_child(child_node)
return container
elif vnode.type == "Grid":
from wijjit.layout.engine import Grid
props = vnode.props_dict()
container = Grid(
rows=props.get("rows", 2),
cols=props.get("cols", 2),
row_gap=props.get("row_gap", 0),
column_gap=props.get("column_gap", 0),
width=layout_spec.width or "fill",
height=layout_spec.height or "auto",
padding=layout_spec.padding or 0,
margin=layout_spec.margin or 0,
align_h=layout_spec.align_h or "stretch",
align_v=layout_spec.align_v or "stretch",
)
for child_vnode in vnode.children:
child_node = self._build_layout_tree_from_vnode(
child_vnode,
reconciled_map,
state_dict,
root_frame_found=root_frame_found,
)
container.add_child(child_node)
return container
elif vnode.type == "GridSpanWrapper":
from wijjit.layout.engine import GridSpanWrapper
props = vnode.props_dict()
# GridSpanWrapper should have exactly one child
if vnode.children:
child_node = self._build_layout_tree_from_vnode(
vnode.children[0],
reconciled_map,
state_dict,
root_frame_found=root_frame_found,
)
return GridSpanWrapper(
child=child_node,
colspan=props.get("colspan", 1),
rowspan=props.get("rowspan", 1),
)
else:
# No child - create placeholder
from wijjit.elements.base import TextElement
from wijjit.layout.engine import ElementNode
placeholder = TextElement(text="[Empty span]")
return GridSpanWrapper(
child=ElementNode(placeholder),
colspan=props.get("colspan", 1),
rowspan=props.get("rowspan", 1),
)
elif vnode.type == "Frame":
# Frame needs layout dimensions to be created
# Get width/height from layout_spec
layout_dict = vnode.layout_spec_dict()
frame_width = layout_dict.get("width", "fill")
frame_height = layout_dict.get("height", "fill")
# Detect if this is the root frame (first frame encountered)
# Root frames get auto-scroll enabled for views taller than terminal
is_root_frame = False
if root_frame_found is not None and not root_frame_found[0]:
is_root_frame = True
root_frame_found[0] = True
# Auto-generate key for root frames without explicit ID so scroll state persists
frame_key = vnode.key
if is_root_frame and not frame_key:
frame_key = "_auto_root_frame"
# Check if we have a reconciled Frame with same key (for state preservation)
if frame_key and frame_key in reconciled_map:
# Reuse reconciled Frame element (preserves scroll state)
frame_element = reconciled_map[frame_key]
# Update dimensions if changed
frame_element.width = frame_width
frame_element.height = frame_height
# Enable scrollable on root frame if not already set
if is_root_frame and not frame_element.style.scrollable:
from dataclasses import replace
frame_element.style = replace(frame_element.style, scrollable=True)
logger.debug(f"Reused reconciled Frame {frame_key}")
else:
# Create new Frame element with layout dimensions
props = vnode.props_dict()
# Import here to avoid circular dependency
from wijjit.layout.frames import BorderStyle, Frame, FrameStyle
# Build FrameStyle from props
border_style = props.get("border_style", BorderStyle.SINGLE)
title = props.get("title")
padding = props.get("padding", (0, 1, 0, 1))
scrollable = props.get("scrollable", False)
# Root frame gets auto-scroll enabled
if is_root_frame:
scrollable = True
show_scrollbar = props.get("show_scrollbar", True)
show_scrollbar_x = props.get("show_scrollbar_x", True)
overflow_x = props.get("overflow_x", "clip")
overflow_y = props.get("overflow_y", "clip")
content_align_h = props.get("content_align_h", "stretch")
content_align_v = props.get("content_align_v", "stretch")
style = FrameStyle(
border_style=border_style,
title=title,
padding=padding,
content_align_h=content_align_h,
content_align_v=content_align_v,
scrollable=scrollable,
show_scrollbar=show_scrollbar,
show_scrollbar_x=show_scrollbar_x,
overflow_y=overflow_y,
overflow_x=overflow_x,
)
frame_element = Frame(
width=frame_width,
height=frame_height,
style=style,
id=frame_key,
)
# Add to reconciled_map so it can be reused on next render
if frame_key:
reconciled_map[frame_key] = frame_element
logger.debug(f"Created new Frame {frame_key}")
# Apply the shared 'class'/'classes' template attribute. Frame is a
# styled Element (unlike the pure-layout VStack/HStack nodes), so it
# participates in class-based theming like every other element. The
# tag layer already normalized 'class' -> 'classes'. Re-apply on
# every render so a changed class set is reflected on a reused frame.
frame_classes = vnode.props_dict().get("classes")
if frame_classes is not None:
if isinstance(frame_classes, str):
frame_element.classes = set(frame_classes.split())
else:
frame_element.classes = set(frame_classes)
# Create FrameNode with content container
from wijjit.layout.engine import FrameNode, VStack
# FrameNode constructor expects individual parameters, not layout_spec
frame_node = FrameNode(
frame=frame_element,
width=frame_width,
height=frame_height,
margin=layout_spec.margin or 0,
align_h=layout_spec.align_h or "stretch",
align_v=layout_spec.align_v or "stretch",
content_align_h=layout_spec.content_align_h or "stretch",
content_align_v=layout_spec.content_align_v or "stretch",
)
# Create content container (vertical stack for children)
# Use height="auto" so children stack naturally without overlapping
content_container = VStack(
width="fill",
height="auto",
spacing=layout_spec.spacing or 0,
)
for child_vnode in vnode.children:
child_node = self._build_layout_tree_from_vnode(
child_vnode,
reconciled_map,
state_dict,
root_frame_found=root_frame_found,
)
content_container.add_child(child_node)
frame_node.content_container = content_container
return frame_node
elif vnode.type == "SplitPanel":
# Build SplitPanel from VNode tree with two children
from wijjit.layout.engine import SplitPanelNode
from wijjit.layout.splitpanel import SplitPanel
# Get props from VNode
props = vnode.props_dict()
layout_dict = vnode.layout_spec_dict()
# Parse dimensions
width_spec = layout_dict.get("width", "fill")
height_spec = layout_dict.get("height", "fill")
# Check if we have a reconciled SplitPanel with same key (for state preservation)
if vnode.key and vnode.key in reconciled_map:
# Reuse reconciled SplitPanel element (preserves ratio/collapse state)
split_panel = reconciled_map[vnode.key]
logger.debug(f"Reused reconciled SplitPanel {vnode.key}")
else:
# Create new SplitPanel element
split_panel = SplitPanel(
orientation=props.get("orientation", "horizontal"),
ratio=props.get("ratio", "50:50"),
resizable=props.get("resizable", True),
min_first=props.get("min_first", 5),
min_second=props.get("min_second", 5),
collapsible=props.get("collapsible", "none"),
divider_style=props.get("divider_style", "single"),
id=vnode.key,
)
# Add to reconciled_map so it can be reused on next render
if vnode.key:
reconciled_map[vnode.key] = split_panel
logger.debug(f"Created new SplitPanel {vnode.key}")
# Create SplitPanelNode
split_node = SplitPanelNode(
split_panel=split_panel,
width=width_spec,
height=height_spec,
id=vnode.key,
)
# Build children (should be exactly 2)
for child_vnode in vnode.children:
child_node = self._build_layout_tree_from_vnode(
child_vnode,
reconciled_map,
state_dict,
root_frame_found=root_frame_found,
)
split_node.add_child(child_node)
return split_node
elif vnode.type == "TabbedPanel":
# Build TabbedPanel from VNode tree with TabContent children
from wijjit.elements.base import TextElement
from wijjit.elements.display.tabbed_panel import TabbedPanel, TabPosition
from wijjit.layout.engine import ElementNode, FrameNode, VStack
from wijjit.layout.frames import BorderStyle, Frame, FrameStyle
# Get props from VNode
props = vnode.props_dict()
layout_dict = vnode.layout_spec_dict()
# Parse dimensions
width_spec = layout_dict.get("width", 60)
height_spec = layout_dict.get("height", 20)
# Convert to numeric for element creation
if isinstance(width_spec, str) and not str(width_spec).isdigit():
element_width = 60
else:
element_width = int(width_spec)
if isinstance(height_spec, str) and not str(height_spec).isdigit():
element_height = 20
else:
element_height = int(height_spec)
# Get other props
tab_pos = props.get("tab_position", TabPosition.TOP)
border_style = props.get("border_style", "single")
active_tab_index = props.get("active_tab_index", 0)
bind = props.get("bind", True)
classes = props.get("classes")
active_tab_state_key = props.get("active_tab_state_key")
focused = props.get("focused", False)
# Convert border_style string to enum
border_map = {
"single": BorderStyle.SINGLE,
"double": BorderStyle.DOUBLE,
"rounded": BorderStyle.ROUNDED,
}
border_style_enum = border_map.get(border_style, BorderStyle.SINGLE)
# Check if we have a reconciled TabbedPanel with same key (for state preservation)
if vnode.key and vnode.key in reconciled_map:
# Reuse reconciled TabbedPanel element (preserves active_tab_index state)
tabbed_panel = reconciled_map[vnode.key]
# Update dimensions if changed
tabbed_panel.width = element_width
tabbed_panel.height = element_height
tabbed_panel.tab_position = tab_pos
tabbed_panel.border_style = border_style_enum
# Clear existing tabs - we'll rebuild them from VNode children
tabbed_panel.tabs.clear()
logger.debug(f"Reused reconciled TabbedPanel {vnode.key}")
else:
# Create new TabbedPanel element
tabbed_panel = TabbedPanel(
id=vnode.key,
classes=classes,
tab_position=tab_pos,
width=element_width,
height=element_height,
border_style=border_style,
active_tab_index=active_tab_index,
)
tabbed_panel.bind = bind
tabbed_panel.focused = focused
if active_tab_state_key:
tabbed_panel.active_tab_state_key = active_tab_state_key
# Add to reconciled_map so it can be found for event handling
if vnode.key:
reconciled_map[vnode.key] = tabbed_panel
logger.debug(
f"Created TabbedPanel {vnode.key} and added to reconciled_map"
)
# Wire up state_dict for tab state and scroll persistence
if state_dict is not None:
tabbed_panel._state_dict = state_dict
# Calculate content area dimensions
if tab_pos in (TabPosition.TOP, TabPosition.BOTTOM):
frame_width = element_width - 2
frame_height = element_height - 5
else:
# For left/right tabs, estimate tab area width from labels
tab_children = [c for c in vnode.children if c.type == "TabContent"]
max_label_width = max(
(len(c.props_dict().get("label", "Tab")) for c in tab_children),
default=3,
)
tab_area_width = max_label_width + 4
frame_width = element_width - tab_area_width - 1
frame_height = element_height - 2
# Process each TabContent child to create tabs
for tab_index, tab_vnode in enumerate(vnode.children):
if tab_vnode.type != "TabContent":
continue
tab_props = tab_vnode.props_dict()
label = tab_props.get("label", "Tab")
# Create Frame for tab content
scroll_state_key = (
f"_scroll_{vnode.key}_tab_{tab_index}" if vnode.key else None
)
frame = Frame(
width=frame_width,
height=frame_height,
style=FrameStyle(
scrollable=True,
show_scrollbar=True,
border_style=BorderStyle.SINGLE,
),
)
frame.scroll_state_key = scroll_state_key
# Wire up state_dict for scroll state persistence
if state_dict is not None:
frame._state_dict = state_dict
# Build content from TabContent's VNode children
content_children = []
for child_vnode in tab_vnode.children:
child_node = self._build_layout_tree_from_vnode(
child_vnode,
reconciled_map,
state_dict,
root_frame_found=root_frame_found,
)
content_children.append(child_node)
# Create FrameNode with content
frame_node = FrameNode(
frame=frame,
children=content_children,
width=frame_width,
height=frame_height,
)
# If only text children, extract text for frame content
has_non_text = any(
hasattr(c, "element") and not isinstance(c.element, TextElement)
for c in content_children
)
if not has_non_text and content_children:
text_parts = []
for child in content_children:
if hasattr(child, "element") and hasattr(child.element, "text"):
text_parts.append(child.element.text)
if text_parts:
frame.set_content("\n".join(text_parts))
tabbed_panel.add_tab(label, frame_node)
logger.debug(
f"Built TabbedPanel from VNode: {vnode.key} with {len(tabbed_panel.tabs)} tabs"
)
return ElementNode(tabbed_panel, width=width_spec, height=height_spec)
elif vnode.type == "Pager":
# Build Pager from VNode tree with PageContent children
from wijjit.elements.base import TextElement
from wijjit.elements.display.pager import Page, Pager
from wijjit.layout.engine import ElementNode, FrameNode, VStack
from wijjit.layout.frames import BorderStyle, Frame, FrameStyle
# Get props from VNode
props = vnode.props_dict()
layout_dict = vnode.layout_spec_dict()
# Parse dimensions
width_spec = layout_dict.get("width", 60)
height_spec = layout_dict.get("height", 20)
# Convert to numeric for element creation
if isinstance(width_spec, str) and not str(width_spec).isdigit():
element_width = 60
else:
element_width = int(width_spec)
if isinstance(height_spec, str) and not str(height_spec).isdigit():
element_height = 20
else:
element_height = int(height_spec)
# Get other props
nav_position = props.get("nav_position", "bottom")
show_indicator = props.get("show_indicator", True)
show_titles = props.get("show_titles", False)
loop = props.get("loop", False)
current_page = props.get("current_page", 0)
border_style = props.get("border_style", "single")
bind = props.get("bind", True)
classes = props.get("classes")
page_state_key = props.get("page_state_key")
focused = props.get("focused", False)
# Check if we have a reconciled Pager with same key (for state preservation)
if vnode.key and vnode.key in reconciled_map:
# Reuse reconciled Pager element (preserves current_page state)
pager = reconciled_map[vnode.key]
# Update dimensions if changed
pager.width = element_width
pager.height = element_height
pager.nav_position = nav_position
pager.show_indicator = show_indicator
pager.show_titles = show_titles
pager.loop = loop
# Clear existing pages - we'll rebuild them from VNode children
pager.pages.clear()
logger.debug(f"Reused reconciled Pager {vnode.key}")
else:
# Create new Pager element
pager = Pager(
id=vnode.key,
classes=classes,
width=element_width,
height=element_height,
border_style=border_style,
nav_position=nav_position,
show_indicator=show_indicator,
show_titles=show_titles,
loop=loop,
current_page=current_page,
)
pager.bind = bind
pager.focused = focused
if page_state_key:
pager.page_state_key = page_state_key
# Add to reconciled_map so it can be found for event handling
if vnode.key:
reconciled_map[vnode.key] = pager
logger.debug(
f"Created Pager {vnode.key} and added to reconciled_map"
)
# Wire up state_dict for page state persistence
if state_dict is not None:
pager._state_dict = state_dict
# Process each PageContent child to create pages
for page_index, page_vnode in enumerate(vnode.children):
if page_vnode.type != "PageContent":
continue
page_props = page_vnode.props_dict()
title = page_props.get("title", "")
content = page_props.get("content", "")
# Check if PageContent has VNode children (complex content)
if page_vnode.children:
# Build content from PageContent's VNode children
# Create a Frame to hold the content
scroll_state_key = (
f"_scroll_{vnode.key}_page_{page_index}" if vnode.key else None
)
frame = Frame(
width=element_width - 2,
height=element_height - 4,
style=FrameStyle(
scrollable=True,
show_scrollbar=True,
border_style=BorderStyle.SINGLE,
title=title if title else None,
),
)
frame.scroll_state_key = scroll_state_key
# Wire up state_dict for scroll state persistence
if state_dict is not None:
frame._state_dict = state_dict
content_children = []
for child_vnode in page_vnode.children:
child_node = self._build_layout_tree_from_vnode(
child_vnode,
reconciled_map,
state_dict,
root_frame_found=root_frame_found,
)
content_children.append(child_node)
# Create FrameNode with content
frame_node = FrameNode(
frame=frame,
children=content_children,
width=element_width - 2,
height=element_height - 4,
)
# If only text children, extract text for frame content
# Need to recursively check for non-text elements in containers
def has_non_text_elements(nodes: list) -> bool:
for node in nodes:
if hasattr(node, "element"):
if not isinstance(node.element, TextElement):
return True
# Check container children recursively
if hasattr(node, "children") and node.children:
if has_non_text_elements(node.children):
return True
return False
has_non_text = has_non_text_elements(content_children)
if not has_non_text and content_children:
text_parts = []
for child in content_children:
if hasattr(child, "element") and hasattr(
child.element, "text"
):
text_parts.append(child.element.text)
if text_parts:
frame.set_content("\n".join(text_parts))
pager.add_page(Page(title=title, content=frame_node))
else:
# Simple text content
pager.add_page(Page(title=title, content=content))
logger.debug(
f"Built Pager from VNode: {vnode.key} with {len(pager.pages)} pages"
)
return ElementNode(pager, width=width_spec, height=height_spec)
else:
# Regular element (TextInput, Button, etc.)
from wijjit.layout.engine import ElementNode
if vnode.key and vnode.key in reconciled_map:
element = reconciled_map[vnode.key]
else:
# Element not in reconciled map - this is an error
logger.warning(
f"Element {vnode.key} (type={vnode.type}) not found in "
f"reconciled map. This indicates a bug in reconciliation."
)
# Create a placeholder - in production this shouldn't happen
from wijjit.elements.base import TextElement
element = TextElement(
id=vnode.key, text=f"[Missing element: {vnode.type}]"
)
# Extract width and height from layout_spec for ElementNode
width = layout_spec.width if layout_spec.width is not None else "auto"
height = layout_spec.height if layout_spec.height is not None else "auto"
return ElementNode(element, width=width, height=height)
@staticmethod
def _rect_intersects_coverage(
cov: set[int], box: tuple[int, int, int, int], width: int
) -> bool:
"""Whether any cell of ``box`` is present in a packed-coverage set.
Used to keep the skip-unchanged fast path from skipping an element whose
region overlaps cells painted by the frame-border pass (which repaints
every frame, before the element loop). Coverage is packed ``y * width +
x``; ``width`` must match the buffer the coverage was recorded against.
Parameters
----------
cov : set of int
Packed cell positions to test against.
box : tuple of int
``(x, y, width, height)`` region.
width : int
Buffer width used to pack ``cov``.
Returns
-------
bool
True if any cell of the box is in ``cov``.
"""
x, y, bw, bh = box
for ry in range(y, y + bh):
base = ry * width
for rx in range(x, x + bw):
if base + rx in cov:
return True
return False
@staticmethod
def _snapshot_region(
buffer: "ScreenBuffer", box: tuple[int, int, int, int]
) -> list["Cell | None"]:
"""Copy the cells in a rectangular region, for skip-verify comparison.
Parameters
----------
buffer : ScreenBuffer
Buffer to read from.
box : tuple of int
``(x, y, width, height)`` region to snapshot.
Returns
-------
list
The cells (or ``None`` for out-of-bounds positions) in row-major
order. Cells are immutable, so identity/value comparison of two
snapshots detects any change.
"""
x, y, w, h = box
return [buffer.get_cell(x + dx, y + dy) for dy in range(h) for dx in range(w)]
def _compose_output_cells(
self,
elements: list[Element],
width: int,
height: int,
root: "LayoutNode | None" = None,
statusbar: Element | None = None,
allow_incremental: bool = False,
) -> tuple[str, "ScreenBuffer"]:
"""Compose final output using cell-based rendering.
This method implements the NEW cell-based rendering pipeline with
theme support and efficient diff rendering capabilities.
Parameters
----------
elements : list of Element
Elements with assigned bounds
width : int
Output width
height : int
Output height
root : LayoutNode, optional
Root of layout tree (for frame rendering)
statusbar : Element, optional
StatusBar element to render at bottom of screen
Returns
-------
str
Composed ANSI output for terminal
Notes
-----
This method creates a ScreenBuffer, renders all elements to cells,
and converts the buffer to ANSI output. Elements are rendered in
z-order (first to last) using their render_to(ctx) method.
"""
style_resolver = StyleResolver(
self.theme_manager.get_theme(), focus_color=self.focus_color
)
# === Buffer preparation: incremental (damage-tracked) vs full repaint ===
# The incremental path (review 2.5) starts the paint buffer as a copy of
# the previous frame and lets the write paths change-detect, so the diff
# scans only the cells that actually change instead of the whole screen.
# It is only safe when: it is enabled, diff rendering is on, we have a
# previous base buffer of the same size with recorded coverage, the
# caller allows it (no overlays this frame), and last frame had no
# overlay (so the displayed buffer IS the base buffer - the thing our
# copied baseline must match for the diff to be correct).
prev = self._last_base_buffer
incremental = (
allow_incremental
and self.incremental_render
and self.use_diff_rendering
and prev is not None
and self._last_coverage is not None
and prev.width == width
and prev.height == height
and self._last_displayed_buffer is prev
)
# Skip-unchanged-elements gate (review 2.5, option c). Only on the
# incremental path (the copied-in baseline holds each unchanged element's
# cells) and only when the styling context is stable frame-to-frame - the
# per-element signatures capture an element's own inputs but not the
# active theme, so a theme/focus-color change must repaint everything.
# ``style_epoch`` bumps when ``set_theme`` swaps the active Theme object
# or the focus color changes.
memo = self.skip_unchanged_elements
style_epoch: tuple[int, Any] = (
id(self.theme_manager.get_theme()),
self.focus_color,
)
skip_enabled = incremental and memo and style_epoch == self._last_style_epoch
self._last_style_epoch = style_epoch
# Acquire a paint buffer, reusing the retired base buffer from two frames
# ago (pooled in _spare_base_buffer) when its size matches. That buffer
# is neither the current base nor the displayed buffer, so painting into
# it cannot corrupt the diff's "old" side.
buffer = self._spare_base_buffer
self._spare_base_buffer = None
if buffer is None or buffer.width != width or buffer.height != height:
buffer = ScreenBuffer(width, height)
if incremental:
# Baseline = previous frame; change-detection then dirties only real
# changes. prev is not None (guarded above).
assert prev is not None # guaranteed by `incremental` above
buffer.copy_from(prev)
buffer.start_tracking(damage=True)
else:
# Full repaint: blank baseline + conservative dirty from the dirty
# manager (or whole screen on the first render), exactly as before.
buffer.reset()
if self.use_diff_rendering:
if self._last_displayed_buffer is None:
# First render: mark the whole screen dirty regardless of
# what the dirty manager holds (initial focus/hover setup may
# have marked only individual elements).
buffer.mark_all_dirty()
elif self.dirty_manager.is_dirty():
if self.dirty_manager.is_full_screen_dirty():
buffer.mark_all_dirty()
else:
for x, y, w, h in self.dirty_manager.get_merged_regions():
buffer.mark_dirty(x, y, w, h)
# Empty dirty manager -> nothing dirty -> diff emits nothing.
# Track coverage (without damage-detection) so the next frame can go
# incremental: it needs to know which cells this frame painted.
buffer.start_tracking(damage=False)
# First pass: Render frame borders if we have a layout tree
if root is not None:
self._render_frames_to_buffer(root, buffer, style_resolver)
# Snapshot the cells the frame-border pass just painted (review 2.5,
# option c). The border pass runs every frame unconditionally, before the
# element loop; a skipped element does not repaint over it. So an element
# whose region overlaps a border cell must NOT be skipped - the border
# would otherwise win and the element's content there would be lost (e.g.
# frame content that bleeds onto an ancestor frame's border). Only needed
# when skipping is possible this frame.
border_cov: set[int] = set()
if skip_enabled:
border_cov = buffer.peek_coverage()
# Second pass: Render elements to the buffer
for element in elements:
if element.bounds is None:
if memo:
_invalidate_paint_memo(element)
continue
# Reset the painted-bounds cache; it is set below only if the
# element is actually painted this frame (not clipped/scrolled out),
# so hit-testing never matches a stale or invisible position.
element._screen_bounds = None
# Check if element is inside one or more frames (scrollable or not).
# Walk the FULL parent_frame chain so the element is clipped to the
# intersection of every ancestor frame's visible content area, and
# offset by the total scroll of all scrollable ancestors. Clipping to
# only the innermost frame let a child that was scrolled out of an
# OUTER frame paint outside it.
scroll_offset = 0
clip_region = None
skip_element = False
# An ancestor frame with overflow_x="visible" lets this element paint
# beyond its own bounds (and over the always-repainted frame borders),
# which breaks the disjoint-bounds assumption the skip-unchanged fast
# path relies on. Such elements are never skipped (see should_skip).
element_can_overflow = False
if getattr(element, "parent_frame", None) is not None:
# Collect the ancestor frame chain, innermost first.
frame_chain = []
parent = element.parent_frame
while parent is not None:
frame_chain.append(parent)
parent = getattr(parent, "parent_frame", None)
element_can_overflow = any(
getattr(f.style, "overflow_x", "clip") == "visible"
for f in frame_chain
)
# Walk outermost -> innermost, accumulating the scroll contributed
# by frames *outer* than the one being processed (a frame's own
# scroll offset moves its children, not itself).
#
# Only the innermost frame (base clip, preserving historical
# behavior) and any ancestor that *actually scrolls*
# (_needs_scroll) contribute to the clip. A non-scrolling
# ancestor is left out so child frames that slightly overflow a
# static parent keep rendering as before; the multi-level
# intersection exists to stop content scrolled out of an outer
# scrollable frame from painting outside it.
innermost_frame = frame_chain[0] if frame_chain else None
outer_scroll = 0
for frame in reversed(frame_chain):
contributes_clip = frame is innermost_frame or (
frame.style.scrollable and frame._needs_scroll
)
if frame.bounds is not None and contributes_clip:
padding_top, padding_right, padding_bottom, padding_left = (
frame.style.padding
)
scrollbar_width = (
1
if frame.style.show_scrollbar and frame._needs_scroll
else 0
)
# This frame's visible content rect, at its on-screen
# position (shifted up by the scroll of outer frames).
rect = Bounds(
x=frame.bounds.x + 1 + padding_left, # +1 left border
y=frame.bounds.y - outer_scroll + 1 + padding_top,
width=max(
1,
frame.bounds.width
- 2 # left + right borders
- padding_left
- padding_right
- scrollbar_width,
),
height=max(
1,
frame.bounds.height
- 2 # top + bottom borders
- padding_top
- padding_bottom,
),
)
if clip_region is None:
clip_region = rect
else:
# Intersect with the running clip; empty => the
# element is entirely hidden by an ancestor frame.
ix = max(clip_region.x, rect.x)
iy = max(clip_region.y, rect.y)
ix2 = min(
clip_region.x + clip_region.width, rect.x + rect.width
)
iy2 = min(
clip_region.y + clip_region.height,
rect.y + rect.height,
)
if ix2 <= ix or iy2 <= iy:
skip_element = True
break
clip_region = Bounds(
x=ix, y=iy, width=ix2 - ix, height=iy2 - iy
)
# A frame's own scroll shifts the frames nested inside it.
if frame.style.scrollable and frame._needs_scroll:
outer_scroll += frame.get_scroll_offset()
scroll_offset = outer_scroll
if skip_element:
if memo:
_invalidate_paint_memo(element)
continue
# Adjust element bounds for scroll offset
adjusted_bounds = Bounds(
x=element.bounds.x,
y=element.bounds.y - scroll_offset,
width=element.bounds.width,
height=element.bounds.height,
)
# Skip if element is completely outside visible area
if clip_region is not None:
clip_top = clip_region.y
clip_bottom = clip_region.y + clip_region.height
if adjusted_bounds.y + adjusted_bounds.height <= clip_top:
if memo:
_invalidate_paint_memo(element)
continue
if adjusted_bounds.y >= clip_bottom:
if memo:
_invalidate_paint_memo(element)
continue
# On-screen rect this element paints into: scroll-adjusted bounds
# clipped to the visible area. Computed up front so it can seed both
# the skip-unchanged decision and the hit-testing cache below.
if clip_region is not None:
screen_bounds = adjusted_bounds.intersect(clip_region)
else:
screen_bounds = adjusted_bounds
# === Skip-unchanged-elements fast path (review 2.5, option c) ===
# An element whose render signature and on-screen geometry match its
# last paint needs no repaint: its cells are already correct in the
# copied-in baseline. Re-record its previous painted region as
# coverage (so vacated-cell blanking spares it) and move on. A None
# signature always repaints. ``verify_skips`` paints anyway and
# asserts the region is unchanged, catching an incomplete signature.
cur_sig = element.render_signature() if memo else None
cur_geom: Any = None
if memo:
cur_geom = (
adjusted_bounds.x,
adjusted_bounds.y,
adjusted_bounds.width,
adjusted_bounds.height,
(
None
if clip_region is None
else (
clip_region.x,
clip_region.y,
clip_region.width,
clip_region.height,
)
),
scroll_offset,
)
should_skip = (
skip_enabled
and not element_can_overflow
and screen_bounds is not None
and cur_sig is not None
and cur_sig == element._paint_signature
and cur_geom == element._paint_geometry
and element._paint_coverage is not None
)
if (
should_skip
and border_cov
and self._rect_intersects_coverage(
border_cov, element._paint_coverage, buffer.width
)
):
# The border pass painted into this element's region; skipping
# would leave the border on top. Repaint instead.
should_skip = False
if should_skip and not self._verify_skips:
assert element._paint_coverage is not None # guarded by should_skip
buffer.record_coverage_rect(*element._paint_coverage)
element._screen_bounds = screen_bounds
continue
# Create paint context for this element with clip region
ctx = PaintContext(
buffer=buffer,
style_resolver=style_resolver,
bounds=adjusted_bounds,
clip_region=clip_region,
)
# Verify mode: snapshot the region a skippable element would have
# blitted, then paint and assert the paint reproduced it exactly.
verify_box = (
element._paint_coverage
if (should_skip and self._verify_skips)
else None
)
before = (
self._snapshot_region(buffer, verify_box)
if verify_box is not None
else None
)
# Render element using cell-based rendering
element.render_to(ctx)
if verify_box is not None:
after = self._snapshot_region(buffer, verify_box)
if before != after:
raise AssertionError(
"skip-unchanged verify failed for "
f"{type(element).__name__} id={element.id!r}: "
"render_signature() reported unchanged but the paint "
"differs from the baseline - the signature is missing a "
"render input."
)
# Record the on-screen rect actually painted (scroll-adjusted and
# clipped to the visible area) so mouse hit-testing matches where
# the element visually appears, not its unscrolled logical bounds.
element._screen_bounds = screen_bounds
# Refresh the skip-unchanged paint memo from this frame's paint. A
# None screen_bounds means the element was fully clipped horizontally
# and painted nothing visible - invalidate so it repaints next time.
if memo:
if screen_bounds is not None:
element._paint_signature = cur_sig
element._paint_geometry = cur_geom
element._paint_coverage = (
screen_bounds.x,
screen_bounds.y,
screen_bounds.width,
screen_bounds.height,
)
else:
_invalidate_paint_memo(element)
# Third pass: Render statusbar if present
if statusbar is not None:
# Position statusbar at bottom of screen
statusbar_bounds = Bounds(x=0, y=height - 1, width=width, height=1)
statusbar.set_bounds(statusbar_bounds)
# Create paint context for statusbar
statusbar_ctx = PaintContext(
buffer=buffer,
style_resolver=style_resolver,
bounds=statusbar_bounds,
)
# Render statusbar using cell-based rendering
if hasattr(statusbar, "render_to") and callable(statusbar.render_to):
statusbar.render_to(statusbar_ctx)
# Finish damage tracking. On the incremental path, blank any cell that
# was painted last frame but not this one (vacated content, e.g. a label
# that got shorter or a row that was removed) - the copied-in baseline
# still holds the stale glyph there, so set it back to blank. set_cell
# change-detects and dirties it, so the diff emits the erase. Coverage
# was ended first so these blanking writes are not themselves counted as
# painted content for the next frame's vacated calculation.
this_coverage = buffer.end_tracking()
if incremental and self._last_coverage:
blank = buffer.blank_cell
w = buffer.width
# Coverage is packed as ``y * width + x``; the incremental path is
# gated on an unchanged buffer size, so ``w`` matches the width used
# to pack ``_last_coverage`` on the previous frame.
for p in self._last_coverage - this_coverage:
buffer.set_cell(p % w, p // w, blank)
self._last_coverage = this_coverage
# Convert buffer to ANSI string for terminal output
# IMPORTANT: Do this BEFORE storing buffer, so diff renderer compares
# old buffer (or None) with new buffer, not new buffer with itself!
output = self._buffer_to_ansi(buffer)
# Retire the previous base buffer into the single-slot pool so the next
# frame can paint into it instead of allocating. prev is neither the new
# base nor (once overlays composite) the displayed buffer.
self._spare_base_buffer = prev
# Store base buffer for next render (after converting!)
# Base buffer: The view without overlays (always preserved)
# NOTE: We update displayed buffer here for normal rendering flow
# This will be overwritten by composite_overlays if overlays are present
self._last_base_buffer = buffer
self._last_displayed_buffer = buffer
# NOTE: Dirty regions are NOT cleared here because overlay compositing
# may still need them. The caller (app._render) will clear them after
# all compositing is complete.
# Return both output and buffer
return output, buffer
def _render_frames_to_buffer(
self,
node: "LayoutNode",
buffer: ScreenBuffer,
style_resolver: StyleResolver,
scroll_offset: int = 0,
clip_region: Bounds | None = None,
) -> None:
"""Recursively render frame borders to cell buffer.
Parameters
----------
node : LayoutNode
Current layout node
buffer : ScreenBuffer
Target buffer
style_resolver : StyleResolver
Style resolver for theme styles
scroll_offset : int, optional
Vertical scroll offset from parent scrollable frame (default: 0)
clip_region : Bounds, optional
Clip region from parent scrollable frame (default: None)
Notes
-----
This recursively walks the layout tree and renders frame borders
to the cell buffer. Frame content is not rendered here - only
the border decorations.
When rendering frames inside a scrollable parent, the scroll_offset
and clip_region are passed down to adjust positions and prevent
rendering outside the visible area.
"""
# Render this frame's border if it's a FrameNode
if isinstance(node, FrameNode):
frame = node.frame
bounds = frame.bounds
if bounds is None:
return
style = frame.style
# Reset painted-bounds cache; set below once the frame is confirmed
# at least partially visible (see the element pass for rationale).
frame._screen_bounds = None
# Adjust bounds for scroll offset if inside a scrollable parent
if scroll_offset != 0:
adjusted_bounds = Bounds(
x=bounds.x,
y=bounds.y - scroll_offset,
width=bounds.width,
height=bounds.height,
)
else:
adjusted_bounds = bounds
# Skip if frame is completely outside clip region
if clip_region is not None:
clip_top = clip_region.y
clip_bottom = clip_region.y + clip_region.height
if adjusted_bounds.y + adjusted_bounds.height <= clip_top:
return # Frame is above visible area
if adjusted_bounds.y >= clip_bottom:
return # Frame is below visible area
# Record the frame's painted on-screen rect for hit-testing (e.g.
# routing scroll-wheel events to a scrolled, nested frame).
frame._screen_bounds = (
adjusted_bounds.intersect(clip_region)
if clip_region is not None
else adjusted_bounds
)
# Resolve frame border style from theme. Mirror Frame.render_to's
# focus handling: a frame whose body holds child elements draws its
# top/bottom borders (focus-aware) in Frame.render_to but skips the
# body, so the vertical side borders come only from THIS pass. If we
# ignored focus here, those verticals would stay the unfocused color
# while the top/bottom went focus-colored - the "half-highlighted"
# frame glitch. Resolving the ``:focus`` border when focused keeps
# all four sides consistent.
prefix = frame.style_prefix
if frame.focused:
border_style = style_resolver.resolve_style(
frame, f"{prefix}.border:focus"
)
else:
border_style = style_resolver.resolve_style_by_class(f"{prefix}.border")
# Get border characters
border_chars = BORDER_CHARS_UNICODE.get(
style.border_style, BORDER_CHARS_UNICODE[BorderStyle.SINGLE]
)
# Create paint context for frame with adjusted bounds and clip region
ctx = PaintContext(buffer, style_resolver, adjusted_bounds, clip_region)
# Draw frame border
ctx.draw_border(
0,
0,
adjusted_bounds.width,
adjusted_bounds.height,
border_style,
border_chars,
)
# Draw title if present (left-aligned like Frame.render_to)
if style.title:
title_text = f" {style.title} "
title_len = visible_length(title_text)
# Left align after the first horizontal character when space allows
title_x = 2 if adjusted_bounds.width > 4 else 1
if title_x + title_len >= adjusted_bounds.width - 1:
# Not enough space - fall back to starting right after corner
title_x = 1
# Resolve frame title style (matches runtime rendering)
title_style = style_resolver.resolve_style(frame, "frame.title")
ctx.write_text(title_x, 0, title_text, title_style, clip=True)
# Calculate scroll context for children if this frame is scrollable
child_scroll_offset = scroll_offset
child_clip_region = clip_region
if style.scrollable and frame._needs_scroll:
# This frame is scrollable - set up scroll context for children
child_scroll_offset = scroll_offset + frame.get_scroll_offset()
# Calculate clip region (visible content area inside frame)
padding_top, padding_right, padding_bottom, padding_left = style.padding
scrollbar_width = (
1 if style.show_scrollbar and frame._needs_scroll else 0
)
# Use adjusted_bounds for clip calculation since children render relative to scrolled position
new_clip_x = adjusted_bounds.x + 1 + padding_left
new_clip_y = adjusted_bounds.y + 1 + padding_top
new_clip_width = (
adjusted_bounds.width
- 2 # Left and right borders
- padding_left
- padding_right
- scrollbar_width
)
new_clip_height = (
adjusted_bounds.height
- 2 # Top and bottom borders
- padding_top
- padding_bottom
)
child_clip_region = Bounds(
x=new_clip_x,
y=new_clip_y,
width=max(1, new_clip_width),
height=max(1, new_clip_height),
)
# Recursively render children with scroll context
for child in node.content_container.children:
self._render_frames_to_buffer(
child,
buffer,
style_resolver,
child_scroll_offset,
child_clip_region,
)
elif isinstance(node, Container):
# Regular container - pass scroll context through
for child in node.children:
self._render_frames_to_buffer(
child, buffer, style_resolver, scroll_offset, clip_region
)
def _buffer_to_ansi(self, buffer: ScreenBuffer) -> str:
"""Convert cell buffer to ANSI string for terminal output.
Parameters
----------
buffer : ScreenBuffer
Source buffer
Returns
-------
str
ANSI string representation
Notes
-----
This method uses DiffRenderer for efficient output generation.
When use_diff_rendering is True::
First render is full (with screen clear), subsequent renders
only output changes (diffs).
When use_diff_rendering is False::
Always does full render with screen clear. This is more
reliable but less efficient.
"""
if self.use_diff_rendering:
# Diff mode: compare with what's on screen and only output changes
return self._diff_renderer.render_diff(self._last_displayed_buffer, buffer)
else:
# Full render mode: always clear and redraw everything
return self._diff_renderer.render_diff(None, buffer)
[docs]
def get_last_buffer(self) -> ScreenBuffer | None:
"""Get the last rendered buffer for external diff rendering.
Returns
-------
ScreenBuffer or None
Last rendered buffer, or None if not yet rendered
"""
return self._last_displayed_buffer
[docs]
def invalidate_display(self) -> None:
"""Discard the cached on-screen buffers so the next render is full.
Diff rendering compares the new frame against ``_last_displayed_buffer``
and emits only the cells that changed, so bytes written to the terminal
out-of-band (foreign ``stdout``, a stray traceback, a subprocess) are
invisible to the differ and persist on screen. Clearing the cached
buffers forces the next ``render_diff`` down the ``_full_render`` path -
a screen clear plus a complete repaint - which overwrites any such
bytes. Both the base and displayed buffers are cleared so overlay
compositing also starts from a clean slate.
"""
self._last_base_buffer = None
self._last_displayed_buffer = None
# Drop incremental state too: with no base to diff against, the next
# frame must be a full repaint, and last frame's coverage no longer
# corresponds to anything on screen.
self._last_coverage = None
self._spare_base_buffer = None
[docs]
def get_buffer_as_text(self) -> str:
"""Get the last rendered buffer as plain text (for testing).
Returns
-------
str
Plain text representation of the last buffer
Notes
-----
This is primarily used for test assertions where you want to check
for visible text content without parsing ANSI escape sequences.
Returns empty string if no buffer has been rendered yet.
Examples
--------
>>> renderer = Renderer()
>>> output, _, _ = renderer.render_with_layout(template, 80, 24)
>>> text = renderer.get_buffer_as_text()
>>> 'Welcome' in text
True
"""
if self._last_displayed_buffer is None:
return ""
return self._last_displayed_buffer.to_text()
def _composite_overlays_cells(
self,
overlay_elements: list[Element],
width: int,
height: int,
apply_dimming: bool = False,
dim_factor: float = 0.6,
force_full_redraw: bool = False,
) -> str:
"""Composite overlay elements on cell buffer.
Parameters
----------
overlay_elements : list of Element
Overlay elements to render
width : int
Screen width
height : int
Screen height
apply_dimming : bool
Whether to dim the base
dim_factor : float
Dimming intensity
force_full_redraw : bool
Force full screen redraw instead of diff rendering
Returns
-------
str
ANSI output with overlays composited
Notes
-----
This method composites overlays directly on the cell buffer,
then renders the entire result. This is cell-aware and works
correctly with diff rendering.
"""
logger = get_logger(__name__)
logger.debug(
f"_composite_overlays_cells: overlay_count={len(overlay_elements)}, "
f"force_full_redraw={force_full_redraw}, has_base_buffer={self._last_base_buffer is not None}"
)
# Create a new buffer for compositing
# IMPORTANT: Start from BASE buffer (not displayed buffer!)
# This ensures overlays are always composited on top of the clean base,
# not on top of previous overlay composites.
composite_buffer = ScreenBuffer(width, height)
# Copy cells from BASE buffer (view without overlays)
if self._last_base_buffer:
for y in range(min(height, self._last_base_buffer.height)):
for x in range(min(width, self._last_base_buffer.width)):
# Create a copy of the cell to avoid mutating the original
original_cell = self._last_base_buffer.cells[y][x]
composite_buffer.cells[y][x] = copy(original_cell)
# Apply dimming to base if requested
if apply_dimming:
for y in range(height):
for x in range(width):
cell = composite_buffer.cells[y][x]
# Dim by reducing RGB values
if cell.fg_color:
r, g, b = cell.fg_color
cell.fg_color = (
int(r * dim_factor),
int(g * dim_factor),
int(b * dim_factor),
)
if cell.bg_color:
r, g, b = cell.bg_color
cell.bg_color = (
int(r * dim_factor),
int(g * dim_factor),
int(b * dim_factor),
)
# Render overlays onto composite buffer
style_resolver = StyleResolver(
self.theme_manager.get_theme(), focus_color=self.focus_color
)
for element in overlay_elements:
if element.bounds is None:
continue
# Create paint context for overlay element
ctx = PaintContext(
buffer=composite_buffer,
style_resolver=style_resolver,
bounds=element.bounds,
)
# Render overlay using cell-based rendering
element.render_to(ctx)
# Now render the composite buffer
# Diff from what's currently on screen (displayed buffer) to new composite
# IMPORTANT: Don't touch _last_base_buffer - it stays as the base view
# Only update _last_displayed_buffer to track what's actually on screen
# When force_full_redraw is True, mark entire buffer dirty to ensure complete redraw
# This is a defensive measure to prevent ghost remnants from dismissed overlays
if force_full_redraw:
composite_buffer.mark_all_dirty()
if self.use_diff_rendering and not force_full_redraw:
# Diff from current display to new composite
output = self._diff_renderer.render_diff(
self._last_displayed_buffer, composite_buffer
)
else:
# Full redraw (either diff rendering is disabled, or force_full_redraw is True)
output = self._diff_renderer.render_diff(None, composite_buffer)
# Update what's displayed (composite with overlays)
# Don't touch _last_base_buffer - it preserves the base view
self._last_displayed_buffer = composite_buffer
return output
[docs]
def composite_overlays(
self,
base_output: str,
overlay_elements: list[Element],
width: int,
height: int,
apply_dimming: bool = False,
dim_factor: float = 0.6,
force_full_redraw: bool = False,
) -> str:
"""Composite overlay elements on top of base output.
This method renders overlays on top of the base UI output, optionally
applying backdrop dimming for modal effects.
Parameters
----------
base_output : str
Base UI output (ANSI string or newline-separated lines)
overlay_elements : list of Element
Overlay elements with assigned bounds (in z-order, lowest first)
width : int
Screen width
height : int
Screen height
apply_dimming : bool
Whether to dim the base output (for modal backdrops)
dim_factor : float
Dimming intensity (0.0 = black, 1.0 = original). Default is 0.6.
force_full_redraw : bool
Force full screen redraw instead of diff rendering
Returns
-------
str
Composited output with overlays on top
Notes
-----
Overlays are rendered in the order provided (first = bottom, last = top).
If apply_dimming is True, the base output is dimmed before overlays
are composited.
Overlays are composited directly on the cell buffer for efficient rendering.
"""
# Composite overlays on base buffer
if self._last_base_buffer is not None:
return self._composite_overlays_cells(
overlay_elements,
width,
height,
apply_dimming,
dim_factor,
force_full_redraw,
)
# No buffer available - return base output unchanged
return base_output
[docs]
def clear_cache(self) -> None:
"""Clear the template cache."""
self._string_templates.clear()
[docs]
def clear_element_cache(self) -> None:
"""Clear the reconciler element cache and VNode tree.
This should be called when navigating to a different view
to avoid stale ephemeral state transfer.
"""
self._reconciler.clear_cache()
self._last_vnode_tree = None