Source code for wijjit.tags.input

"""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 ButtonExtension(Extension): """Jinja2 extension for {% button %} tag. Syntax:: {% button id="submit" %}Submit{% endbutton %} """ tags = {"button"}
[docs] def parse(self, parser: Parser) -> nodes.Node: """Parse the button 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, "endbutton", lineno) # Parse body (button label) node = nodes.CallBlock( self.call_method("_render_button", [], kwargs), [], [], parser.parse_statements(("name:endbutton",), drop_needle=True), ).set_lineno(lineno) return node
def _render_button( self, caller: Any, id: str | None = None, action: str | None = None, label: str = "", **kwargs: Any, ) -> str: """Render the button tag. Parameters ---------- caller : callable Jinja2 caller for body content (button label) id : str, optional Element identifier action : str, optional Action ID to dispatch when button is clicked label : str, optional Button label. If empty, the tag body is used as the label. classes : str, optional CSS-like class names for styling tab_index : int, optional Tab order for focus navigation Returns ------- str Rendered output """ # Get layout context from RenderContext render_ctx = get_render_context() layout_context = render_ctx.layout_context focused_id = render_ctx.focused_id # Label comes from the label attribute, falling back to the tag body. body = caller().strip() if not label: label = body # Auto-generate ID if not provided if id is None: id = auto_element_id(layout_context, "button", kwargs) # Check if this element should be focused is_focused = focused_id and id and focused_id == id # Create VNode for reconciliation # Button width is based on label length + brackets button_width = visible_length(label) + 4 # "< label >" vnode = VNodeBuilder("Button", key=id) vnode.set_prop("id", id) # Set id as prop so Element gets it vnode.set_prop("label", label) vnode.set_prop("action", action) vnode.set_prop("focused", is_focused) # Normalize class/tabindex/key and forward any extra attributes (e.g. # style) onto the VNode as props so typos surface to the validator. forward_extra_props(vnode, kwargs) vnode.set_layout(width=button_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)