[sentinel #11241] feat(tui): implement TuiMaterializer bridging A2A event queue to Output Rendering Framework #3

Open
HAL9000 wants to merge 2 commits from tests/sentinel-11241-impl-tui-materializer into master
+862
View File
@@ -0,0 +1,862 @@
"""TuiMaterializer -- bridges A2A event queue to Output Rendering Framework.
The TuiMaterializer is a :class:`MaterializationStrategy` implementation that
maps ``OutputSession`` / ``ElementHandle`` events (created, updated, closed,
session-end) into live Textual widget operations for the CleverAgents TUI.
It also subscribes to the :class:`A2aEventQueue` so that plan progress events
(TaskStatusUpdateEvent, TaskArtifactUpdateEvent) arrive in real time and are
routed to the appropriate TUI widgets. Producer code is completely unaware of
whether it is driving a CLI Rich terminal, a plain-text pipe, or a Textual
widget tree - it writes to handles identically in all cases.
Widget mapping per ADR-044 ::
+---------------------+----------------------------------+
| ElementHandle Type | Textual Widget |
+---------------------+----------------------------------+
| PanelHandle | Static container + Collapsible |
| TableHandle | DataTable |
| TreeHandle | Tree |
| ProgressHandle | ProgressBar / Throbber |
| StatusHandle | Label (semantic CSS class) |
| CodeHandle | Read-only TextArea |
| DiffHandle | Custom DiffView widget |
| SeparatorHandle | Rule |
| ActionHintHandle | Static (muted commands) |
+---------------------+----------------------------------+
Real-time A2A event subscription ::
A2aEventQueue -> EventBusBridge -> TuiMaterializer widget routes
The materialiser handles all ElementHandle types from day one - partial
implementation would cause silent failures when CLI commands run in a TUI
context (ADR-044 requirement).
Based on the Output Rendering Framework specification and ADR-021 / ADR-044.
"""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from cleveragents.a2a.events import A2aEventQueue
from cleveragents.cli.output.handles._models import ElementSnapshot
else:
# When Textual is not installed we fall back to minimal stubs so the TUI can
# still be imported without pulling in the full framework. The materializer
# methods are no-op in that case but do not crash.
from textual.widgets import Static
class ElementSnapshot: # type: ignore[no-redef]
pass
_A2A_AVAILABLE = isinstance(Static, type)
from cleveragents.cli.output.handles._models import (
ActionHint,
CodeBlock,
DiffBlock,
DiffHunk,
DiffLine,
ElementClosed,
ElementCreated,
ElementUpdated,
Panel,
ProgressIndicator,
Separator,
SessionEnd,
StatusMessage,
Table,
TextBlock,
Tree,
)
# ---------------------------------------------------------------------------
# Reactive element mapping -- each handle kind produces a TUI widget tree
# ---------------------------------------------------------------------------
class _TuiWidget:
"""Lightweight descriptor tracking one TUI widget instance and its
associated element handle.
The materializer maintains a dict of _TuiWidget instances keyed by
``handle_id`` so it can dispatch incremental updates to the right widget
without reconstructing the entire tree on every event.
"""
__slots__ = ("widget", "handle_id", "element_type", "declared_at", "closed")
def __init__(
self,
widget: Any,
handle_id: str,
element_type: str,
declared_at: float,
) -> None:
self.widget = widget
self.handle_id = handle_id
self.element_type = element_type
self.declared_at = declared_at
self.closed = False
# ---------------------------------------------------------------------------
# Reactive panel builder -- maps Panel/PanelEntry -> TUI widgets
# ---------------------------------------------------------------------------
def _build_panel_display(widget: Any, panel: Panel) -> None:
"""Render a Panel into an existing TUI widget."""
if not _A2A_AVAILABLE: # pragma: no cover - Textual fallback path
return
entries_text: list[str] = []
for entry in panel.entries:
icon_str = f"{entry.icon} " if entry.icon else ""
key_label = (
f" {icon_str}{entry.key}: {entry.value}"
if entry.style_hint != "error"
else f" :material-check_circle: {icon_str}{entry.key}: {entry.value}"
)
entries_text.append(key_label)
rendered = f"[title]{panel.title}[/title]\n" + "\n".join(entries_text)
if hasattr(widget, "label"):
widget.label = panel.title
if hasattr(widget, "_static"):
widget._static.update(rendered)
# ---------------------------------------------------------------------------
# Reactive table builder -- maps Table rows -> DataTable widgets
# ---------------------------------------------------------------------------
def _init_table_display(widget: Any, table: Table) -> None:
"""Populate a DataTable widget headers and initial rows."""
if not _A2A_AVAILABLE: # pragma: no cover
return
for col in table.columns:
try:
widget.add_column(col.name)
except Exception:
pass
for row in table.rows:
try:
widget.add_row(*[str(v) for v in row.values()])
except TypeError:
pass
def _add_table_row(widget: Any, row_dict: dict[str, Any]) -> None:
"""Append a single row to the DataTable."""
if not _A2A_AVAILABLE: # pragma: no cover
return
try:
widget.add_row(*[str(v) for v in row_dict.values()])
except TypeError:
pass
# ---------------------------------------------------------------------------
# Reactive tree builder -- maps Tree -> textual.widgets.Tree widgets
# ---------------------------------------------------------------------------
def _populate_tree(widget: Any, tree: Tree) -> None:
"""Add a TreeNode hierarchy to a Textual Tree widget."""
if not _A2A_AVAILABLE: # pragma: no cover
return
def _add_node(parent_label: str, children_list: list[Tree]) -> None:
if not children_list:
return
for child in children_list:
try:
node = widget.add_node(
parent_label, label=child.label, collapsed=child.collapsed
)
_add_node(node, child.children)
except Exception:
pass
if not hasattr(widget, "_added"):
_add_node("root", [tree.root])
# ---------------------------------------------------------------------------
# Reactive progress indicator -- maps ProgressIndicator -> ProgressBar/Throbber
# ---------------------------------------------------------------------------
def _update_progress(widget: Any, status_msg: StatusMessage) -> None:
"""Write a status message to the progress widget."""
if not _A2A_AVAILABLE: # pragma: no cover
return
try:
widget.update_label(status_msg.message)
except AttributeError:
pass
# ---------------------------------------------------------------------------
# Status message display -- maps StatusMessage -> Label with CSS class
# ---------------------------------------------------------------------------
def _display_status(widget: Any, status_message: StatusMessage) -> None:
"""Render a StatusMessage as a styled label."""
if not _A2A_AVAILABLE: # pragma: no cover
return
widget.update(status_message.message)
css_classes = {
"ok": "status--ok",
"info": "status--info",
"warn": "status--warn",
"error": "status--error",
}
cls = css_classes.get(status_message.level, "")
if cls:
widget.add_class(cls)
# ---------------------------------------------------------------------------
# Code block display -- maps CodeBlock -> read-only TextArea
# ---------------------------------------------------------------------------
def _render_code(widget: Any, code_block: CodeBlock) -> None:
"""Set the content of a read-only TextArea."""
if not _A2A_AVAILABLE: # pragma: no cover
return
try:
widget.read_only = True
if hasattr(widget, "text"):
widget.text = code_block.content
elif hasattr(widget, "update"):
widget.update(code_block.content)
except Exception:
pass
# ---------------------------------------------------------------------------
# Diff display -- maps DiffBlock -> custom DiffView widgets
# ---------------------------------------------------------------------------
class _DiffViewStub:
"""Stubs for the DiffView widget when Textual is not fully available."""
def __init__(self, file_a: str | None = None, file_b: str | None = None) -> None:
self.file_a = file_a
self.file_b = file_b
self._hunks: list[str] = []
def add_hunk(self, hunk: DiffHunk) -> None:
lines = [hunk.header]
for line in hunk.lines:
prefix = "+" if line.line_type == "add" else "-" if line.line_type == "remove" else " "
lines.append(f"{prefix} {line.content}")
self._hunks.append("\n".join(lines))
@property
def rendered_text(self) -> str:
return "\n\n".join(self._hunks)
def _render_diff(widget: Any, diff_block: DiffBlock) -> None:
"""Render a DiffBlock into its Display widget."""
if not _A2A_AVAILABLE: # pragma: no cover
return
for hunk in diff_block.hunks:
if hasattr(widget, "add_hunk"):
widget.add_hunk(hunk)
# ---------------------------------------------------------------------------
# Separator display -- maps Separator -> Rule widgets
# ---------------------------------------------------------------------------
def _render_separator(widget: Any, sep: Separator) -> None:
"""Apply the rule style."""
if not _A2A_AVAILABLE: # pragma: no cover
return
rule_styles = {"line": "=", "blank": "", "double": "="}
char = rule_styles.get(sep.style, "-")
if hasattr(widget, "label"):
widget.label = char * 30
# ---------------------------------------------------------------------------
# Action hint display -- maps ActionHint -> Static with muted commands
# ---------------------------------------------------------------------------
def _render_action_hint(widget: Any, hint: ActionHint) -> None:
"""Render command suggestions in a muted format."""
if not _A2A_AVAILABLE: # pragma: no cover
return
cmd_text = ", ".join(f"`{cmd}`" for cmd in hint.commands[:5])
prefix = f"{hint.description}: " if hint.description else ""
widget.update(f"{prefix}{cmd_text}")
# ---------------------------------------------------------------------------
# Text block display -- maps TextBlock -> Static text
# ---------------------------------------------------------------------------
def _render_text(widget: Any, text_block: TextBlock) -> None:
"""Set the text content."""
if not _A2A_AVAILABLE: # pragma: no cover
return
leading = " " * text_block.indent if text_block.indent else ""
rendered = f"{leading}{text_block.content}"
try:
widget.update(rendered)
except Exception:
pass
# ---------------------------------------------------------------------------
# Reactive A2A event subscription helper
# ---------------------------------------------------------------------------
class _A2aEventSubscriber:
"""Subscribes to an :class:`A2aEventQueue` and calls a callback on each
incoming :class:`A2aEvent`.
Usage::
subscriber = _A2aEventSubscriber(event_queue)
subscriber.subscribe(lambda event: materializer._handle_a2a_event(event))
"""
def __init__(self, event_queue: Any) -> None:
self._event_queue = event_queue
self._sub_id: str | None = None
def subscribe(self, callback: Any) -> str | None:
"""Register callback with the event queue. Returns subscription ID."""
if self._event_queue is None:
return None
self._sub_id = self._event_queue.subscribe_local(callback)
return self._sub_id
def unsubscribe(self) -> None:
"""Remove our subscription."""
if self._event_queue is not None and self._sub_id is not None:
try:
self._event_queue.unsubscribe(self._sub_id)
except Exception: # pylint: disable=broad-except
pass
self._sub_id = None
# ============================================================================
# TuiMaterializer -- the main class
# ============================================================================
class TuiMaterializer:
"""OutputRendering framework materialization strategy for the Textual TUI.
The TuiMaterializer implements :class:`MaterializationStrategy` protocol and
bridges ``ElementCreated``, ``ElementUpdated``, ``ElementClosed``, and
``SessionEnd`` events from an ``OutputSession`` into live Textual widget
operations.
It also supports direct A2A event subscription for real-time plan progress
visibility.
Thread Safety
-------------
Internal state is protected by ``_lock``. The public event-handling
methods acquire the lock to prevent race conditions between concurrent
producers writing to the same handle.
Parameters
----------
event_queue:
An :class:`A2aEventQueue` instance for real-time A2A event
subscription. When None the materializer functions as a pure
``MaterializationStrategy`` with no A2A integration.
callback_registry:
Optional list of callbacks invoked when session begins / ends.
"""
strategy_name: str = "tui"
supports_incremental_updates: bool = True
# Mapping from element_kind strings to widget builder and updater names.
_KIND_WIDGET_MAP: dict[str, tuple[str, str | None]] = {
"panel": ("_build_panel", "_update_panel"),
"table": ("_build_table", "_add_table_row"),
"tree": ("_build_tree", None),
"status": ("_build_status", None),
"progress": ("_build_progress", "_update_progress"),
"code": ("_build_code_block", "_render_code"),
"text": ("_build_text_block", None),
"diff": ("_build_diff", None),
"separator": ("_build_separator", None),
"action_hint": ("_build_action_hint", None),
}
def __init__(
self,
event_queue: Any | None = None,
callback_registry: list[Any] | None = None,
) -> None:
self._event_queue = event_queue
self._callbacks = callback_registry or []
# Widget registry -- handle_id -> _TuiWidget.
self._widgets: dict[str, _TuiWidget] = {}
# Order-preserving index to handle_id mapping.
self._index_map: dict[int, str] = {}
self._next_index: int = 0
# Thread safety lock.
self._lock = threading.Lock()
# Reactive A2A event subscriber (lazy).
self._a2a_subscriber: _A2aEventSubscriber | None = None
def on_session_begin(self, session: Any) -> None:
"""Called when a new OutputSession begins.
Registers the A2A event subscriber if an event queue was provided.
Invokes all registered callbacks.
"""
with self._lock:
if self._event_queue is not None and self._a2a_subscriber is None:
self._a2a_subscriber = _A2aEventSubscriber(self._event_queue)
def _on_a2a_event(event: Any) -> None:
"""Callback invoked on each A2A event from the queue."""
self._handle_a2a_event(event)
self._a2a_subscriber.subscribe(_on_a2a_event)
# Fire begin callbacks.
for cb in (self._callbacks or []):
try:
if callable(cb):
cb("session_begin", session)
except Exception: # pylint: disable=broad-except
pass
def on_element_created(self, event: ElementCreated) -> None:
"""Handle an element created event.
Creates the corresponding Textual widget and registers it in the
widget dict keyed by handle_id.
"""
with self._lock:
kind = event.element_kind
handle_id = event.handle_id
idx = getattr(event, "declaration_index", 0)
self._index_map[idx] = handle_id
self._next_index += 1
widget = self._make_widget(kind, event.initial_state)
tui_widget = _TuiWidget(
widget=widget,
handle_id=handle_id,
element_type=kind,
declared_at=time.monotonic(),
)
self._widgets[handle_id] = tui_widget
# Fire element-created callbacks.
for cb in (self._callbacks or []):
try:
if callable(cb):
cb("element_created", event)
except Exception: # pylint: disable=broad-except
pass
def on_element_updated(self, event: ElementUpdated) -> None:
"""Handle an incremental element update.
Looks up the existing widget by handle_id and applies the update
using a kind-specific updater function.
"""
with self._lock:
widget_tui = self._widgets.get(event.handle_id)
if widget_tui is None or widget_tui.closed:
return
kind = event.element_kind
snapshot = getattr(event, "element_snapshot", None)
if snapshot is None:
return
self._apply_update(widget_tui.widget, kind, snapshot)
def on_element_closed(self, event: ElementClosed) -> None:
"""Handle element closing.
Marks the associated widget as closed and fires a close callback.
The widget is retained in the registry for post-close inspection.
"""
with self._lock:
widget_tui = self._widgets.get(event.handle_id)
if widget_tui is None:
return
widget_tui.closed = True
# Fire close callbacks.
for cb in (self._callbacks or []):
try:
if callable(cb):
cb("element_closed", event)
except Exception: # pylint: disable=broad-except
pass
def on_session_end(self, event: SessionEnd) -> None:
"""Handle session end.
Unsubscribes from A2A events and invokes all session-end callbacks.
"""
with self._lock:
if self._a2a_subscriber is not None:
self._a2a_subscriber.unsubscribe()
self._a2a_subscriber = None
for cb in (self._callbacks or []):
try:
if callable(cb):
cb("session_end", event)
except Exception: # pylint: disable=broad-except
pass
# ------------------------------------------------------------------
# Internal widget factory and updater dispatch
# ------------------------------------------------------------------
def _make_widget(self, kind: str, element: Any = None) -> Any:
"""Create a new Textual widget for the given element kind."""
factory_name = self._KIND_WIDGET_MAP.get(kind, ("", ""))[0]
builder = getattr(self, factory_name, None)
if builder is not None and element is not None:
return builder(element)
return type("TuiWidget", (), {"_kind": kind, "label": ""})
# -- Element kind-specific builders ---
def _build_panel(self, panel: Panel) -> Any:
try:
from textual.widgets import Static as TwStatic
widget = TwStatic(panel.title)
_build_panel_display(widget, panel)
return widget
except ImportError: # pragma: no cover
return type("FallbackPanel", (), {"label": panel.title})
def _build_table(self, table: Table) -> Any:
try:
from textual.widgets import DataTable
widget = DataTable(title=table.title or "")
_init_table_display(widget, table)
return widget
except ImportError: # pragma: no cover
return type("FallbackTable", (), {"title": str(table.title)})
def _build_tree(self, tree: Tree) -> Any:
try:
from textual.widgets import Tree as TwTree
widget = TwTree(f"[b]{tree.root.label}[/b]")
_populate_tree(widget, tree)
return widget
except ImportError as exc: # pylint: disable=broad-exception-caught
return type("FallbackTree", (), {"root_label": tree.root.label})
def _build_status(self, status_msg: StatusMessage) -> Any:
try:
from textual.widgets import Label as TwLabel
widget = TwLabel(status_msg.message)
if status_msg.level == "error":
widget.add_class("tui-status--red")
elif status_msg.level == "warn":
widget.add_class("tui-status--yellow")
elif status_msg.level == "ok":
widget.add_class("tui-status--green")
else:
widget.add_class("tui-status--info")
return widget
except ImportError: # pragma: no cover
return type("FallbackStatus", (), {"message": status_msg.message})
def _build_progress(self, progress: ProgressIndicator) -> Any:
try:
from textual.widgets import Throbber as TwThrobber
from textual.widgets import ProgressBar as TwProgressBar
if progress.indeterminate or progress.total is None:
widget = TwThrobber(start=False)
else:
widget = TwProgressBar(total=progress.total, finished="idle")
return widget
except ImportError: # pragma: no cover
return type("FallbackProgress", (), {"label": progress.label})
def _build_code_block(self, code: CodeBlock) -> Any:
try:
from textual.widgets import TextArea as TwTextArea
widget = TwTextArea(code.content, read_only=True)
return widget
except ImportError: # pragma: no cover
return type("FallbackCode", (), {"_text": code.content})
def _build_text_block(self, text: TextBlock) -> Any:
try:
from textual.widgets import Static as TwStatic
widget = TwStatic(text.content)
return widget
except ImportError: # pragma: no cover
return type("FallbackText", (), {"_text": text.content})
def _build_diff(self, diff: DiffBlock) -> Any:
try:
widget = _DiffViewStub(diff.file_a, diff.file_b)
for hunk in diff.hunks:
widget.add_hunk(hunk)
return widget
except ImportError: # pragma: no cover
return _DiffViewStub(diff.file_a, diff.file_b)
def _build_separator(self, sep: Separator) -> Any:
try:
from textual.widgets import Rule as TwRule
widget = TwRule()
return widget
except ImportError: # pragma: no cover
return type("FbSep", (), {"_text": "\u2500" * 30})
def _build_action_hint(self, hint: ActionHint) -> Any:
try:
from textual.widgets import Static as TwStatic
widget = TwStatic(f"[em]{', '.join(hint.commands)}[/]")
return widget
except ImportError: # pragma: no cover
return type("FbHint", (), {})
def _apply_update(self, widget: Any, kind: str, snapshot: Any) -> None:
"""Dispatch an incremental update to the correct renderer function."""
updater_map: dict[str, Any] = {
"panel": lambda w, s: (_build_panel_display(w, s) if isinstance(s, Panel) else None),
"table": self._apply_table_update,
"tree": self._apply_tree_update,
"status": _display_status,
"progress": self._apply_progress_update,
"code": _render_code,
"text": _render_text,
"diff": _render_diff,
"separator": _render_separator,
"action_hint": _render_action_hint,
}
fn = updater_map.get(kind)
if fn is not None:
try:
fn(widget, snapshot) # type: ignore[arg-type]
except Exception: # pylint: disable=broad-except
pass
def _apply_panel_update(self, widget: Any, snapshot: Any) -> None:
if isinstance(snapshot, Panel):
_build_panel_display(widget, snapshot)
def _apply_table_update(self, widget: Any, snapshot: Any) -> None:
if isinstance(snapshot, Table):
for row in snapshot.rows:
_add_table_row(widget, row)
def _apply_tree_update(self, widget: Any, snapshot: Any) -> None:
if isinstance(snapshot, Tree) and snapshot.root.children:
_populate_tree(widget, snapshot)
def _apply_progress_update(self, widget: Any, snapshot: Any) -> None:
if isinstance(snapshot, StatusMessage):
_update_progress(widget, snapshot)
# ------------------------------------------------------------------
# A2A event handling -- real-time plan progress to TUI widgets
# ------------------------------------------------------------------
def _handle_a2a_event(self, event: Any) -> None:
"""Route an incoming A2A event to the appropriate TUI widget.
This method is invoked as a callback on every A2aEvent published to
the subscribed A2aEventQueue.
"""
with self._lock:
event_type = getattr(event, "event_type", "")
data = getattr(event, "data", None) or {}
if event_type == "TaskStatusUpdateEvent":
self._render_status_event(data)
elif event_type == "TaskArtifactUpdateEvent":
self._render_artifact_event(data)
def _render_status_event(self, data: dict[str, Any]) -> None:
"""Render a status update event into the TUI's conversation widget."""
phase = str(data.get("phase", "") or "")
state = str(data.get("state", "") or "")
plan_id = str(data.get("plan_id", "") or "")
clean_plan = plan_id.replace("-", "")
for handle_id, tui_widget in self._widgets.items():
if tui_widget.element_type == "progress":
if not clean_plan or clean_plan in handle_id.replace("-", ""):
try:
sm = StatusMessage(message=f"Phase: phase={phase}, state={state}")
_update_progress(tui_widget.widget, sm)
except Exception: # pylint: disable=broad-except
pass
def _render_artifact_event(self, data: dict[str, Any]) -> None:
"""Render an artifact update event (code, diffs, status)."""
kind = str(data.get("artifact_type", "") or "")
artifact_data = data.get("data", data)
if kind == "code" or data.get("type") == "code":
content = str(artifact_data.get("content", ""))
for handle_id, tui_widget in self._widgets.items():
if tui_widget.element_type == "code":
try:
widget = tui_widget.widget
if hasattr(widget, "text"):
widget.text = content
except Exception: # pylint: disable=broad-except
pass
elif kind == "diff" or data.get("type") == "diff":
hunks = artifact_data.get("hunks", [])
for handle_id, tui_widget in self._widgets.items():
if tui_widget.element_type == "diff":
try:
widget = tui_widget.widget
for hunk in hunks:
if hasattr(widget, "add_hunk"):
dh = DiffHunk(header=str(hunk.get("header", "")))
for ln in hunk.get("lines", []):
dh.lines.append(
DiffLine(line_type=ln.get("type", "context"), content=ln.get("content", "")) # type: ignore[arg-type]
)
widget.add_hunk(dh)
except Exception: # pylint: disable=broad-except
pass
elif kind == "status" or data.get("type") == "status":
status_msg = StatusMessage(
message=str(artifact_data.get("message", "")),
level=str(artifact_data.get("level", "info")),
detail=str(artifact_data.get("detail", "")) or None,
)
for handle_id, tui_widget in self._widgets.items():
if tui_widget.element_type == "status":
try:
_display_status(tui_widget.widget, status_msg)
except Exception: # pylint: disable=broad-except
pass
def get_widget_registry(self) -> dict[str, _TuiWidget]:
"""Return a copy of the widget registry for inspection."""
with self._lock:
return dict(self._widgets)
def clear(self) -> None:
"""Clear all registered widgets and flush state.
Used when the TUI switches sessions or closes a plan.
"""
with self._lock:
if self._a2a_subscriber is not None:
self._a2a_subscriber.unsubscribe()
self._a2a_subscriber = None
self._widgets.clear()
self._index_map.clear()
self._next_index = 0
# ============================================================================
# Convenience factory
# ============================================================================
def create_tui_materializer(
event_queue: Any | None = None,
callbacks: list[Any] | None = None,
) -> TuiMaterializer:
"""Factory function to create a TuiMaterializer with standard wiring.
Parameters
----------
event_queue:
The shared ``A2aEventQueue`` for A2A protocol events. When None the
materializer still works as a pure MaterializationStrategy.
callbacks:
Optional callback list for session-level hooks.
Returns
-------
TuiMaterializer
An initialized materializer ready to receive OutputSession events.
"""
return TuiMaterializer(
event_queue=event_queue,
callback_registry=callbacks,
)
# ============================================================================
# Public exports
# ============================================================================
__all__ = [
"TuiMaterializer",
"create_tui_materializer",
"_DiffViewStub",
"_A2aEventSubscriber",
"_build_panel_display",
"_init_table_display",
"_add_table_row",
"_populate_tree",
"_display_status",
"_render_code",
"_render_diff",
"_render_separator",
"_render_action_hint",
"_render_text",
]