"""Jinja2 template extensions for layout elements.
This module provides Jinja2 template tag extensions for layout containers
including frames, vertical stacks (vstack), and horizontal stacks (hstack).
Also provides utility functions for processing template body content.
"""
from __future__ import annotations
import re
import textwrap
from ast import literal_eval
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Literal, cast
from jinja2 import nodes
from jinja2.ext import Extension
from jinja2.parser import Parser
from wijjit.core.render_context import get_render_context
from wijjit.core.vdom import LAYOUT_META, VNodeBuilder
from wijjit.layout.frames import BorderStyle
from wijjit.logging_config import get_logger
if TYPE_CHECKING:
pass
logger = get_logger(__name__)
# Pre-compiled regex patterns for element marker handling
# Pattern to match element markers (with capture group for digit extraction)
_MARKER_EXTRACT_PATTERN = re.compile(r"\x00ELEM_(\d+)\x00")
# Pattern for splitting (without capture group to avoid nested groups)
_MARKER_SPLIT_PATTERN = re.compile(r"(\x00ELEM_\d+\x00)")
def safe_int(value: Any, default: int = 0, name: str = "value") -> int:
"""Safely convert a value to int with error handling.
Parameters
----------
value : Any
Value to convert to int
default : int, optional
Default value if conversion fails (default: 0)
name : str, optional
Name of the value for logging (default: "value")
Returns
-------
int
Converted integer or default value
"""
if isinstance(value, int):
return value
try:
return int(value)
except (ValueError, TypeError) as e:
logger.warning(f"Invalid {name} '{value}', using default {default}: {e}")
return default
def parse_tag_attributes(
parser: Parser, end_tag: str, lineno: int
) -> list[nodes.Keyword]:
"""Parse tag attributes as keyword arguments.
This helper consolidates the common attribute parsing pattern used by all
template tag extensions. It parses key=value pairs until it encounters
the end tag.
Parameters
----------
parser : Parser
Jinja2 parser instance
end_tag : str
The end tag name (e.g., "endvstack", "endbutton")
lineno : int
Line number for creating Keyword nodes
Returns
-------
list[nodes.Keyword]
List of parsed keyword arguments
"""
kwargs: list[nodes.Keyword] = []
while parser.stream.current.test("name") and not parser.stream.current.test(
f"name:{end_tag}"
):
key = parser.stream.expect("name").value
if parser.stream.current.test("assign"):
parser.stream.expect("assign")
value = parser.parse_expression()
kwargs.append(nodes.Keyword(key, value, lineno=lineno))
else:
# Valueless attributes (e.g. ``{% button disabled %}``) are not
# supported: every attribute must be ``key=value``. Bailing out
# here silently dropped this attribute *and every attribute after
# it*, which is a confusing failure. Warn naming the offender so
# the author can add ``=value`` (e.g. ``disabled=true``).
logger.warning(
"Template tag ending in %r got a valueless attribute %r on "
"line %d; attributes must be written as key=value. This "
"attribute and any following it are ignored.",
end_tag,
key,
lineno,
)
break
return kwargs
def normalize_element_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
"""Normalize element kwargs for consistent attribute naming.
This helper handles common attribute name conversions:
- ``class`` -> ``classes`` (since ``class`` is a Python keyword)
- ``tabindex`` -> ``tab_index`` (HTML-style to Python-style)
Parameters
----------
kwargs : dict
Raw keyword arguments from template tag
Returns
-------
dict
Normalized keyword arguments
Examples
--------
>>> normalize_element_kwargs({'class': 'btn', 'tabindex': 1})
{'classes': 'btn', 'tab_index': 1}
"""
result = {}
for key, val in kwargs.items():
if key == "class":
result["classes"] = val
elif key == "tabindex":
result["tab_index"] = val
else:
result[key] = val
return result
def auto_element_id(
layout_context: Any, element_type: str, kwargs: dict[str, Any]
) -> str:
"""Generate an element id, derived from an explicit ``key`` when present.
Input elements bind their value to ``state[id]``, so a stable reconciliation
key alone is not enough to keep typed data with the right row: the *state
key* must also be stable per row. When a ``key`` attribute is present this
returns ``f"{element_type}_{key}"`` - a stable, per-row id in the same shape
as the positional fallback (``textinput_0``) but keyed by the row's identity
instead of its position. Without a ``key`` it falls back to the positional
:meth:`LayoutContext.generate_id`, preserving the original behavior.
The ``key`` attribute is only peeked here, not consumed;
:func:`apply_reconciliation_key` removes it from ``kwargs`` before props are
forwarded.
Parameters
----------
layout_context : LayoutContext
The active layout context (source of the positional counter).
element_type : str
The element type prefix (e.g. ``"textinput"``).
kwargs : dict
Raw template kwargs; read (not popped) for a ``key`` entry.
Returns
-------
str
The resolved element id.
"""
key = kwargs.get("key")
if key is not None:
return f"{element_type}_{key}"
return cast(str, layout_context.generate_id(element_type))
def resolve_bind_key(
bind: bool | str,
id: str | None,
default_key: str | None = None,
) -> str | None:
"""Resolve which state key an element binds to.
The single source of truth for the ``bind`` attribute, used by both halves
of the binding: the tags (which read ``state[key]`` at render time) and
:class:`~wijjit.core.wiring.ElementWiringManager` (which writes it back on
change). Keeping one resolver is what makes ``bind`` mean the same thing on
every element.
``bind`` is a bool *or* a state key name. As a bool it selects the
element's default key, which fuses identity with storage: ``id="name"``
binds to ``state["name"]``. As a string it names the key outright, so the
id is free to stay an identity - two widgets can share one key, and giving
an element an id no longer colonizes that state key.
Parameters
----------
bind : bool or str
``True`` to bind to the default key, ``False`` (or ``""``) not to bind,
or a state key name to bind to that key.
id : str or None
The element's id - the default key for most elements.
default_key : str or None, optional
The default key when it is not the id. Radio and RadioGroup pass their
(already-resolved) group ``name``, because a radio group's identity is
the group, not the individual element.
Returns
-------
str or None
The state key to bind to, or None when this element does not bind.
Examples
--------
>>> resolve_bind_key(True, "volume")
'volume'
>>> resolve_bind_key("settings_volume", "volume") # id stays an identity
'settings_volume'
>>> resolve_bind_key(False, "volume") is None
True
"""
if not bind:
# Covers False, None, and "" - an empty key name binds to nothing.
return None
if isinstance(bind, str):
# isinstance(True, str) is False, so a bool can never land here.
return bind
return default_key or id
def apply_reconciliation_key(vnode: Any, kwargs: dict[str, Any]) -> None:
"""Override a VNode's reconciliation key from an explicit ``key`` attribute.
An element's reconciliation key is its identity across renders: two VNodes
with the same key are treated as the *same* element and updated in place
rather than replaced, so the element instance - and the transient state it
carries (a text input's value and cursor, a tree's scroll and selection) -
is reused. By default the key is the element ``id``, which for an element in
a ``{% for %}`` is an auto-generated *positional* id (``textinput_0``,
``textinput_1``, ...). That makes a row's identity purely its position:
inserting or reordering rows silently migrates typed input to whatever row
now sits at the old slot.
An explicit ``key`` attribute breaks the coupling. It sets the
reconciliation identity independently of ``id``, so a loop can key rows by a
stable value (``{% textinput key=row.id %}``) and keep each row's state with
the right logical row across inserts and reorders, without ``id`` having to
double as - and collide in - the state namespace. The attribute is consumed
from ``kwargs`` so it is not also forwarded as an element prop.
Parameters
----------
vnode : VNodeBuilder
The VNode builder whose ``key`` is overridden when ``key`` is present.
kwargs : dict
Raw template kwargs; a ``key`` entry, if present, is popped and applied.
"""
key = kwargs.pop("key", None)
if key is not None:
vnode.key = str(key)
def forward_extra_props(vnode: Any, kwargs: dict[str, Any]) -> None:
"""Normalize kwargs, apply common attributes, and forward extras as props.
This is the single choke point every VNode-building tag uses for the
leftover keyword arguments the template supplied but the tag did not bind to
a named parameter. It normalizes the HTML-style spellings via
:func:`normalize_element_kwargs` (``class`` -> ``classes``, ``tabindex`` ->
``tab_index``). Consumes ``class``/``classes``, ``tabindex``/``tab_index``,
and ``key``, and forwards every other non-layout attribute onto the VNode so
the reconciler and the devtools validator can see it (unknown names surface
as ``unknown-attribute`` findings instead of vanishing silently).
Attributes named in :data:`wijjit.core.vdom.LAYOUT_META` are excluded: layout
attrs flow through :meth:`VNodeBuilder.set_layout`, whose ``width``/``height``
"only if not already set" sync must win, so they must not be pre-seeded here.
Not used by the dialog/menu tags, which build ``overlay_info`` dicts rather
than VNodes (there is no VNode consumer for the forwarded props).
Parameters
----------
vnode : VNodeBuilder
The VNode builder being populated for the element.
kwargs : dict
Raw keyword arguments captured from the template tag. Both the
HTML-style (``class``/``tabindex``) and Python-style
(``classes``/``tab_index``) spellings are accepted. This mapping is not
mutated - :func:`normalize_element_kwargs` returns a copy - so callers
may still read from ``kwargs`` afterwards.
"""
normalized = normalize_element_kwargs(kwargs)
# An explicit ``key`` overrides the reconciliation identity (see
# :func:`apply_reconciliation_key`); consume it so it is not forwarded.
key = normalized.pop("key", None)
if key is not None:
vnode.key = str(key)
classes = normalized.pop("classes", None)
if classes is not None:
vnode.set_prop("classes", classes)
tab_index = normalized.pop("tab_index", None)
if tab_index is not None:
vnode.set_prop("tab_index", tab_index)
for name, value in normalized.items():
if name in LAYOUT_META:
continue
vnode.set_prop(name, value)
def apply_common_attributes(vnode: Any, kwargs: dict[str, Any]) -> None:
"""Forward the common normalized template attributes onto a VNode.
Thin wrapper over :func:`forward_extra_props`, kept as the entry point for
tags that read their attributes as named parameters (layout containers,
charts, most display tags). Every element shares two HTML-style attributes
whose spelling differs from the Python prop name: ``class`` (-> ``classes``,
since ``class`` is a Python keyword) and ``tabindex`` (-> ``tab_index``);
those are normalized and set here, an explicit ``key`` is honored, and any
remaining non-layout attribute is forwarded as a prop so template typos
surface as ``unknown-attribute`` validator findings instead of vanishing.
Parameters
----------
vnode : VNodeBuilder
The VNode builder being populated for the element.
kwargs : dict
Raw keyword arguments captured from the template tag. Both the
HTML-style (``class``/``tabindex``) and Python-style
(``classes``/``tab_index``) spellings are accepted.
"""
forward_extra_props(vnode, kwargs)
def process_body_content(body_output: str, raw: bool = False) -> str:
"""Process template body content with optional whitespace stripping.
Parameters
----------
body_output : str
Raw body content from caller()
raw : bool, optional
If True, preserve whitespace. If False, dedent and strip (default: False)
Returns
-------
str
Processed body content
"""
if not body_output or not body_output.strip():
return ""
if raw:
# Raw mode: preserve all whitespace
return body_output
else:
# Default mode: dedent and strip
return textwrap.dedent(body_output).strip()
def interleave_text_and_vnode_builders(
body_output: str,
vnode_children: list[Any],
raw: bool,
layout_context: Any,
) -> list[Any]:
"""Interleave text content with child VNodeBuilders based on markers.
Child elements insert markers like '\x00ELEM_0\x00' into body_output
to indicate their position in the template source. This function
parses those markers and rebuilds the children list with text
VNodes interleaved at the correct positions.
Parameters
----------
body_output : str
Template body output containing text and element markers
vnode_children : list
Child VNodeBuilders that were added (in order they were added)
raw : bool
If True, preserve whitespace in text. If False, dedent/strip
layout_context : LayoutContext
Layout context for generating unique keys
Returns
-------
list
New children list with text and elements properly interleaved as VNodeBuilders
"""
from wijjit.core.vdom import VNodeBuilder
# Split body_output on markers, keeping the markers
parts = _MARKER_SPLIT_PATTERN.split(body_output)
result = []
for part in parts:
# Check if this is a marker
marker_match = _MARKER_EXTRACT_PATTERN.match(part)
if marker_match:
# This is an element marker
elem_index = int(marker_match.group(1))
if 0 <= elem_index < len(vnode_children):
result.append(vnode_children[elem_index])
else:
# Out-of-range marker - this indicates a template processing bug
logger.warning(
f"Element marker index {elem_index} out of range "
f"(0-{len(vnode_children) - 1}). Element may be missing from output."
)
else:
# This is text content
processed_text = process_body_content(part, raw=raw)
if processed_text:
# Generate unique key for text element
text_key = layout_context.generate_id("text")
# Create TextElement VNodeBuilder with key
text_vnode = VNodeBuilder("TextElement", key=text_key)
text_vnode.set_prop("id", text_key) # Set id prop for reconciler cache
text_vnode.set_prop("text", processed_text)
text_vnode.set_prop("wrap", not raw)
text_vnode.set_layout(width="auto", height="auto")
result.append(text_vnode)
return result
def get_element_marker(layout_context: LayoutContext) -> str:
"""Get marker for element that was just added to layout context.
This should be called after add_vnode() to get the marker string
for the element that was just added. The marker indicates the element's
position in the parent's VNode children list.
Parameters
----------
layout_context : LayoutContext
Layout context
Returns
-------
str
Marker string to return from element extension (e.g., '\x00ELEM_0\x00')
"""
if layout_context.vnode_stack:
parent = layout_context.vnode_stack[-1]
# Get index of element that was just added to the VNode tree
elem_index = len(parent.children) - 1
return f"\x00ELEM_{elem_index}\x00"
return ""
class LayoutContext:
"""Context for building layout tree during template rendering.
The VNode stack is used for VNode-based reconciliation. The renderer
builds the final layout tree from the VNode tree via `_build_layout_tree_from_vnode()`.
Attributes
----------
element_counters : dict
Counter for auto-generating element IDs by type
vnode_root : VNodeBuilder or None
Root of the VNode tree (for reconciliation)
vnode_stack : list of VNodeBuilder
Stack of VNodeBuilders being processed
element_vnodes : dict
Map of element ID -> VNodeBuilder for element lookup
"""
def __init__(self) -> None:
self.element_counters: dict[str, int] = {}
# VNode tree tracking for reconciliation
self.vnode_root: Any | None = None # VNodeBuilder
self.vnode_stack: list[Any] = [] # list[VNodeBuilder]
self.element_vnodes: dict[str, Any] = {} # id -> VNodeBuilder
def generate_id(self, element_type: str) -> str:
"""Generate a unique ID for an element.
Parameters
----------
element_type : str
Type of element (e.g., 'button', 'textinput')
Returns
-------
str
Generated ID in format 'element_type_N'
"""
if element_type not in self.element_counters:
self.element_counters[element_type] = 0
id_value = f"{element_type}_{self.element_counters[element_type]}"
self.element_counters[element_type] += 1
return id_value
# === VNode Tree Building Methods ===
def push_vnode(self, vnode_builder: Any) -> None:
"""Push a VNodeBuilder onto the VNode stack.
Parameters
----------
vnode_builder : VNodeBuilder
VNode builder to push (container type)
"""
if self.vnode_root is None:
self.vnode_root = vnode_builder
if self.vnode_stack:
parent = self.vnode_stack[-1]
parent.add_child(vnode_builder)
self.vnode_stack.append(vnode_builder)
# Track by ID if present
if vnode_builder.key:
self.element_vnodes[vnode_builder.key] = vnode_builder
def pop_vnode(self) -> Any | None:
"""Pop a VNodeBuilder from the VNode stack.
Returns
-------
VNodeBuilder or None
Popped builder
"""
if self.vnode_stack:
return self.vnode_stack.pop()
return None
def add_vnode(self, vnode_builder: Any) -> None:
"""Add a leaf VNodeBuilder to the current container.
Parameters
----------
vnode_builder : VNodeBuilder
VNode builder to add (leaf element)
"""
if self.vnode_root is None:
self.vnode_root = vnode_builder
if vnode_builder.key:
self.element_vnodes[vnode_builder.key] = vnode_builder
return
if self.vnode_stack:
parent = self.vnode_stack[-1]
parent.add_child(vnode_builder)
# Track by ID if present
if vnode_builder.key:
self.element_vnodes[vnode_builder.key] = vnode_builder
def freeze_vnode_tree(self) -> Any | None:
"""Freeze the VNode tree into immutable VNodes.
Returns
-------
VNode or None
Frozen VNode tree root
"""
if self.vnode_root is None:
return None
return self.vnode_root.freeze()
def parse_size_attr(value: Any) -> Any:
"""Parse a size attribute value.
Parameters
----------
value : Any
Attribute value (could be int, str, etc.)
Returns
-------
Any
Parsed size value
"""
if isinstance(value, int):
return value
if isinstance(value, str):
# Handle percentage or fill
if value.endswith("%") or value in ("fill", "auto"):
return value
# Try to parse as int
try:
return int(value)
except ValueError:
return value
return value
def _parse_for_render(
width: int | str = "fill",
height: int | str = "fill",
spacing: int = 0,
padding: int | str | tuple[int, ...] = 0,
padding_top: int | None = None,
padding_right: int | None = None,
padding_bottom: int | None = None,
padding_left: int | None = None,
margin: int | str | tuple[int, ...] = 0,
) -> tuple[Any, ...]:
"""Calculate the attributes for rendering"""
# Parse attributes
width_parsed = parse_size_attr(width)
height_parsed = parse_size_attr(height)
spacing_int = safe_int(spacing, default=0, name="spacing")
# Parse padding - could be int, tuple, or directional attributes
padding_parsed: int | tuple[int, int, int, int]
if isinstance(padding, str) and padding.startswith("("):
# Parse tuple string like "(1,2,3,4)"
try:
padding_parsed = literal_eval(padding)
except (ValueError, SyntaxError, NameError):
padding_parsed = 0
elif isinstance(padding, tuple):
padding_parsed = cast("int | tuple[int, int, int, int]", padding)
elif isinstance(padding, str):
try:
padding_parsed = int(padding)
except ValueError:
padding_parsed = 0
else:
padding_parsed = int(padding) if padding else 0
# Apply directional padding overrides
if any(
[
padding_top is not None,
padding_right is not None,
padding_bottom is not None,
padding_left is not None,
]
):
# Convert base padding to tuple if needed
if isinstance(padding_parsed, int):
base = padding_parsed
padding_tuple = (base, base, base, base)
else:
padding_tuple = padding_parsed
# Override with directional values
padding_parsed = (
padding_top if padding_top is not None else padding_tuple[0],
padding_right if padding_right is not None else padding_tuple[1],
padding_bottom if padding_bottom is not None else padding_tuple[2],
padding_left if padding_left is not None else padding_tuple[3],
)
# Parse margin
margin_parsed: int | tuple[int, int, int, int]
if isinstance(margin, str) and margin.startswith("("):
try:
margin_parsed = literal_eval(margin)
except (ValueError, SyntaxError, NameError):
margin_parsed = 0
elif isinstance(margin, str):
try:
margin_parsed = int(margin)
except ValueError:
margin_parsed = 0
else:
margin_parsed = cast(int, margin)
return width_parsed, height_parsed, spacing_int, padding_parsed, margin_parsed
[docs]
class VStackExtension(Extension):
"""Jinja2 extension for {% vstack %} tag.
Syntax::
{% vstack width="fill" height="auto" spacing=0 padding=0
margin=0 align_h="stretch" align_v="stretch" %}
... children ...
{% endvstack %}
"""
tags = {"vstack"}
[docs]
def parse(self, parser: Parser) -> nodes.CallBlock:
"""Parse the vstack tag.
Parameters
----------
parser : jinja2.parser.Parser
Jinja2 parser
Returns
-------
jinja2.nodes.CallBlock
Parsed node tree
"""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endvstack", lineno)
# Parse body
node = nodes.CallBlock(
self.call_method("_render_vstack", [], kwargs),
[],
[],
parser.parse_statements(("name:endvstack",), drop_needle=True),
).set_lineno(lineno)
return cast(nodes.CallBlock, node)
def _render_vstack(
self,
caller: Callable[[], str],
width: int | str = "fill",
height: int | str = "auto",
spacing: int = 0,
padding: int | str | tuple[int, ...] = 0,
padding_top: int | None = None,
padding_right: int | None = None,
padding_bottom: int | None = None,
padding_left: int | None = None,
margin: int | str | tuple[int, ...] = 0,
align_h: str = "stretch",
align_v: str = "stretch",
raw: bool = False,
id: str | None = None,
**kwargs: Any,
) -> str:
"""Render the vstack tag.
Parameters
----------
caller : callable
Jinja2 caller for body content
width : int or str
Width specification (default: "fill")
height : int or str
Height specification (default: "fill")
spacing : int
Spacing between children
padding : int or str or tuple
Padding around children (uniform or tuple: top, right, bottom, left)
padding_top : int, optional
Top padding (overrides padding)
padding_right : int, optional
Right padding (overrides padding)
padding_bottom : int, optional
Bottom padding (overrides padding)
padding_left : int, optional
Left padding (overrides padding)
margin : int or tuple
Margin around container
align_h : str
Horizontal alignment of children
align_v : str
Vertical alignment of children
raw : bool, optional
If True, preserve whitespace in body content. If False, dedent and strip (default: False)
id : str, optional
Node identifier
Returns
-------
str
Rendered output
"""
# Get layout context from RenderContext
render_ctx = get_render_context()
layout_context = render_ctx.layout_context
width_parsed, height_parsed, spacing_int, padding_parsed, margin_parsed = (
_parse_for_render(
width,
height,
spacing,
padding,
padding_top,
padding_right,
padding_bottom,
padding_left,
margin,
)
)
# Create VNode builder for reconciliation
vnode = VNodeBuilder("VStack", key=id)
apply_common_attributes(vnode, kwargs)
vnode.set_layout(
width=width_parsed,
height=height_parsed,
spacing=spacing_int,
padding=padding_parsed,
margin=margin_parsed,
align_h=align_h,
align_v=align_v,
)
layout_context.push_vnode(vnode)
# Render body - nested elements will add themselves via push_vnode/add_vnode
# and insert markers in body_output to indicate their positions
body_output = caller()
# Handle text content for VNodes
vnode_children = vnode.children
if not vnode_children and body_output.strip():
# No VNode children and has text - create a single TextElement VNode
processed_text = process_body_content(body_output, raw=raw)
if processed_text:
text_key = layout_context.generate_id("text")
text_vnode = VNodeBuilder("TextElement", key=text_key)
text_vnode.set_prop("id", text_key)
text_vnode.set_prop("text", processed_text)
text_vnode.set_prop("wrap", not raw)
text_vnode.set_layout(width="auto", height="auto")
vnode.add_child(text_vnode)
elif vnode_children:
# Has VNode children - interleave text and VNodes in source order
interleaved_vnode_builders = interleave_text_and_vnode_builders(
body_output, vnode_children, raw, layout_context
)
vnode.children = interleaved_vnode_builders
# Pop VNode from stack
layout_context.pop_vnode()
# Return marker for text interleaving
return get_element_marker(layout_context)
[docs]
class HStackExtension(Extension):
"""Jinja2 extension for {% hstack %} tag.
Syntax::
{% hstack width="auto" height="fill" spacing=0 padding=0
margin=0 align_h="stretch" align_v="stretch" %}
... children ...
{% endhstack %}
"""
tags = {"hstack"}
[docs]
def parse(self, parser: Parser) -> nodes.CallBlock:
"""Parse the hstack tag.
Parameters
----------
parser : jinja2.parser.Parser
Jinja2 parser
Returns
-------
jinja2.nodes.CallBlock
Parsed node tree
"""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endhstack", lineno)
# Parse body
node = nodes.CallBlock(
self.call_method("_render_hstack", [], kwargs),
[],
[],
parser.parse_statements(("name:endhstack",), drop_needle=True),
).set_lineno(lineno)
return cast(nodes.CallBlock, node)
def _render_hstack(
self,
caller: Callable[[], str],
width: int | str = "auto",
height: int | str = "auto",
spacing: int = 0,
padding: int | str | tuple[int, ...] = 0,
padding_top: int | None = None,
padding_right: int | None = None,
padding_bottom: int | None = None,
padding_left: int | None = None,
margin: int | str | tuple[int, ...] = 0,
align_h: str = "stretch",
align_v: str = "stretch",
justify: str = "flex-start",
wrap: bool = False,
gap: int | None = None,
row_gap: int | None = None,
column_gap: int | None = None,
raw: bool = False,
id: str | None = None,
**kwargs: Any,
) -> str:
"""Render the hstack tag.
Parameters
----------
caller : callable
Jinja2 caller for body content
width : int or str
Width specification (default: "auto")
height : int or str
Height specification (default: "auto")
spacing : int
Spacing between children (legacy, use column_gap instead)
padding : int or str or tuple
Padding around children (uniform or tuple: top, right, bottom, left)
padding_top : int, optional
Top padding (overrides padding)
padding_right : int, optional
Right padding (overrides padding)
padding_bottom : int, optional
Bottom padding (overrides padding)
padding_left : int, optional
Left padding (overrides padding)
margin : int or tuple
Margin around container
align_h : str
Horizontal alignment of children
align_v : str
Vertical alignment of children
justify : str
Main axis distribution mode (default: "flex-start").
Options: "flex-start", "flex-end", "center", "space-between",
"space-around", "space-evenly"
wrap : bool
If True, children wrap to next row when exceeding width
gap : int, optional
Shorthand for both row_gap and column_gap
row_gap : int, optional
Space between rows when wrapping
column_gap : int, optional
Space between columns (overrides spacing)
raw : bool, optional
If True, preserve whitespace in body content. If False, dedent and strip (default: False)
id : str, optional
Node identifier
Returns
-------
str
Rendered output
"""
# Get layout context from RenderContext
render_ctx = get_render_context()
layout_context = render_ctx.layout_context
width_parsed, height_parsed, spacing_int, padding_parsed, margin_parsed = (
_parse_for_render(
width,
height,
spacing,
padding,
padding_top,
padding_right,
padding_bottom,
padding_left,
margin,
)
)
# Resolve gap shorthand
# gap sets both row_gap and column_gap if they aren't explicitly set
resolved_row_gap = row_gap if row_gap is not None else gap
resolved_column_gap = column_gap if column_gap is not None else gap
# Create VNode builder for reconciliation
vnode = VNodeBuilder("HStack", key=id)
apply_common_attributes(vnode, kwargs)
vnode.set_layout(
width=width_parsed,
height=height_parsed,
spacing=spacing_int,
padding=padding_parsed,
margin=margin_parsed,
align_h=align_h,
align_v=align_v,
justify=justify,
wrap=wrap,
row_gap=resolved_row_gap,
column_gap=resolved_column_gap,
)
layout_context.push_vnode(vnode)
# Render body - nested elements will add themselves via push_vnode/add_vnode
# and insert markers in body_output to indicate their positions
body_output = caller()
# Handle text content for VNodes
vnode_children = vnode.children
if not vnode_children and body_output.strip():
# No VNode children and has text - create a single TextElement VNode
processed_text = process_body_content(body_output, raw=raw)
if processed_text:
text_key = layout_context.generate_id("text")
text_vnode = VNodeBuilder("TextElement", key=text_key)
text_vnode.set_prop("id", text_key)
text_vnode.set_prop("text", processed_text)
text_vnode.set_prop("wrap", not raw)
text_vnode.set_layout(width="auto", height="auto")
vnode.add_child(text_vnode)
elif vnode_children:
# Has VNode children - interleave text and VNodes in source order
interleaved_vnode_builders = interleave_text_and_vnode_builders(
body_output, vnode_children, raw, layout_context
)
vnode.children = interleaved_vnode_builders
# Pop VNode from stack
layout_context.pop_vnode()
# Return marker for text interleaving
return get_element_marker(layout_context)
[docs]
class FrameExtension(Extension):
"""Jinja2 extension for {% frame %} tag.
Syntax::
{% frame title="Title" border="single" width="fill" height="auto"
margin=0 align_h="stretch" align_v="stretch"
content_align_h="stretch" content_align_v="stretch" %}
... content ...
{% endframe %}
"""
tags = {"frame"}
[docs]
def parse(self, parser: Parser) -> nodes.CallBlock:
"""Parse the frame tag.
Parameters
----------
parser : jinja2.parser.Parser
Jinja2 parser
Returns
-------
jinja2.nodes.CallBlock
Parsed node tree
"""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endframe", lineno)
# Parse body
node = nodes.CallBlock(
self.call_method("_render_frame", [], kwargs),
[],
[],
parser.parse_statements(("name:endframe",), drop_needle=True),
).set_lineno(lineno)
return cast(nodes.CallBlock, node)
def _render_frame(
self,
caller: Callable[[], str],
width: int | str = "fill",
height: int | str = "auto",
title: str | None = None,
border: str | None = None,
margin: int | str | tuple[int, ...] = 0,
padding: int | str | tuple[int, ...] | None = None,
align_h: str = "stretch",
align_v: str = "stretch",
content_align_h: str = "stretch",
content_align_v: str = "stretch",
overflow_x: str | None = None,
scrollable: bool = False,
show_scrollbar: bool = True,
show_scrollbar_x: bool = True,
raw: bool = False,
id: str | None = None,
**kwargs: Any,
) -> str:
"""Render the frame tag.
Parameters
----------
caller : callable
Jinja2 caller for body content
width : int or str
Width specification
height : int or str
Height specification
title : str, optional
Frame title
border : str, optional
Border style: "none", "single", "double", "rounded", "heavy" or
"ascii" (default: "single"). Matches the dialog tags.
margin : int or tuple, optional
Margin around frame (default: 0)
padding : int or tuple, optional
Padding inside frame (top, right, bottom, left). Can be int for uniform padding or tuple
align_h : str, optional
Horizontal alignment of frame within parent (default: "stretch")
align_v : str, optional
Vertical alignment of frame within parent (default: "stretch")
content_align_h : str, optional
Horizontal alignment of content within frame (default: "stretch")
content_align_v : str, optional
Vertical alignment of content within frame (default: "stretch")
overflow_x : str, optional
Horizontal overflow mode: "clip", "visible", "wrap", "scroll", or
"auto". When unspecified (the default), frame body text wraps to the
frame width; an explicit value is honored, so "clip"/"visible" keep
logical lines instead of reflowing.
- "scroll": Enable horizontal scrolling with scrollbar
- "auto": Enable horizontal scrolling only when content exceeds width
scrollable : bool, optional
Enable vertical scrolling (default: False)
show_scrollbar : bool, optional
Show vertical scrollbar when scrollable (default: True)
show_scrollbar_x : bool, optional
Show horizontal scrollbar when overflow_x is "scroll" or "auto" (default: True)
raw : bool, optional
If True, preserve whitespace in body content. If False, dedent and strip (default: False)
id : str, optional
Node identifier
Returns
-------
str
Rendered output
"""
# Get layout context from RenderContext
render_ctx = get_render_context()
layout_context = render_ctx.layout_context
# Auto-generate ID for scrollable frames if not provided
if scrollable and not id:
id = render_ctx.generate_frame_id()
# Parse attributes
width_parsed = parse_size_attr(width)
height_parsed = parse_size_attr(height)
# Parse margin - could be int or tuple string like "(1,2,3,4)"
margin_parsed: int | tuple[int, int, int, int]
if isinstance(margin, str) and margin.startswith("("):
# Parse tuple string
try:
margin_parsed = literal_eval(margin)
except (ValueError, SyntaxError, NameError):
margin_parsed = 0
elif isinstance(margin, str):
try:
margin_parsed = int(margin)
except ValueError:
margin_parsed = 0
else:
margin_parsed = cast(int, margin)
# "border" is the frame tag's only border attribute, matching the
# dialog tags.
border_style = border if border is not None else "single"
border_map = {
"none": BorderStyle.NONE,
"single": BorderStyle.SINGLE,
"double": BorderStyle.DOUBLE,
"rounded": BorderStyle.ROUNDED,
}
border_style_enum = border_map.get(border_style, BorderStyle.SINGLE)
# Parse padding - could be int or tuple string like "(1,2,3,4)"
padding_parsed: tuple[int, int, int, int]
if padding is None:
padding_parsed = (0, 1, 0, 1) # Default padding: horizontal only
elif isinstance(padding, int):
padding_parsed = (padding, padding, padding, padding)
elif isinstance(padding, str) and padding.startswith("("):
# Parse tuple string
try:
padding_parsed = cast(tuple[int, int, int, int], literal_eval(padding))
except (ValueError, SyntaxError, NameError):
padding_parsed = (1, 1, 1, 1)
elif isinstance(padding, str):
try:
p = int(padding)
padding_parsed = (p, p, p, p)
except ValueError:
padding_parsed = (1, 1, 1, 1)
else:
padding_parsed = (1, 1, 1, 1)
# Parse overflow_y from scrollable parameter
overflow_y: Literal["clip", "scroll", "auto"]
if scrollable:
overflow_y = "auto"
else:
overflow_y = "clip"
# overflow_x defaults to None (unspecified). Frame body text wraps by
# default; an explicit overflow_x is honored. Resolve the effective mode
# for the frame element (None -> "clip", the historical default), and
# remember whether the caller left it unspecified so body text keeps
# wrapping unless an explicit non-"wrap" mode was requested.
overflow_x_unspecified = overflow_x is None
effective_overflow_x = "clip" if overflow_x is None else overflow_x
# Create VNode builder for reconciliation
# The renderer will create the actual Frame element from these props
vnode = VNodeBuilder("Frame", key=id)
apply_common_attributes(vnode, kwargs)
vnode.set_prop("border_style", border_style_enum)
vnode.set_prop("title", title)
vnode.set_prop("scrollable", scrollable)
vnode.set_prop("show_scrollbar", show_scrollbar)
vnode.set_prop("show_scrollbar_x", show_scrollbar_x)
vnode.set_prop("overflow_x", effective_overflow_x)
vnode.set_prop("overflow_y", overflow_y)
vnode.set_layout(
width=width_parsed,
height=height_parsed,
padding=padding_parsed,
margin=margin_parsed,
align_h=align_h,
align_v=align_v,
content_align_h=content_align_h,
content_align_v=content_align_v,
)
layout_context.push_vnode(vnode)
# Render body - nested elements will add themselves via push_vnode/add_vnode
# and insert markers in body_output to indicate their positions
body_output = caller()
# Handle text content for VNodes
vnode_children = vnode.children
if not vnode_children and body_output.strip():
# No VNode children and has text - create a single TextElement VNode
processed_text = process_body_content(body_output, raw=raw)
if processed_text:
text_key = layout_context.generate_id("text")
text_vnode = VNodeBuilder("TextElement", key=text_key)
text_vnode.set_prop("id", text_key)
text_vnode.set_prop("text", processed_text)
# Body text wraps by default; honor an explicit overflow_x.
# Only "wrap" reflows - clip/visible/scroll/auto keep logical
# lines so the frame's own overflow handling takes effect.
wrap_body = (not raw) and (
overflow_x_unspecified or effective_overflow_x == "wrap"
)
text_vnode.set_prop("wrap", wrap_body)
text_vnode.set_layout(width="auto", height="auto")
vnode.add_child(text_vnode)
elif vnode_children:
# Has VNode children - interleave text and VNodes in source order
interleaved_vnode_builders = interleave_text_and_vnode_builders(
body_output, vnode_children, raw, layout_context
)
vnode.children = interleaved_vnode_builders
# Pop VNode from stack
layout_context.pop_vnode()
# Return marker for text interleaving
return get_element_marker(layout_context)
[docs]
class GridExtension(Extension):
"""Jinja2 extension for {% grid %} tag.
Syntax::
{% grid rows=2 cols=3 row_gap=1 column_gap=2 %}
... children ...
{% endgrid %}
"""
tags = {"grid"}
[docs]
def parse(self, parser: Parser) -> nodes.CallBlock:
"""Parse the grid tag.
Parameters
----------
parser : jinja2.parser.Parser
Jinja2 parser
Returns
-------
jinja2.nodes.CallBlock
Parsed node tree
"""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endgrid", lineno)
# Parse body
node = nodes.CallBlock(
self.call_method("_render_grid", [], kwargs),
[],
[],
parser.parse_statements(("name:endgrid",), drop_needle=True),
).set_lineno(lineno)
return cast(nodes.CallBlock, node)
def _render_grid(
self,
caller: Callable[[], str],
rows: int = 2,
cols: int = 2,
row_gap: int = 0,
column_gap: int = 0,
width: int | str = "fill",
height: int | str = "auto",
padding: int | str | tuple[int, ...] = 0,
margin: int | str | tuple[int, ...] = 0,
align_h: str = "stretch",
align_v: str = "stretch",
id: str | None = None,
**kwargs: Any,
) -> str:
"""Render the grid tag.
Parameters
----------
caller : callable
Jinja2 caller for body content
rows : int
Number of rows in the grid (default: 2)
cols : int
Number of columns in the grid (default: 2)
row_gap : int
Vertical gap between rows (default: 0)
column_gap : int
Horizontal gap between columns (default: 0). Matches HStack's
``column_gap``.
width : int or str
Width specification (default: "fill")
height : int or str
Height specification (default: "auto")
padding : int or str or tuple
Padding around the grid (default: 0)
margin : int or str or tuple
Margin around the grid (default: 0)
align_h : str
Horizontal alignment within cells (default: "stretch")
align_v : str
Vertical alignment within cells (default: "stretch")
id : str, optional
Node identifier
Returns
-------
str
Rendered output
"""
# Get layout context from RenderContext
render_ctx = get_render_context()
layout_context = render_ctx.layout_context
# Parse attributes
width_parsed = parse_size_attr(width)
height_parsed = parse_size_attr(height)
rows_int = safe_int(rows, default=2, name="rows")
cols_int = safe_int(cols, default=2, name="cols")
row_gap_int = safe_int(row_gap, default=0, name="row_gap")
column_gap_int = safe_int(column_gap, default=0, name="column_gap")
# Parse padding and margin
_, _, _, padding_parsed, margin_parsed = _parse_for_render(
width, height, 0, padding, None, None, None, None, margin
)
# Create VNode builder for reconciliation
vnode = VNodeBuilder("Grid", key=id)
apply_common_attributes(vnode, kwargs)
vnode.set_prop("rows", rows_int)
vnode.set_prop("cols", cols_int)
vnode.set_prop("row_gap", row_gap_int)
vnode.set_prop("column_gap", column_gap_int)
vnode.set_layout(
width=width_parsed,
height=height_parsed,
padding=padding_parsed,
margin=margin_parsed,
align_h=align_h,
align_v=align_v,
)
layout_context.push_vnode(vnode)
# Render body - nested elements will add themselves via push_vnode/add_vnode
body_output = caller()
# Handle text content for VNodes
vnode_children = vnode.children
if vnode_children:
# Has VNode children - interleave in source order
interleaved_vnode_builders = interleave_text_and_vnode_builders(
body_output, vnode_children, False, layout_context
)
vnode.children = interleaved_vnode_builders
# Pop VNode from stack
layout_context.pop_vnode()
# Return marker for text interleaving
return get_element_marker(layout_context)
[docs]
class ColspanExtension(Extension):
"""Jinja2 extension for {% colspan %} tag.
Syntax::
{% colspan cols=2 %}
{% frame %}Content{% endframe %}
{% endcolspan %}
"""
tags = {"colspan"}
[docs]
def parse(self, parser: Parser) -> nodes.CallBlock:
"""Parse the colspan tag.
Parameters
----------
parser : jinja2.parser.Parser
Jinja2 parser
Returns
-------
jinja2.nodes.CallBlock
Parsed node tree
"""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endcolspan", lineno)
# Parse body
node = nodes.CallBlock(
self.call_method("_render_colspan", [], kwargs),
[],
[],
parser.parse_statements(("name:endcolspan",), drop_needle=True),
).set_lineno(lineno)
return cast(nodes.CallBlock, node)
def _render_colspan(
self,
caller: Callable[[], str],
cols: int = 1,
**kwargs: Any,
) -> str:
"""Render the colspan wrapper.
Parameters
----------
caller : callable
Jinja2 caller for body content
cols : int
Number of columns to span (default: 1)
Returns
-------
str
Rendered output
"""
render_ctx = get_render_context()
layout_context = render_ctx.layout_context
# Create wrapper VNode
vnode = VNodeBuilder("GridSpanWrapper")
vnode.set_prop("colspan", int(cols))
vnode.set_prop("rowspan", 1)
layout_context.push_vnode(vnode)
# Render child content
body_output = caller()
# Get the children
vnode_children = vnode.children
# Wrapper should have exactly one child element
if vnode_children:
# Interleave to preserve order (though we expect only 1 child)
interleaved = interleave_text_and_vnode_builders(
body_output, vnode_children, False, layout_context
)
# Filter to only element vnodes (ignore text-only vnodes)
element_children = [c for c in interleaved if c.type != "TextElement"]
if len(element_children) != 1:
logger.warning(
f"colspan wrapper should contain exactly 1 element, "
f"got {len(element_children)}"
)
vnode.children = interleaved
layout_context.pop_vnode()
return get_element_marker(layout_context)
[docs]
class RowspanExtension(Extension):
"""Jinja2 extension for {% rowspan %} tag.
Syntax::
{% rowspan rows=2 %}
{% frame %}Tall content{% endframe %}
{% endrowspan %}
"""
tags = {"rowspan"}
[docs]
def parse(self, parser: Parser) -> nodes.CallBlock:
"""Parse the rowspan tag.
Parameters
----------
parser : jinja2.parser.Parser
Jinja2 parser
Returns
-------
jinja2.nodes.CallBlock
Parsed node tree
"""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endrowspan", lineno)
# Parse body
node = nodes.CallBlock(
self.call_method("_render_rowspan", [], kwargs),
[],
[],
parser.parse_statements(("name:endrowspan",), drop_needle=True),
).set_lineno(lineno)
return cast(nodes.CallBlock, node)
def _render_rowspan(
self,
caller: Callable[[], str],
rows: int = 1,
**kwargs: Any,
) -> str:
"""Render the rowspan wrapper.
Parameters
----------
caller : callable
Jinja2 caller for body content
rows : int
Number of rows to span (default: 1)
Returns
-------
str
Rendered output
"""
render_ctx = get_render_context()
layout_context = render_ctx.layout_context
# Create wrapper VNode
vnode = VNodeBuilder("GridSpanWrapper")
vnode.set_prop("colspan", 1)
vnode.set_prop("rowspan", int(rows))
layout_context.push_vnode(vnode)
# Render child content
body_output = caller()
# Get the children
vnode_children = vnode.children
# Wrapper should have exactly one child element
if vnode_children:
# Interleave to preserve order (though we expect only 1 child)
interleaved = interleave_text_and_vnode_builders(
body_output, vnode_children, False, layout_context
)
# Filter to only element vnodes (ignore text-only vnodes)
element_children = [c for c in interleaved if c.type != "TextElement"]
if len(element_children) != 1:
logger.warning(
f"rowspan wrapper should contain exactly 1 element, "
f"got {len(element_children)}"
)
vnode.children = interleaved
layout_context.pop_vnode()
return get_element_marker(layout_context)
[docs]
class SplitPanelExtension(Extension):
"""Jinja2 extension for {% splitpanel %} tag.
Syntax::
{% splitpanel orientation="horizontal" ratio="50:50" resizable=true
min_first=5 min_second=5 collapsible="none" id="my_split" %}
{% frame %}First panel{% endframe %}
{% frame %}Second panel{% endframe %}
{% endsplitpanel %}
The splitpanel takes exactly **two direct children** - any element type
is allowed (frame, textarea, another splitpanel, etc.).
"""
tags = {"splitpanel"}
[docs]
def parse(self, parser: Parser) -> nodes.CallBlock:
"""Parse the splitpanel tag.
Parameters
----------
parser : jinja2.parser.Parser
Jinja2 parser
Returns
-------
jinja2.nodes.CallBlock
Parsed node tree
"""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endsplitpanel", lineno)
# Parse body
node = nodes.CallBlock(
self.call_method("_render_splitpanel", [], kwargs),
[],
[],
parser.parse_statements(("name:endsplitpanel",), drop_needle=True),
).set_lineno(lineno)
return cast(nodes.CallBlock, node)
def _render_splitpanel(
self,
caller: Callable[[], str],
orientation: str = "horizontal",
ratio: str = "50:50",
resizable: bool = True,
min_first: int = 5,
min_second: int = 5,
collapsible: str = "none",
divider_style: str = "single",
width: int | str = "fill",
height: int | str = "fill",
id: str | None = None,
**kwargs: Any,
) -> str:
"""Render the splitpanel tag.
Parameters
----------
caller : callable
Jinja2 caller for body content
orientation : str
"horizontal" (side-by-side) or "vertical" (stacked). Default: "horizontal"
ratio : str
Initial size ratio like "50:50" or "30:70". Default: "50:50"
resizable : bool
Allow drag-to-resize. Default: True
min_first : int
Minimum size for first panel. Default: 5
min_second : int
Minimum size for second panel. Default: 5
collapsible : str
Which panels can collapse: "none", "first", "second", "both". Default: "none"
divider_style : str
Style of divider: "single", "double", "dashed", "thick". Default: "single"
width : int or str
Width specification. Default: "fill"
height : int or str
Height specification. Default: "fill"
id : str, optional
Element ID for state binding
Returns
-------
str
Rendered output
"""
# Get layout context from RenderContext
render_ctx = get_render_context()
layout_context = render_ctx.layout_context
# Parse attributes
width_parsed = parse_size_attr(width)
height_parsed = parse_size_attr(height)
min_first_int = safe_int(min_first, default=5, name="min_first")
min_second_int = safe_int(min_second, default=5, name="min_second")
# Create VNode builder for reconciliation
vnode = VNodeBuilder("SplitPanel", key=id)
apply_common_attributes(vnode, kwargs)
vnode.set_prop("orientation", orientation)
vnode.set_prop("ratio", ratio)
vnode.set_prop("resizable", resizable)
vnode.set_prop("min_first", min_first_int)
vnode.set_prop("min_second", min_second_int)
vnode.set_prop("collapsible", collapsible)
vnode.set_prop("divider_style", divider_style)
vnode.set_layout(
width=width_parsed,
height=height_parsed,
)
layout_context.push_vnode(vnode)
# Render body - nested elements will add themselves via push_vnode/add_vnode
body_output = caller()
# Get the children (should be exactly 2)
vnode_children = vnode.children
if vnode_children:
# Interleave to preserve order
interleaved = interleave_text_and_vnode_builders(
body_output, vnode_children, False, layout_context
)
# Filter to only element vnodes (ignore text-only vnodes)
element_children = [c for c in interleaved if c.type != "TextElement"]
if len(element_children) != 2:
logger.warning(
f"splitpanel should contain exactly 2 children, "
f"got {len(element_children)}"
)
vnode.children = element_children # Only keep element children
# Pop VNode from stack
layout_context.pop_vnode()
# Return marker for text interleaving
return get_element_marker(layout_context)