"""Jinja2 template extensions for input elements.
This module provides Jinja2 template tag extensions for input elements
including text inputs, text areas, buttons, selects, checkboxes, and
radio buttons.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, Literal
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 VNodeBuilder
from wijjit.layout.frames import BorderStyle, has_border
from wijjit.logging_config import get_logger
from wijjit.tags.layout import (
apply_reconciliation_key,
auto_element_id,
forward_extra_props,
get_element_marker,
parse_tag_attributes,
resolve_bind_key,
safe_int,
)
from wijjit.terminal.ansi import visible_length
if TYPE_CHECKING:
from wijjit.autocomplete.completer import Completer
# Get logger for this module
logger = get_logger(__name__)
[docs]
class TextInputExtension(Extension):
"""Jinja2 extension for {% textinput %} tag.
Syntax::
{% textinput id="name" placeholder="Enter name" width=30 %}{% endtextinput %}
"""
tags = {"textinput"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the textinput 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, "endtextinput", lineno)
# Parse body (should be empty, but we need to consume until endtextinput)
node = nodes.CallBlock(
self.call_method("_render_textinput", [], kwargs),
[],
[],
parser.parse_statements(("name:endtextinput",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_textinput(
self,
caller: Any,
id: str | None = None,
placeholder: str = "",
width: int = 20,
value: str = "",
action: str | None = None,
bind: bool | str = True,
autocomplete: list[str] | str | bool | Completer | None = None,
**kwargs: Any,
) -> str:
"""Render the textinput tag.
Parameters
----------
caller : callable
Jinja2 caller for body content
id : str, optional
Element identifier
placeholder : str
Placeholder text
width : int
Input width
value : str
Initial value. If empty, the tag body is used as the initial value
(state binding still takes precedence).
action : str, optional
Action ID to dispatch when Enter is pressed
bind : bool or str
State binding. True auto-binds value to state[id]; False disables
binding; a string names the state key to bind to instead, so the
id stays a pure identity (default: True).
autocomplete : list, str, bool, Completer, or None
Autocomplete configuration. Can be:
- None/False: Disabled (default)
- list: Word list to use for suggestions
- "state.key": Reference to state key containing word list
- "name": Name of registered completer in app.completers
- True: Auto-wire by element ID lookup in app.completers
- Completer: Direct completer instance
classes : str, optional
CSS-like class names for styling
Returns
-------
str
Rendered output
"""
# Get layout context from RenderContext
render_ctx = get_render_context()
layout_context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
# Preserve layout specs ("fill"/"auto"/"NN%") for the layout node so
# the field can stretch inside its parent; the element itself gets a
# numeric width for its initial render and adopts the assigned bounds
# via TextInput.set_bounds.
width_spec: int | str = width
if isinstance(width, str) and not width.isdigit():
width = 30
else:
width = safe_int(width, default=30, name="width")
width_spec = width
# Auto-generate ID if not provided
if id is None:
id = auto_element_id(layout_context, "textinput", kwargs)
# Use the tag body as the initial value when no value attribute is given
# (consistent with textarea). State binding below still takes precedence.
body = caller().strip()
if not value and body:
value = body
# If binding is enabled and id is provided, try to get initial value from state
bind_key = resolve_bind_key(bind, id)
if bind_key:
try:
if bind_key in state:
value = str(state[bind_key])
except (KeyError, TypeError, AttributeError) as e:
logger.warning(
f"Failed to restore state for textinput '{bind_key}': {e}"
)
# Create VNode for reconciliation
vnode = VNodeBuilder("TextInput", key=id)
vnode.set_prop("id", id) # Set id as prop so Element gets it
vnode.set_prop("value", value)
vnode.set_prop("placeholder", placeholder)
vnode.set_prop("action", action)
vnode.set_prop("bind", bind)
vnode.set_prop("width", width)
# Pass autocomplete spec for resolution in wiring phase
if autocomplete is not None:
vnode.set_prop("autocomplete", autocomplete)
# Check if this element should be focused
if focused_id and id and focused_id == id:
vnode.set_prop("focused", True)
# Normalize class/tabindex/key and forward any extra attributes
# (e.g. max_length, style, password) onto the VNode as props so typos
# are visible to the reconciler and validator.
forward_extra_props(vnode, kwargs)
# Calculate layout width including border characters based on style
# Default style is BRACKETS which has left and right borders
style = kwargs.get("style", "brackets")
# Normalize style to string for comparison
if hasattr(style, "name"):
# Handle InputStyle enum
style_name = style.name.lower()
elif isinstance(style, str):
style_name = style.lower()
else:
style_name = "brackets" # Default
# BRACKETS, BOX, and BLOCK styles have 2-character border overhead
# UNDERLINE and MINIMAL have no side borders
layout_width: int | str
if isinstance(width_spec, str):
layout_width = width_spec # keep "fill"/"auto"/"NN%" for the engine
elif style_name in ("brackets", "box", "block"):
layout_width = width_spec + 2
else:
layout_width = width_spec
vnode.set_layout(width=layout_width, height=1)
layout_context.add_vnode(vnode)
# Return marker for text interleaving
return get_element_marker(layout_context)
[docs]
class SelectExtension(Extension):
"""Jinja2 extension for {% select %} tag.
Syntax::
{% select id="color" width=30 %}
Red
Green
Blue
{% endselect %}
Or with options attribute:
{% select id="fruit" options=["Apple", "Banana", "Orange"] %}{% endselect %}
Or with value/label pairs:
{% select id="size" %}
{"value": "s", "label": "Small"}
{"value": "m", "label": "Medium"}
{"value": "l", "label": "Large"}
{% endselect %}
Disabled options:
{% select id="priority" %}
Low
Medium
High (disabled)
{% endselect %}
"""
tags = {"select"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the select 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, "endselect", lineno)
# Parse body (options list)
node = nodes.CallBlock(
self.call_method("_render_select", [], kwargs),
[],
[],
parser.parse_statements(("name:endselect",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_select(
self,
caller: Any,
id: str | None = None,
options: list[Any] | None = None,
value: str | None = None,
values: list[Any] | None = None,
multiple: bool = False,
width: int = 20,
visible_rows: int = 5,
action: str | None = None,
bind: bool | str = True,
border_style: (
BorderStyle | Literal["single", "double", "rounded"] | None
) = None,
title: str | None = None,
**kwargs: Any,
) -> str:
"""Render the select tag.
Parameters
----------
caller : callable
Jinja2 caller for body content (options list)
id : str, optional
Element identifier
options : list, optional
List of options (if not provided in body)
value : str, optional
Initial selected value (single-select mode)
values : list, optional
Initial selected values (multi-select mode)
multiple : bool
Enable multiple selection mode (default: False)
width : int
Select width (default: 20)
visible_rows : int
Number of visible rows in the list (default: 5)
action : str, optional
Action ID to dispatch when value changes
bind : bool or str
State binding. True auto-binds value to state[id]; False disables
binding; a string names the state key to bind to instead, so the
id stays a pure identity (default: True).
border_style : str, optional
Border style: "single", "double", "rounded", or None (default: None)
title : str, optional
Title to display in top border (only when border_style is set)
classes : str, optional
CSS-like class names for styling
tab_index : int, optional
Tab order for focus navigation
Returns
-------
str
Rendered output
"""
# "border" is the canonical alias for "border_style" (see frame/dialog tags).
# Popped here (consumed by the tag) before extras are forwarded.
border_style = kwargs.pop("border", border_style)
# Get layout context from RenderContext
render_ctx = get_render_context()
context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
# Convert numeric parameters safely
width = safe_int(width, default=20, name="width")
visible_rows = safe_int(visible_rows, default=5, name="visible_rows")
# Parse options. Precedence: options= attribute, then nested
# {% selectitem %} tags, then a newline-delimited text body.
item_list = render_ctx.push_items()
body = caller().strip()
render_ctx.pop_items()
if options is not None:
# Options provided as attribute; body already consumed above.
pass
elif item_list:
options = item_list
elif body:
options = self._parse_options_from_body(body)
else:
options = []
# Extract disabled values
disabled_values: list[str] = []
cleaned_options: list[Any] = []
for opt in options:
if isinstance(opt, str):
# Check for " (disabled)" suffix
if opt.endswith(" (disabled)"):
opt_value = opt[:-11].strip() # Remove " (disabled)"
cleaned_options.append(opt_value)
disabled_values.append(opt_value)
else:
cleaned_options.append(opt)
elif isinstance(opt, dict):
# Check for disabled key. Fall back to "label" when "value" is
# absent (matching Select's own normalization) and skip
# entirely if neither is present, rather than crashing the
# whole template render with a KeyError.
if opt.get("disabled", False):
disabled_value = opt.get("value", opt.get("label"))
if disabled_value is not None:
disabled_values.append(disabled_value)
cleaned_options.append(opt)
else:
cleaned_options.append(opt)
# Auto-generate ID if not provided
if id is None:
id = auto_element_id(context, "select", kwargs)
# If binding is enabled and id is provided, try to get initial value from state
bind_key = resolve_bind_key(bind, id)
if bind_key:
try:
if bind_key in state:
state_value = state[bind_key]
if multiple:
# Multi-select: expect list/set from state
if isinstance(state_value, (list, set, tuple)):
values = list(state_value)
elif state_value is not None:
values = [str(state_value)]
else:
# Single-select: expect string from state
value = str(state_value) if state_value is not None else None
except (KeyError, TypeError, AttributeError) as e:
logger.warning(f"Failed to restore state for select '{bind_key}': {e}")
# Check if this element should be focused
is_focused = focused_id and id and focused_id == id
# Calculate total height accounting for borders
# - No borders: height = visible_rows (content only)
# - With borders: height = visible_rows + 2 (top border + content + bottom border)
total_height = visible_rows + (2 if has_border(border_style) else 0)
# Width also needs to account for borders (adds 2 columns)
total_width = width + (2 if has_border(border_style) else 0)
# Create VNode for reconciliation
vnode = VNodeBuilder("Select", key=id)
vnode.set_prop("id", id) # Set id as prop so Element gets it
vnode.set_prop("multiple", multiple)
if multiple:
vnode.set_prop("values", values or [])
else:
vnode.set_prop("value", value)
vnode.set_prop("options", cleaned_options)
vnode.set_prop("width", width)
vnode.set_prop("visible_rows", visible_rows)
vnode.set_prop("border_style", border_style)
vnode.set_prop("title", title)
vnode.set_prop("disabled_values", disabled_values)
vnode.set_prop("action", action)
vnode.set_prop("bind", bind)
vnode.set_prop("focused", is_focused)
# Normalize class/tabindex/key and forward extra attributes as props.
forward_extra_props(vnode, kwargs)
vnode.set_layout(width=total_width, height=total_height)
context.add_vnode(vnode)
# Return marker for text interleaving
return get_element_marker(context)
def _parse_options_from_body(self, body: str) -> list[Any]:
"""Parse options from template body content.
Parameters
----------
body : str
Body content with options (one per line or JSON)
Returns
-------
list
List of option strings or dicts
"""
options = []
lines = body.split("\n")
for line in lines:
line = line.strip()
if not line:
continue
# Try to parse as JSON dict (for value/label pairs)
if line.startswith("{") and line.endswith("}"):
try:
opt_dict = json.loads(line)
options.append(opt_dict)
continue
except (json.JSONDecodeError, ValueError):
pass
# Otherwise treat as plain string option
options.append(line)
return options
[docs]
class SelectItemExtension(Extension):
"""Jinja2 extension for the ``{% selectitem %}`` tag.
Declares a single option inside a ``{% select %}`` body, as an alternative
to the ``options=`` attribute or a newline-delimited text body::
{% select id="size" %}
{% selectitem value="s" %}Small{% endselectitem %}
{% selectitem value="m" label="Medium" %}{% endselectitem %}
{% selectitem value="x" disabled=True %}Unavailable{% endselectitem %}
{% endselect %}
"""
tags = {"selectitem"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the selectitem 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, "endselectitem", lineno)
node = nodes.CallBlock(
self.call_method("_render_selectitem", [], kwargs),
[],
[],
parser.parse_statements(("name:endselectitem",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_selectitem(
self,
caller: Any,
value: Any = None,
label: str = "",
disabled: bool = False,
**kwargs: Any,
) -> str:
"""Render a selectitem: append an option dict to the current select.
Parameters
----------
caller : callable
Jinja2 caller for body content (label fallback).
value : Any, optional
Option value. Defaults to the label when omitted.
label : str, optional
Option label. If empty, the tag body is used.
disabled : bool, optional
Whether the option is disabled (default: False).
Returns
-------
str
Empty string; the option is added to the enclosing select.
"""
render_ctx = get_render_context()
current = render_ctx.current_items
body = caller().strip()
if current is None:
logger.warning("selectitem tag used outside of a select")
return ""
if not label:
label = body
if value is None:
value = label
item: dict[str, Any] = {"value": value, "label": label}
if disabled:
item["disabled"] = True
current.append(item)
return ""
[docs]
class CheckboxExtension(Extension):
"""Jinja2 extension for checkbox tag.
Syntax::
{% checkbox id="terms" label="I agree" checked=False action="submit" %}{% endcheckbox %}
"""
tags = {"checkbox"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the checkbox tag."""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endcheckbox", lineno)
# Parse body (should be empty, but consume until endcheckbox)
node = nodes.CallBlock(
self.call_method("_render_checkbox", [], kwargs),
[],
[],
parser.parse_statements(("name:endcheckbox",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_checkbox(
self,
caller: Any,
id: str | None = None,
label: str = "",
checked: bool = False,
action: str | None = None,
bind: bool | str = True,
**kwargs: Any,
) -> str:
"""Render the checkbox tag."""
# Get layout context from RenderContext
render_ctx = get_render_context()
context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
# Auto-generate ID if not provided
if id is None:
id = auto_element_id(context, "checkbox", kwargs)
# If binding is enabled, try to get initial checked state from state
bind_key = resolve_bind_key(bind, id)
if bind_key:
try:
if bind_key in state:
checked = bool(state[bind_key])
except (KeyError, TypeError, AttributeError) as e:
logger.warning(
f"Failed to restore state for checkbox '{bind_key}': {e}"
)
# Check if this element should be focused
is_focused = focused_id and id and focused_id == id
# Get label from body content if not specified via label attribute
body_content = caller().strip()
if not label and body_content:
label = body_content
# Checkbox width: "[X] " (4 chars) + label length
checkbox_width = 4 + len(label)
# Create VNode for reconciliation
vnode = VNodeBuilder("Checkbox", key=id)
vnode.set_prop("id", id) # Set id as prop so Element gets it
vnode.set_prop("label", label)
vnode.set_prop("checked", checked)
vnode.set_prop("action", action)
vnode.set_prop("bind", bind)
vnode.set_prop("focused", is_focused)
# Normalize class/tabindex/key and forward extra attributes as props.
forward_extra_props(vnode, kwargs)
vnode.set_layout(width=checkbox_width, height=1)
context.add_vnode(vnode)
# Return marker for text interleaving
return get_element_marker(context)
[docs]
class RadioExtension(Extension):
"""Jinja2 extension for radio tag.
Syntax::
{% radio name="size" id="size_m" label="Medium" value="m" checked=False %}{% endradio %}
"""
tags = {"radio"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the radio tag."""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endradio", lineno)
# Parse body (should be empty, but consume until endradio)
node = nodes.CallBlock(
self.call_method("_render_radio", [], kwargs),
[],
[],
parser.parse_statements(("name:endradio",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_radio(
self,
caller: Any,
name: str | None = None,
id: str | None = None,
label: str = "",
checked: bool = False,
value: str = "",
action: str | None = None,
bind: bool | str = True,
**kwargs: Any,
) -> str:
"""Render the radio tag."""
# Get layout context from RenderContext
render_ctx = get_render_context()
context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
# Auto-generate ID if not provided
if id is None:
id = auto_element_id(context, "radio", kwargs)
# Get label from body content if not provided as parameter
if not label:
label = caller().strip()
else:
# Still need to consume body
caller()
# If name not provided, try to get it from radiogroup context
if name is None:
name = render_ctx.current_radiogroup
# If binding is enabled, try to get checked state from state[name]
# A radio's default key is its group *name*, never its id: every radio
# in a group reads and writes the one key holding the group's selection.
# Passing id=None keeps that exact - an ungrouped radio binds to nothing
# unless bind= names a key explicitly.
bind_key = resolve_bind_key(bind, None, default_key=name)
if bind_key:
try:
if bind_key in state:
# Check if this radio's value matches the group's selected value
checked = state[bind_key] == value
except (KeyError, TypeError, AttributeError) as e:
logger.warning(f"Failed to restore state for radio '{bind_key}': {e}")
# Check if this element should be focused
is_focused = focused_id and id and focused_id == id
# Radio width: "(o) " (4 chars) + label length
radio_width = 4 + len(label)
# Create VNode for reconciliation
vnode = VNodeBuilder("Radio", key=id)
vnode.set_prop("id", id) # Set id as prop so Element gets it
vnode.set_prop("name", name or "")
vnode.set_prop("label", label)
vnode.set_prop("checked", checked)
vnode.set_prop("value", value)
vnode.set_prop("action", action)
vnode.set_prop("bind", bind)
vnode.set_prop("focused", is_focused)
# Normalize class/tabindex/key and forward extra attributes as props.
forward_extra_props(vnode, kwargs)
vnode.set_layout(width=radio_width, height=1)
context.add_vnode(vnode)
# Return marker for text interleaving
return get_element_marker(context)
[docs]
class CheckboxGroupExtension(Extension):
"""Jinja2 extension for checkboxgroup tag.
Syntax::
{% checkboxgroup id="features" options=["A", "B", "C"]
selected=["A"] width=30
border="single" title="Select Features" %}
{% endcheckboxgroup %}
"""
tags = {"checkboxgroup"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the checkboxgroup tag."""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endcheckboxgroup", lineno)
# Parse body (should be empty, but consume until endcheckboxgroup)
node = nodes.CallBlock(
self.call_method("_render_checkboxgroup", [], kwargs),
[],
[],
parser.parse_statements(("name:endcheckboxgroup",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_checkboxgroup(
self,
caller: Any,
id: str | None = None,
options: list[Any] | None = None,
selected: list[Any] | None = None,
width: int = 20,
orientation: Literal["vertical", "horizontal"] = "vertical",
border_style: (
BorderStyle | Literal["single", "double", "rounded"] | None
) = None,
title: str | None = None,
action: str | None = None,
bind: bool | str = True,
**kwargs: Any,
) -> str:
"""Render the checkboxgroup tag."""
# "border" is the canonical alias for "border_style" (see frame/dialog tags).
# Popped here (consumed by the tag) before extras are forwarded.
border_style = kwargs.pop("border", border_style)
# Get layout context from RenderContext
render_ctx = get_render_context()
context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
# Convert numeric parameters
width = int(width)
# Auto-generate ID if not provided
if id is None:
id = auto_element_id(context, "checkboxgroup", kwargs)
# If binding is enabled, try to get selected values from state
bind_key = resolve_bind_key(bind, id)
if bind_key:
try:
if bind_key in state:
selected = state[bind_key]
except (KeyError, TypeError, AttributeError) as e:
logger.warning(
f"Failed to restore state for checkbox_group '{bind_key}': {e}"
)
# Ensure selected is a list
if selected is None:
selected = []
elif not isinstance(selected, list):
selected = list(selected)
# Ensure options is a list
if options is None:
options = []
elif not isinstance(options, list):
options = list(options)
# Check if this element should be focused
is_focused = focused_id and id and focused_id == id
# Calculate total height accounting for borders
total_height = len(options) + (2 if has_border(border_style) else 0)
total_width = width + (2 if has_border(border_style) else 0)
# Create VNode for reconciliation
vnode = VNodeBuilder("CheckboxGroup", key=id)
vnode.set_prop("id", id) # Set id as prop so Element gets it
vnode.set_prop("options", options)
vnode.set_prop("selected_values", selected)
vnode.set_prop("width", width)
vnode.set_prop("orientation", orientation)
vnode.set_prop("border_style", border_style)
vnode.set_prop("title", title)
vnode.set_prop("action", action)
vnode.set_prop("bind", bind)
vnode.set_prop("focused", is_focused)
# Normalize class/tabindex/key and forward extra attributes as props.
forward_extra_props(vnode, kwargs)
vnode.set_layout(width=total_width, height=total_height)
context.add_vnode(vnode)
# Save marker now, before any nested elements are processed
my_marker = get_element_marker(context)
# Consume body (should be empty)
caller()
# Return marker for text interleaving (saved before processing body)
return my_marker
[docs]
class RadioGroupExtension(Extension):
"""Jinja2 extension for radiogroup tag.
Syntax::
{% radiogroup name="size" id="size_group" options=["S", "M", "L"]
selected="M" width=20
border="single" title="Select Size" %}
{% endradiogroup %}
"""
tags = {"radiogroup"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the radiogroup tag."""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endradiogroup", lineno)
# Parse body (should be empty, but consume until endradiogroup)
node = nodes.CallBlock(
self.call_method("_render_radiogroup", [], kwargs),
[],
[],
parser.parse_statements(("name:endradiogroup",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_radiogroup(
self,
caller: Any,
name: str | None = None,
id: str | None = None,
options: list[Any] | None = None,
selected: str | None = None,
width: int = 20,
orientation: Literal["vertical", "horizontal"] = "vertical",
border_style: (
BorderStyle | Literal["single", "double", "rounded"] | None
) = None,
title: str | None = None,
action: str | None = None,
bind: bool | str = True,
**kwargs: Any,
) -> str:
"""Render the radiogroup tag."""
# "border" is the canonical alias for "border_style" (see frame/dialog tags).
# Popped here (consumed by the tag) before extras are forwarded.
border_style = kwargs.pop("border", border_style)
# Get layout context from RenderContext
render_ctx = get_render_context()
context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
# Convert numeric parameters
width = int(width)
# Auto-generate ID if not provided
if id is None:
id = auto_element_id(context, "radiogroup", kwargs)
# If name not provided, use id as the state binding key
if name is None:
name = id
# If binding is enabled, try to get selected value from state[name]
# Like Radio: the group's key is its name (which already falls back to
# the id above), never the id directly.
bind_key = resolve_bind_key(bind, None, default_key=name)
if bind_key:
try:
if bind_key in state:
selected = state[bind_key]
except (KeyError, TypeError, AttributeError) as e:
logger.warning(
f"Failed to restore state for radio_group '{bind_key}': {e}"
)
# Ensure options is a list
if options is None:
options = []
elif not isinstance(options, list):
options = list(options)
# Determine if using nested radio tags (no options provided)
using_nested_radios = len(options) == 0
using_frame = using_nested_radios and (
has_border(border_style) or title is not None
)
if not using_nested_radios:
# Check if this element should be focused
is_focused = focused_id and id and focused_id == id
# Calculate total height accounting for orientation and borders.
# Horizontal groups draw every option on a single line; only the
# vertical layout reserves one row per option (matching the
# element's get_intrinsic_size()).
content_height = 1 if orientation == "horizontal" else len(options)
total_height = content_height + (2 if has_border(border_style) else 0)
total_width = width + (2 if has_border(border_style) else 0)
# Create VNode for reconciliation
vnode = VNodeBuilder("RadioGroup", key=id)
vnode.set_prop("id", id) # Set id as prop so Element gets it
vnode.set_prop("name", name)
vnode.set_prop("options", options)
vnode.set_prop("selected_value", selected)
vnode.set_prop("width", width)
vnode.set_prop("orientation", orientation)
vnode.set_prop("border_style", border_style)
vnode.set_prop("title", title)
vnode.set_prop("action", action)
vnode.set_prop("bind", bind)
vnode.set_prop("focused", is_focused)
# Normalize class/tabindex/key and forward extra attributes as props.
forward_extra_props(vnode, kwargs)
vnode.set_layout(width=total_width, height=total_height)
context.add_vnode(vnode)
# Return marker immediately for non-nested radiogroups
return get_element_marker(context)
# Handle nested radios (with or without frame)
if using_nested_radios and not using_frame:
# For nested radios without a frame, create a VStack VNode
vstack_vnode = VNodeBuilder("VStack", key=id)
apply_reconciliation_key(vstack_vnode, kwargs)
vstack_vnode.set_layout(width="auto", height="auto")
context.push_vnode(vstack_vnode)
# For nested radios with borders/titles, create a frame VNode
elif using_frame:
# Map border_style string to BorderStyle enum
border_style_map = {
"single": BorderStyle.SINGLE,
"double": BorderStyle.DOUBLE,
"rounded": BorderStyle.ROUNDED,
}
if isinstance(border_style, str):
border_enum = border_style_map.get(
border_style.lower(), BorderStyle.SINGLE
)
else:
border_enum = (
border_style if border_style is not None else BorderStyle.SINGLE
)
# Create VNode builder for reconciliation
frame_vnode = VNodeBuilder("Frame", key=id)
apply_reconciliation_key(frame_vnode, kwargs)
frame_vnode.set_prop("border_style", border_enum)
frame_vnode.set_prop("title", title)
frame_vnode.set_prop("scrollable", False)
frame_vnode.set_layout(
width="auto",
height="auto",
padding=(1, 1, 1, 1),
margin=0,
)
context.push_vnode(frame_vnode)
# Set radiogroup name in RenderContext for nested radio tags to access
render_ctx.push_radiogroup(name)
try:
# Render body (may contain nested {% radio %} tags)
caller()
finally:
# Restore previous radiogroup context
render_ctx.pop_radiogroup()
# Pop VNode from stack if we created one
if using_nested_radios:
context.pop_vnode()
# Return marker for text interleaving (get after popping so parent is on top of stack)
return get_element_marker(context)
[docs]
class TextAreaExtension(Extension):
"""Jinja2 extension for textarea tag.
Syntax::
{% textarea id="editor" value=state.content
width=60 height=15 wrap_mode="soft"
border="single" title="Editor" %}
{% endtextarea %}
"""
tags = {"textarea"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the textarea 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, "endtextarea", lineno)
# Parse body (should be empty, but consume until endtextarea)
node = nodes.CallBlock(
self.call_method("_render_textarea", [], kwargs),
[],
[],
parser.parse_statements(("name:endtextarea",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_textarea(
self,
caller: Any,
id: str | None = None,
value: str = "",
width: int | str = "auto",
height: int | str = "auto",
wrap_mode: Literal["none", "soft", "hard"] = "none",
max_lines: int | None = None,
show_scrollbar: bool = True,
show_scrollbar_x: bool = False,
border_style: BorderStyle | Literal["single", "double", "rounded"] = "single",
action: str | None = None,
bind: bool | str = True,
autosize: bool = False,
max_height: int | None = None,
capture_tab: bool = False,
tab_width: int = 4,
**kwargs: Any,
) -> str:
"""Render the textarea tag.
Parameters
----------
caller : callable
Jinja2 caller for body content
id : str, optional
Element identifier
value : str
Initial value (default: "")
width : int or str
TextArea width (default: "auto")
height : int or str
TextArea height (default: "auto")
wrap_mode : str
Line wrapping mode: "none", "soft", or "hard" (default: "none")
max_lines : int, optional
Maximum number of lines
show_scrollbar : bool
Whether to show vertical scrollbar (default: True)
show_scrollbar_x : bool
Whether to show horizontal scrollbar when needed (default: False)
border_style : str
Border style (default: "single")
action : str, optional
Action ID to dispatch on content change
bind : bool or str
State binding. True auto-binds value to state[id]; False disables
binding; a string names the state key to bind to instead, so the
id stays a pure identity (default: True).
autosize : bool
Grow the height to fit the content up to ``max_height`` total rows
(default: False). When enabled the ``height`` attribute is ignored.
max_height : int, optional
Maximum total height (rows, including borders) when ``autosize`` is
enabled. ``None`` (default) means unbounded; content beyond it
scrolls.
capture_tab : bool
Tab inserts an indent instead of moving focus (default: False).
Shift+Tab still moves focus backward, so the field can be left.
tab_width : int
Spaces inserted by Tab when ``capture_tab`` is on (default: 4)
classes : str, optional
CSS-like class names for styling
tab_index : int, optional
Tab order for focus navigation
Returns
-------
str
Rendered output
"""
# "border" is the canonical alias for "border_style" (see frame/dialog tags).
# Popped here (consumed by the tag) before extras are forwarded.
border_style = kwargs.pop("border", border_style)
# Get layout context from RenderContext
render_ctx = get_render_context()
context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
autosize = bool(autosize)
if max_height is not None:
max_height = int(max_height)
# Store original width/height specs for layout. When autosizing, the
# height is driven by the element's content-based intrinsic size, so
# force an "auto" spec regardless of any height attribute.
width_spec = width
height_spec = "auto" if autosize else height
# Detect dynamic ("fill"/"auto"/percentage) sizing. When either axis is a
# non-numeric string spec the element must report minimal layout
# constraints and expand via set_bounds; otherwise ElementNode treats it
# as fixed-size and its (large) intrinsic size inflates and collapses the
# parent container. The element receives numeric width/height for its
# initial render, so the spec intent is conveyed via this flag.
dynamic_sizing = (isinstance(width_spec, str) and not width_spec.isdigit()) or (
isinstance(height_spec, str) and not height_spec.isdigit()
)
# Convert numeric parameters for element creation
# If width/height are "fill" or other string specs, use default numeric values
# for initial element creation (will be resized on bounds assignment)
if isinstance(width, str) and not width.isdigit():
element_width = 40 # Default for initial render
else:
element_width = int(width)
if isinstance(height, str) and not height.isdigit():
element_height = 10 # Default for initial render
else:
element_height = int(height)
show_scrollbar = bool(show_scrollbar)
show_scrollbar_x = bool(show_scrollbar_x)
if max_lines is not None:
max_lines = int(max_lines)
# Auto-generate ID if not provided
if id is None:
id = auto_element_id(context, "textarea", kwargs)
# Get initial value from body if not provided as attribute
if not value:
body = caller().strip()
value = body if body else ""
else:
# Value provided as attribute, consume body anyway
caller()
# If binding is enabled and id is provided, try to get value from state
# (state value takes precedence over body/value parameter)
bind_key = resolve_bind_key(bind, id)
if bind_key:
try:
if bind_key in state:
value = str(state[bind_key])
except (KeyError, TypeError, AttributeError) as e:
logger.warning(
f"Failed to restore state for textarea '{bind_key}': {e}"
)
# Check if this element should be focused
is_focused = focused_id and id and focused_id == id
# Create VNode for reconciliation
vnode = VNodeBuilder("TextArea", key=id)
vnode.set_prop("id", id) # Set id as prop so Element gets it
vnode.set_prop("value", value)
vnode.set_prop("width", element_width)
vnode.set_prop("height", element_height)
vnode.set_prop("wrap_mode", wrap_mode)
vnode.set_prop("max_lines", max_lines)
vnode.set_prop("show_scrollbar", show_scrollbar)
vnode.set_prop("show_scrollbar_x", show_scrollbar_x)
vnode.set_prop("border_style", border_style)
vnode.set_prop("action", action)
vnode.set_prop("bind", bind)
vnode.set_prop("focused", is_focused)
vnode.set_prop("dynamic_sizing", dynamic_sizing)
vnode.set_prop("autosize", autosize)
vnode.set_prop("max_height", max_height)
vnode.set_prop("capture_tab", bool(capture_tab))
vnode.set_prop("tab_width", int(tab_width))
# Normalize class/tabindex/key and forward extra attributes as props.
forward_extra_props(vnode, kwargs)
# Account for borders in layout size if present
layout_width = width_spec
layout_height = height_spec
if border_style not in (None, "none"):
# Add 2 for borders (top+bottom, left+right) if width/height are numeric
if isinstance(width_spec, int):
layout_width = width_spec + 2
if isinstance(height_spec, int):
layout_height = height_spec + 2
vnode.set_layout(width=layout_width, height=layout_height)
context.add_vnode(vnode)
# Return marker for text interleaving
return get_element_marker(context)
[docs]
class CodeEditorExtension(Extension):
"""Jinja2 extension for codeeditor tag.
Syntax::
{% codeeditor id="editor" language="python" theme="monokai"
width=80 height=25 show_line_numbers=True %}
{% endcodeeditor %}
"""
tags = {"codeeditor"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the codeeditor 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, "endcodeeditor", lineno)
# Parse body (should be empty, but consume until endcodeeditor)
node = nodes.CallBlock(
self.call_method("_render_codeeditor", [], kwargs),
[],
[],
parser.parse_statements(("name:endcodeeditor",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_codeeditor(
self,
caller: Any,
id: str | None = None,
value: str = "",
language: str | None = "python",
theme: str = "monokai",
filename_hint: str | None = None,
width: int | str = 60,
height: int | str = 20,
show_line_numbers: bool = True,
wrap_mode: Literal["none", "soft"] = "none",
show_scrollbar: bool = True,
border_style: BorderStyle | Literal["single", "double", "rounded"] = "single",
action: str | None = None,
bind: bool | str = True,
capture_tab: bool = True,
tab_width: int = 4,
**kwargs: Any,
) -> str:
"""Render the codeeditor tag.
Parameters
----------
caller : callable
Jinja2 caller for body content
id : str, optional
Element identifier
value : str
Initial source code (default: "")
language : str, optional
Programming language (default: "python").
Use "auto" for auto-detection, None to disable highlighting.
theme : str
Color theme name (default: "monokai")
filename_hint : str, optional
Filename hint for auto-detection
width : int or str
Editor width (default: 60)
height : int or str
Editor height (default: 20)
show_line_numbers : bool
Whether to show line numbers (default: True)
wrap_mode : str
Line wrapping mode: "none" or "soft" (default: "none")
show_scrollbar : bool
Whether to show scrollbar (default: True)
border_style : str
Border style (default: "single")
action : str, optional
Action ID to dispatch on content change
bind : bool or str
State binding. True auto-binds value to state[id]; False disables
binding; a string names the state key to bind to instead, so the
id stays a pure identity (default: True).
capture_tab : bool
Tab inserts an indent instead of moving focus (default: True for
the editor). Shift+Tab still moves focus backward.
tab_width : int
Spaces inserted by Tab when ``capture_tab`` is on (default: 4)
classes : str, optional
CSS-like class names for styling
tab_index : int, optional
Tab order for focus navigation
Returns
-------
str
Rendered output
"""
# "border" is the canonical alias for "border_style" (see frame/dialog tags).
# Popped here (consumed by the tag) before extras are forwarded.
border_style = kwargs.pop("border", border_style)
# Get layout context from RenderContext
render_ctx = get_render_context()
context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
# Store original width/height specs for layout
width_spec = width
height_spec = height
# Convert numeric parameters
if isinstance(width, str) and not width.isdigit():
element_width = 60 # Default
else:
element_width = int(width)
if isinstance(height, str) and not height.isdigit():
element_height = 20 # Default
else:
element_height = int(height)
show_scrollbar = bool(show_scrollbar)
show_line_numbers = bool(show_line_numbers)
# Auto-generate ID if not provided
if id is None:
id = auto_element_id(context, "codeeditor", kwargs)
# Get initial value from body if not provided
if not value:
body = caller().strip()
value = body if body else ""
else:
caller() # Consume body anyway
# Get value from state if binding is enabled
bind_key = resolve_bind_key(bind, id)
if bind_key:
try:
if bind_key in state:
value = str(state[bind_key])
except (KeyError, TypeError, AttributeError) as e:
logger.warning(
f"Failed to restore state for codeeditor '{bind_key}': {e}"
)
# Check if this element should be focused
is_focused = focused_id and id and focused_id == id
# Create VNode for reconciliation
vnode = VNodeBuilder("CodeEditor", key=id)
vnode.set_prop("id", id) # Set id as prop so Element gets it
vnode.set_prop("value", value)
vnode.set_prop("language", language)
vnode.set_prop("theme", theme)
vnode.set_prop("filename_hint", filename_hint)
vnode.set_prop("width", element_width)
vnode.set_prop("height", element_height)
vnode.set_prop("show_line_numbers", show_line_numbers)
vnode.set_prop("wrap_mode", wrap_mode)
vnode.set_prop("show_scrollbar", show_scrollbar)
vnode.set_prop("border_style", border_style)
vnode.set_prop("action", action)
vnode.set_prop("bind", bind)
vnode.set_prop("capture_tab", bool(capture_tab))
vnode.set_prop("tab_width", int(tab_width))
vnode.set_prop("focused", is_focused)
# Normalize class/tabindex/key and forward extra attributes as props.
forward_extra_props(vnode, kwargs)
# Account for borders in layout size if present
layout_width = width_spec
layout_height = height_spec
if border_style not in (None, "none"):
# Add 2 for borders (top+bottom, left+right) if width/height are numeric
if isinstance(width_spec, int):
layout_width = width_spec + 2
if isinstance(height_spec, int):
layout_height = height_spec + 2
vnode.set_layout(width=layout_width, height=layout_height)
context.add_vnode(vnode)
# Return marker for text interleaving
return get_element_marker(context)
[docs]
class SliderExtension(Extension):
"""Jinja2 extension for {% slider %} tag.
Syntax::
{% slider id="volume" min=0 max=100 value=50 %}{% endslider %}
{% slider id="opacity" min=0.0 max=1.0 step=0.1 float_mode=True %}{% endslider %}
{% slider id="brightness" label="Brightness" width=30 %}{% endslider %}
"""
tags = {"slider"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the slider tag."""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endslider", lineno)
node = nodes.CallBlock(
self.call_method("_render_slider", [], kwargs),
[],
[],
parser.parse_statements(("name:endslider",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_slider(
self,
caller: Any,
id: str | None = None,
min: float = 0,
max: float = 100,
value: float | None = None,
step: float = 1,
width: int = 20,
float_mode: bool = False,
label: str | None = None,
show_value: bool = True,
action: str | None = None,
bind: bool | str = True,
**kwargs: Any,
) -> str:
"""Render the slider tag."""
render_ctx = get_render_context()
context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
width = safe_int(width, default=20, name="width")
min_val = float(min)
max_val = float(max)
step_val = float(step)
if value is None:
value = min_val
else:
value = float(value)
if id is None:
id = auto_element_id(context, "slider", kwargs)
bind_key = resolve_bind_key(bind, id)
if bind_key:
try:
if bind_key in state:
value = float(state[bind_key])
except (KeyError, TypeError, AttributeError, ValueError) as e:
logger.warning(f"Failed to restore state for slider '{bind_key}': {e}")
is_focused = focused_id and id and focused_id == id
caller()
layout_width = width + 2
if label:
layout_width += len(label) + 1
if show_value:
if float_mode:
layout_width += 1 + len(f"{max_val:.1f}")
else:
layout_width += 1 + len(str(int(max_val)))
vnode = VNodeBuilder("Slider", key=id)
vnode.set_prop("id", id)
vnode.set_prop("min_val", min_val)
vnode.set_prop("max_val", max_val)
vnode.set_prop("value", value)
vnode.set_prop("step", step_val)
vnode.set_prop("width", width)
vnode.set_prop("float_mode", bool(float_mode))
vnode.set_prop("label", label)
vnode.set_prop("show_value", bool(show_value))
vnode.set_prop("action", action)
vnode.set_prop("bind", bind)
vnode.set_prop("focused", is_focused)
# Normalize class/tabindex/key and forward extra attributes as props.
forward_extra_props(vnode, kwargs)
vnode.set_layout(width=layout_width, height=1)
context.add_vnode(vnode)
return get_element_marker(context)
[docs]
class ToggleExtension(Extension):
"""Jinja2 extension for {% toggle %} tag.
Syntax::
{% toggle id="dark_mode" label="Dark Mode" %}{% endtoggle %}
{% toggle id="notifications" checked=True %}{% endtoggle %}
{% toggle id="sound" label_mode="dual" on_label="ON" off_label="OFF" %}{% endtoggle %}
"""
tags = {"toggle"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the toggle tag."""
lineno = next(parser.stream).lineno
kwargs = parse_tag_attributes(parser, "endtoggle", lineno)
node = nodes.CallBlock(
self.call_method("_render_toggle", [], kwargs),
[],
[],
parser.parse_statements(("name:endtoggle",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_toggle(
self,
caller: Any,
id: str | None = None,
checked: bool = False,
label: str | None = None,
on_label: str = "ON",
off_label: str = "OFF",
label_mode: Literal["single", "dual"] = "single",
action: str | None = None,
bind: bool | str = True,
**kwargs: Any,
) -> str:
"""Render the toggle tag."""
render_ctx = get_render_context()
context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
if id is None:
id = auto_element_id(context, "toggle", kwargs)
bind_key = resolve_bind_key(bind, id)
if bind_key:
try:
if bind_key in state:
checked = bool(state[bind_key])
except (KeyError, TypeError, AttributeError) as e:
logger.warning(f"Failed to restore state for toggle '{bind_key}': {e}")
is_focused = focused_id and id and focused_id == id
caller()
if label_mode == "dual":
layout_width = len(off_label) + 1 + 6 + 1 + len(on_label)
else:
layout_width = 5
if label:
layout_width += 1 + len(label)
vnode = VNodeBuilder("Toggle", key=id)
vnode.set_prop("id", id)
vnode.set_prop("checked", bool(checked))
vnode.set_prop("label", label)
vnode.set_prop("on_label", on_label)
vnode.set_prop("off_label", off_label)
vnode.set_prop("label_mode", label_mode)
vnode.set_prop("action", action)
vnode.set_prop("bind", bind)
vnode.set_prop("focused", is_focused)
# Normalize class/tabindex/key and forward extra attributes as props.
forward_extra_props(vnode, kwargs)
vnode.set_layout(width=layout_width, height=1)
context.add_vnode(vnode)
return get_element_marker(context)
[docs]
class DataGridExtension(Extension):
"""Jinja2 extension for {% datagrid %} tag.
Syntax::
{% datagrid id="my_grid" data=grid_data columns=["Name", "Age", "City"]
width=60 height=15 %}
{% enddatagrid %}
{% datagrid id="inventory" data=items
columns=[
{"key": "name", "label": "Product", "width": 20},
{"key": "qty", "label": "Qty", "width": 8},
{"key": "price", "label": "Price", "width": 10}
]
show_row_numbers=True %}
{% enddatagrid %}
"""
tags = {"datagrid"}
[docs]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse the datagrid 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, "enddatagrid", lineno)
node = nodes.CallBlock(
self.call_method("_render_datagrid", [], kwargs),
[],
[],
parser.parse_statements(("name:enddatagrid",), drop_needle=True),
).set_lineno(lineno)
return node
def _render_datagrid(
self,
caller: Any,
id: str | None = None,
data: list[list[str]] | None = None,
columns: list[str] | list[dict[str, Any]] | None = None,
width: int = 60,
height: int = 15,
show_row_numbers: bool = True,
editable: bool = True,
border: str | None = None,
border_style: str = "single",
show_scrollbar: bool = True,
bind: bool | str = True,
**kwargs: Any,
) -> str:
"""Render the datagrid tag.
Parameters
----------
caller : callable
Jinja2 caller for body content
id : str, optional
Element identifier
data : list of list of str, optional
2D grid data (rows x columns)
columns : list of str or list of dict, optional
Column definitions. Can be simple strings (headers) or dicts with
"key", "label", and "width" keys.
width : int
Total display width (default: 60)
height : int
Total display height (default: 15)
show_row_numbers : bool
Show row numbers on left side (default: True)
editable : bool
Whether cells can be edited (default: True)
border : str, optional
Canonical border style (``single``, ``double``, ``rounded``,
``none``). Takes precedence over ``border_style`` when given.
border_style : str
Border style: "single", "double", "rounded", etc. (default: "single")
show_scrollbar : bool
Whether to show scrollbars when content overflows (default: True)
bind : bool or str
State binding. True auto-binds data to state[id]; False disables
binding; a string names the state key to bind to instead, so the
id stays a pure identity (default: True).
classes : str, optional
CSS-like class names for styling
tab_index : int, optional
Tab order for focus navigation
Returns
-------
str
Rendered output
"""
if border is not None:
border_style = border
# Get layout context from RenderContext
render_ctx = get_render_context()
context = render_ctx.layout_context
state = render_ctx.state
focused_id = render_ctx.focused_id
# Convert numeric parameters
width = safe_int(width, default=60, name="width")
height = safe_int(height, default=15, name="height")
# Auto-generate ID if not provided
if id is None:
id = auto_element_id(context, "datagrid", kwargs)
# Consume body (typically empty for datagrid)
caller()
# If binding is enabled and id is provided, try to get data from state
bind_key = resolve_bind_key(bind, id)
if bind_key:
try:
if bind_key in state:
state_data = state[bind_key]
if isinstance(state_data, list):
data = state_data
except (KeyError, TypeError, AttributeError) as e:
logger.warning(
f"Failed to restore state for datagrid '{bind_key}': {e}"
)
# Ensure data is a list
if data is None:
data = []
# Ensure columns is a list
if columns is None:
columns = []
# Check if this element should be focused
is_focused = focused_id and id and focused_id == id
# Create VNode for reconciliation
vnode = VNodeBuilder("DataGrid", key=id)
vnode.set_prop("id", id)
vnode.set_prop("data", data)
vnode.set_prop("columns", columns)
vnode.set_prop("width_spec", width)
vnode.set_prop("height_spec", height)
vnode.set_prop("show_row_numbers", bool(show_row_numbers))
vnode.set_prop("editable", bool(editable))
vnode.set_prop("border_style", border_style)
vnode.set_prop("show_scrollbar", bool(show_scrollbar))
vnode.set_prop("bind", bind)
vnode.set_prop("focused", is_focused)
# Normalize class/tabindex/key and forward extra attributes as props.
forward_extra_props(vnode, kwargs)
# Set layout dimensions
vnode.set_layout(width=width, height=height)
context.add_vnode(vnode)
return get_element_marker(context)