diff --git a/.gitignore b/.gitignore index e00c0eae..a8cfeea5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ release .clangd .cache .idea +logs # User-specific stuff .idea/**/workspace.xml diff --git a/python/layout_visualizer.py b/python/layout_visualizer.py new file mode 100644 index 00000000..ef617470 --- /dev/null +++ b/python/layout_visualizer.py @@ -0,0 +1,60 @@ +from graphviz import Digraph +import json +import sys +from typing import Optional +import argparse + +def visualize_layout_tree(data, output_path: str = "-", format: str = "png") -> Optional[str]: + if isinstance(data, str): + data = json.load(data) + + tree = data['final_tree'] + + dot = Digraph('LayoutTree', filename='layout_tree' if output_path == '-' else output_path, format=format) + dot.attr("node", shape="box", fontname="Arial", fontsize="10") + + def add_node(node): + nid = str(node["node_id"]) + label = f'{node["type"]}#{node["node_id"]}\n' + label += f'{node["sizing_policy"]["width"]}×{node["sizing_policy"]["height"]}\n' + label += f'dir={node.get("direction", "-")}\n' + label += f'size=({node["size"]["w"]}×{node["size"]["h"]})\n' + label += f'pos=({node["position"]["x"]},{node["position"]["y"]})' + color = node.get("color", "#dddddd") + if node["type"] == "Text": + txt = node.get("text_preview", "") + preview = (txt[:20] + "…") if len(txt) > 20 else txt + label += f'\n"{preview}"' + color = "#dddddd" + dot.node(nid, label, style="filled", fillcolor=color) + for c in node.get("children", []): + cid = str(c["node_id"]) + dot.edge(nid, cid) + add_node(c) + add_node(tree) + if output_path == '-': + return dot.source + else: + dot.render(cleanup=True) + return None + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Visualize layout tree from a layout JSON file.") + parser.add_argument("input", + help="Input JSON layout log (e.g., logs/layout-log.json)", nargs="?", default="-") + parser.add_argument("-o", "--out", default='-', + help="Output file path or '-' for stdout (default: '-')") + parser.add_argument("--format", default="png", choices=["png", "svg", "pdf"], + help="Output format (default: png)") + + args = parser.parse_args() + if args.input != "-": + with open(args.input, 'r', encoding='utf-8') as f: + data = json.load(f) + else: + data = json.load("\n".join(sys.stdin.readlines())) + + result = visualize_layout_tree(data, args.out, args.format) + if result: + print(result) diff --git a/python/rlc/__init__.py b/python/rlc/__init__.py index 1abea3a9..8ed224ce 100644 --- a/python/rlc/__init__.py +++ b/python/rlc/__init__.py @@ -10,3 +10,9 @@ from .program import Program, compile, State, get_included_contents from .llm_runner import make_llm, run_game, Ollama, Gemini, GeminiStateless from .program_graph import parse_call_graph, Node, CallGraph, NodeKind +from .renderer_backend import RendererBackend +from .layout import Layout, Padding, Direction, FIT, FIXED, GROW +from .text import Text +from rlc.layout_logger import LayoutLogConfig, LayoutLogger + + diff --git a/python/rlc/event_queue.py b/python/rlc/event_queue.py new file mode 100644 index 00000000..12b5915d --- /dev/null +++ b/python/rlc/event_queue.py @@ -0,0 +1,211 @@ +import ctypes +from dataclasses import dataclass, field +from typing import Optional, Any, Callable, Dict +from enum import Enum + + +class SignalKind(Enum): + ACTION = "action" + RESIZE = "resize" + SCROLL = "scroll" + FOCUS = "focus" + + +@dataclass +class UpdateSignal: + """ + Represents a single event that may require state mutation and/or relayout. + + The queue accumulates these during event polling, then processes them + in batch: all mutations first, then a single update+relayout pass. + """ + kind: SignalKind + handler_name: Optional[str] = None + args: Dict[str, Any] = field(default_factory=dict) + target: Any = None + width: int = 0 + height: int = 0 + dx: int = 0 + dy: int = 0 + + +def _copy_state(state_obj): + """Deep copy of the state object via RLC's generated clone() method. + clone() uses rl_m_assign which allocates independent heap storage for + Vector fields, preventing double-free and allowing diff to detect changes. + """ + return state_obj.clone() + + +def _rlc_string_to_python(rlc_str) -> str: + """Convert an RLC String object to a Python str.""" + vec = rlc_str._data # Vector + size = vec._size # includes null terminator + data = vec._data # pointer to bytes + return bytes( + data[i] if isinstance(data[i], int) else data[i].value + for i in range(size - 1) # -1 to skip null terminator + ).decode('ascii') + + +class UpdateController: + """ + Guarded Update Protocol controller. + + Decouples event collection from state mutation from UI update. + Prevents reentrancy by processing in strict phases: + + Phase 1 (COLLECT): Accumulate UpdateSignals from input events + Phase 2 (MUTATE): Execute all action handlers + Phase 3 (UPDATE): Call RLC diff against last known state, + update only the renderers whose sim fields changed + Phase 4 (RELAYOUT): If dirty, recompute sizes and positions once + """ + + def __init__(self, renderer, layout, relayout_fn: Callable, dispatch_fn: Callable, + mapping=None, state_obj=None, program_module=None): + """ + Args: + renderer: The root renderer (Renderable subclass) + layout: The root layout node + relayout_fn: Callable that recomputes layout sizes and positions + dispatch_fn: Callable(handler_name, args) -> bool that executes + an action handler and returns True if state changed. + mapping: Optional SimRendererMapping for targeted updates. + If None, falls back to full renderer.update(). + state_obj: The initial state object. Required if mapping is provided. + program_module: The compiled RLC module. Required for targeted updates + (provides the `diff` function from algorithms/diff.rl). + """ + self.renderer = renderer + self.layout = layout + self.relayout_fn = relayout_fn + self.dispatch_fn = dispatch_fn + + self._queue = [] + self._processing = False + self._state_changed = False + self._needs_relayout = False + + self.scroll = {"x": 0, "y": 0} + + # Targeted update support + self._mapping = mapping + self._program_module = program_module + if mapping is not None and state_obj is not None: + self._last_state = _copy_state(state_obj) + else: + self._last_state = None + + def enqueue(self, signal: UpdateSignal): + """Add a signal to the queue. Safe to call from handlers.""" + self._queue.append(signal) + + def process(self, state_obj, elapsed: float): + """ + Process all queued signals. Called once per frame after all events collected. + + Args: + state_obj: The simulation state object (e.g., state.state) + elapsed: Time since last frame in seconds + """ + if self._processing: + return + + self._processing = True + self._needs_relayout = False + + try: + # Phase 2: MUTATE - execute all queued signals + while self._queue: + signal = self._queue.pop(0) + self._handle_signal(signal) + + # Phase 3: UPDATE + if self._state_changed: + can_target = (self._mapping is not None + and self._last_state is not None + and self._program_module is not None + and hasattr(self._program_module, 'diff') + and hasattr(self._program_module, 'VectorTStringT')) + + if can_target: + self._targeted_update(state_obj, elapsed) + else: + self.renderer.update(self.layout, state_obj, elapsed) + self._needs_relayout = True + + # Phase 4: RELAYOUT - recompute sizes/positions (once) + if self._needs_relayout or _any_child_dirty(self.layout): + self.relayout_fn() + + finally: + self._processing = False + self._state_changed = False + + def _targeted_update(self, state_obj, elapsed: float): + """ + Call RLC diff to find changed fields, update only those renderers. + Uses stdlib/algorithms/diff.rl via program_module.diff(). + """ + from rlc.sim_renderer_mapping import SimRendererMapping + + changed = self._program_module.VectorTStringT() + self._program_module.diff(self._last_state, state_obj, changed) + + num_changed = changed.size() if hasattr(changed, 'size') else changed._data._size + for i in range(num_changed): + path_str = _rlc_string_to_python(changed.get(i).contents) + sim_path = tuple( + int(p) if p.isdigit() else p + for p in path_str.split('.') + if p + ) + entry = self._mapping.get_entry(sim_path) + if entry: + value = SimRendererMapping.resolve_value(state_obj, sim_path) + entry.renderer.update(entry.layout_node, value, elapsed) + + self._last_state = _copy_state(state_obj) + if num_changed > 0: + self._needs_relayout = True + else: + # Shallow copy shares heap with original — diff may miss heap-resident + # changes (e.g. Vector elements without size change). Fall back to + # full update so the renderer stays consistent. + self.renderer.update(self.layout, state_obj, elapsed) + self._needs_relayout = True + + def notify_state_changed(self): + """Call after programmatic state mutations (e.g., auto-play).""" + self._state_changed = True + + def _handle_signal(self, signal: UpdateSignal): + if signal.kind == SignalKind.ACTION: + changed = self.dispatch_fn(signal.handler_name, signal.args) + if changed: + self._state_changed = True + + elif signal.kind == SignalKind.FOCUS: + self.layout.set_focus(signal.target) + self._needs_relayout = True + + elif signal.kind == SignalKind.RESIZE: + self._needs_relayout = True + + elif signal.kind == SignalKind.SCROLL: + self.scroll["x"] += signal.dx + self.scroll["y"] += signal.dy + self._needs_relayout = True + + +def _any_child_dirty(layout): + """Check and clear dirty flags recursively.""" + if getattr(layout, "is_dirty", False): + layout.is_dirty = False + return True + return any( + _any_child_dirty(c) + for c in layout.children + if hasattr(c, "children") + ) diff --git a/python/rlc/layout.py b/python/rlc/layout.py new file mode 100644 index 00000000..3c4ad059 --- /dev/null +++ b/python/rlc/layout.py @@ -0,0 +1,342 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Tuple, List +import copy +from .renderer_backend import RendererBackend +# //utility for dumping layout + +class Direction(Enum): + ROW = "row" + COLUMN = "column" + +class SizePolicies(Enum): + FIXED = "fixed" + FIT = "fit" + GROW = "grow" + +class SizePolicy: + def __init__(self, size_policy: SizePolicies, value: Optional[float] = None): + self.size_policy = size_policy + self.value : Optional[float] = value + def __repr__(self) -> str: + return f"{self.size_policy.value}({self.value})" if self.value is not None else self.size_policy.value + +def FIXED(value) -> SizePolicy: + return SizePolicy(SizePolicies.FIXED, value) +def FIT() -> SizePolicy: + return SizePolicy(SizePolicies.FIT, 0) +def GROW() -> SizePolicy: + return SizePolicy(SizePolicies.GROW, 0) + +@dataclass(frozen=True) +class Padding: + top: float = 0 + bottom: float = 0 + left: float = 0 + right: float = 0 + + +ZERO_PADDING = Padding() + +class Layout: + def __init__( + self, + sizing : Tuple[SizePolicy, SizePolicy] = (FIT(), FIT()), + padding : Optional[Padding] = None, + direction : Direction = Direction.ROW, + border: float = 0, + child_gap : float = 0, + color: Optional[str] = None, + interactive=False, + on_click=None, + on_hover=None, + on_key=None): + self.sizing : Tuple[SizePolicy, SizePolicy] = sizing + self.direction : Direction = direction + self.border = border + self.child_gap : float = child_gap + self.padding : Padding = padding or ZERO_PADDING + self.color: Optional[str] = color + self.children : List['Layout'] = [] + self.x = 0 + self.y = 0 + self.is_dirty = False + self.interactive = interactive + self.on_click = on_click + self.on_hover = on_hover + self.on_key = on_key + self._children_sized = False + self.render_path = None + self.focused = False + self.width = sizing[0].value if sizing[0].size_policy == SizePolicies.FIXED else 0 + self.height = sizing[1].value if sizing[1].size_policy == SizePolicies.FIXED else 0 + + def add_child(self, child: 'Layout') -> None: + self.children.append(child) + + def _axis_size(self, axis: int) -> float: + return self.width if axis == 0 else self.height + + def _set_axis_size(self, axis: int, value: float) -> None: + if axis == 0: + self.width = value + else: + self.height = value + + def _padding_for_axis(self, axis: int) -> Tuple[float, float]: + return ( + (self.padding.left, self.padding.right) + if axis == 0 + else (self.padding.top, self.padding.bottom) + ) + + def _inner_size(self, axis: int) -> float: + start, end = self._padding_for_axis(axis) + return max(0, self._axis_size(axis) - start - end) + + def child_size(self, logger: Optional['LayoutLogger'] = None, backend: Optional[RendererBackend] = None) -> None: + if self._children_sized: + return + # Always size children — even if self has fixed size + inner_available_width = self._inner_size(0) + inner_available_height = self._inner_size(1) + + for child in self.children: + child_width_policy, child_height_policy = child.sizing + # Only pass constraints if needed; leave GROW unset so parent-driven sizing is preserved. + if child_width_policy.size_policy == SizePolicies.FIXED: + child_width = child_width_policy.value + elif child_width_policy.size_policy == SizePolicies.GROW: + child_width = None + else: + child_width = inner_available_width + + if child_height_policy.size_policy == SizePolicies.FIXED: + child_height = child_height_policy.value + elif child_height_policy.size_policy == SizePolicies.GROW: + child_height = None + else: + child_height = inner_available_height + + child.compute_size(child_width, child_height,logger, backend=backend) + self._children_sized = True + + + # Sizing + + def compute_size(self, available_width=None, available_height=None, logger: Optional['LayoutLogger']=None, backend: Optional[RendererBackend] = None) -> None: + self._children_sized = False + if logger: logger.snapshot(self, "before_compute") + available = (available_width, available_height) + primary_axis = 0 if self.direction == Direction.ROW else 1 + self._compute_axis(primary_axis, available, logger, backend) + self._compute_grow_axis(primary_axis) + self.child_size(logger, backend) + if logger: logger.snapshot(self, "after_children_sizing") + cross_axis = 1 - primary_axis + self._compute_axis(cross_axis, available, logger, backend) + self._compute_grow_axis(cross_axis) + if logger: logger.snapshot(self, "after_compute") + + def _compute_axis(self, axis: int, available: Tuple[Optional[float], Optional[float]], logger: Optional['LayoutLogger']=None, backend: Optional[RendererBackend] = None) -> None: + policy = self.sizing[axis] + if policy.size_policy == SizePolicies.FIXED: + self._set_axis_size(axis, policy.value) + elif policy.size_policy == SizePolicies.FIT: + self._set_axis_size(axis, self._compute_fit(axis, logger, backend)) + elif policy.size_policy == SizePolicies.GROW: + available_size = available[axis] + # Only fill from available space if we haven't already been sized + if available_size is not None and self._axis_size(axis) <= 0: + self._set_axis_size(axis, available_size) + + def _compute_fit(self, axis: int, logger: Optional['LayoutLogger']=None, backend: Optional[RendererBackend] = None) -> float: + self.child_size(logger, backend) + if self.direction == Direction.ROW: + content = ( + sum(c.width for c in self.children) + (len(self.children) - 1) * self.child_gap + if axis == 0 else + max((c.height for c in self.children), default=0) + ) + else: + content = ( + sum(c.height for c in self.children) + (len(self.children) - 1) * self.child_gap + if axis == 1 else + max((c.width for c in self.children), default=0) + ) + padding_start, padding_end = self._padding_for_axis(axis) + return content + padding_start + padding_end + + def _compute_grow_axis(self, axis: int) -> None: + if not self.children: + return + is_primary_axis = ( + (self.direction == Direction.ROW and axis == 0) + or (self.direction == Direction.COLUMN and axis == 1) + ) + inner_size = self._inner_size(axis) + if is_primary_axis: + total_gap = (len(self.children) - 1) * self.child_gap + fixed_total = sum( + self._child_axis_size(child, axis) + for child in self.children + if child.sizing[axis].size_policy != SizePolicies.GROW + ) + remaining = inner_size - fixed_total - total_gap + self._grow_children_evenly(self.children, remaining, axis) + else: + for child in self.children: + if child.sizing[axis].size_policy == SizePolicies.GROW: + self._set_child_axis_size(child, axis, inner_size) + + def _child_axis_size(self, child: 'Layout', axis: int) -> float: + return child.width if axis == 0 else child.height + + def _set_child_axis_size(self, child: 'Layout', axis: int, value: float) -> None: + if axis == 0: + child.width = value + else: + child.height = value + + def _grow_children_evenly(self, children: List['Layout'], remaining: float, axis: int): + if not children or remaining == 0: + return + if remaining > 0: + self._distribute_positive_space([c for c in children if c.sizing[axis].size_policy == SizePolicies.GROW], remaining, axis) + else: + self._distribute_negative_space([c for c in children if c.sizing[axis].size_policy != SizePolicies.FIXED], -remaining, axis) + + def _distribute_positive_space(self, growable: List['Layout'], space: float, axis: int) -> None: + if not growable or space <= 0: + return + # Level items up to the next size tier before splitting evenly. + while growable and space > 0: + growable.sort(key=lambda c: self._child_axis_size(c, axis)) + smallest_size = self._child_axis_size(growable[0], axis) + next_larger = next( + (self._child_axis_size(c, axis) for c in growable if self._child_axis_size(c, axis) > smallest_size), + None + ) + if next_larger is None: + share = space // len(growable) + for child in growable: + self._set_child_axis_size(child, axis, self._child_axis_size(child, axis) + share) + return + group = [c for c in growable if self._child_axis_size(c, axis) == smallest_size] + increment = min(next_larger - smallest_size, space / len(group)) + if increment <= 0: + share = space // len(group) + for child in group: + self._set_child_axis_size(child, axis, self._child_axis_size(child, axis) + share) + return + for child in group: + self._set_child_axis_size(child, axis, self._child_axis_size(child, axis) + increment) + space -= increment * len(group) + growable = [c for c in growable if self._child_axis_size(c, axis) < next_larger] + + def _distribute_negative_space(self, shrinkable: List['Layout'], space: float, axis: int) -> None: + if not shrinkable or space <= 0: + return + # Trim the largest items first to avoid skewing sizes. + while shrinkable and space > 0: + shrinkable.sort(key=lambda c: self._child_axis_size(c, axis), reverse=True) + largest_size = self._child_axis_size(shrinkable[0], axis) + next_smaller = next( + (self._child_axis_size(c, axis) for c in shrinkable if self._child_axis_size(c, axis) < largest_size), + None + ) + if next_smaller is None: + share = space // len(shrinkable) + for child in shrinkable: + self._set_child_axis_size(child, axis, max(0, self._child_axis_size(child, axis) - share)) + return + group = [c for c in shrinkable if self._child_axis_size(c, axis) == largest_size] + decrement = min(largest_size - next_smaller, space / len(group)) + if decrement <= 0: + share = space // len(group) + for child in group: + self._set_child_axis_size(child, axis, max(0, self._child_axis_size(child, axis) - share)) + return + for child in group: + self._set_child_axis_size(child, axis, max(0, self._child_axis_size(child, axis) - decrement)) + space -= decrement * len(group) + shrinkable = [c for c in shrinkable if self._child_axis_size(c, axis) > next_smaller] + + # Position + def layout(self, x: int=0, y: int=0, logger: Optional['LayoutLogger']=None) -> None: + self.x = x + self.y = y + if logger: logger.snapshot(self, "before_layout") + axis = 0 if self.direction == Direction.ROW else 1 + self._layout_children(axis) + if logger: logger.snapshot(self, "after_layout") + + def _layout_children(self, axis: int) -> None: + offset = self.padding.left if axis == 0 else self.padding.top + for child in self.children: + child_x = self.x + offset if axis == 0 else self.x + self.padding.left + child_y = self.y + self.padding.top if axis == 0 else self.y + offset + child.layout(child_x, child_y) + offset += self._child_axis_size(child, axis) + self.child_gap + + def print_layout(self, depth: int = 0): + indent = " " * depth + print(f"{indent}{self.__class__.__name__}: binding : {self.binding},,,,, {self.on_click} (dir={self.direction.value if self.direction else '-'}, size=({self.width}*{self.height}), pos=({self.x},{self.y}), policy=({self.sizing[0].size_policy},{self.sizing[1].size_policy}), color={self.color if self.color else '-'})") + for child in self.children: + child.print_layout(depth + 1) + + def print_path(self, depth : int = 0): + indent = " " * depth + print(f"{indent}{self.__class__.__name__}: path : {self.render_path}, on_click : {self.on_click}") + for child in self.children: + child.print_path(depth + 1) + + # Event Handling + def hit_test(self, x, y): + return (self.x <= x <= self.x + self.width and + self.y <= y <= self.y + self.height) + + def find_target(self, x, y): + # Search children front-to-back + for child in reversed(self.children): + target = child.find_target(x, y) + if target: + return target + if self.hit_test(x, y): + return self + + return None + + def find_focused_node(self): + """ + Traverse the layout tree and find the node with focused=True. + + Returns: + The focused layout node, or None if no node is focused + """ + if self.focused: + return self + + for child in self.children: + result = child.find_focused_node() + if result is not None: + return result + + return None + + def clear_all_focus(self): + """Recursively clear the focused flag on all layout nodes.""" + self.focused = False + for child in self.children: + child.clear_all_focus() + + def set_focus(self, target_node): + """ + Clear all focus in the tree, then set focus on the target node. + + Args: + target_node: The specific node to focus (or None to clear all focus) + """ + self.clear_all_focus() + if target_node is not None: + target_node.focused = True diff --git a/python/rlc/layout_logger.py b/python/rlc/layout_logger.py new file mode 100644 index 00000000..817ad7a3 --- /dev/null +++ b/python/rlc/layout_logger.py @@ -0,0 +1,233 @@ +from typing import Optional, List, Dict, Any +import time +import itertools +from dataclasses import asdict, dataclass +import json +import os +import tempfile + +@dataclass +class LayoutLogConfig: + include_timestamp: bool = True + precision: int = 2 + record_events: bool = True #lifecycle events (before/after compute/layout) + record_final_tree: bool = True #final tree snapshot + +class LayoutLogger: + + _id_counter = itertools.count(1) + + def __init__(self, config: Optional[LayoutLogConfig] = None): + self.config = config or LayoutLogConfig() + self._events: List[Dict[str, Any]] = [] + self._node_meta: Dict[int, Dict[str, Any]] = {} #stable identity & static metadata + self._final_tree: Optional[Dict[str, Any]] = None + self._start = time.time() + self._root_ref = None + + def __del__(self): + """Destructor: clean up node IDs when logger is garbage-collected.""" + try: + if self._root_ref: + self.finalize(self._root_ref) + except Exception: + pass + + def _attach_id(self, node) -> int: + # Reset _log_node_id for copied nodes to ensure unique IDs + # if hasattr(node, "_log_node_id"): + # delattr(node, "_log_node_id") + nid = getattr(node, "_log_node_id", None) + if nid is None: + nid = next(self._id_counter) + setattr(node, "_log_node_id", nid) + self._node_meta[nid] = { + "type" : type(node).__name__ + } + return nid + + def _remove_id(self, node): + nid = getattr(node, "_log_node_id", None) + if nid is not None: + self._node_meta.pop(nid, None) # safe remove + if hasattr(node, "_log_node_id"): + delattr(node, "_log_node_id") + + def _ts(self) -> float: + return round(time.time() - self._start, 6) if self.config.include_timestamp else None + + def _pad_dict(self, pad) -> Optional[Dict[str, Any]]: + if pad is None: return None + return {"top": pad.top, + "right": pad.right, + "bottom": pad.bottom, + "left": pad.left} + + def _preview(self, s:str) -> str: + if not s: + return "" + s = s.replace("\n", " ") + return s + + def _pad_str(self, pad) -> str: + if pad is None: return "-" + return f"t{pad.top}/r{pad.right}/b{pad.bottom}/l{pad.left}" + + def _extract_node_info(self, node) -> Dict[str, Any]: + sizing = getattr(node, "sizing", (None, None)) + wmode = sizing[0].size_policy.value if sizing and sizing[0] else None + hmode = sizing[1].size_policy.value if sizing and sizing[1] else None + return { + "id" : self._attach_id(node), + "type": type(node).__name__, + "position": {"x": getattr(node, "x", None), "y": getattr(node, "y", None)}, + "size": {"w": getattr(node, "width", None), "h": getattr(node, "height", None)}, + "sizing_policy": { + "width": wmode, + "height": hmode + }, + "padding": self._pad_dict(getattr(node, "padding", None)), + "direction": getattr(getattr(node, "direction", None), "value", None) + } + + def _safe_write_json(self, path: str, data, indent: int = 2) -> None: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + dir_name = os.path.dirname(path) or "." + fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=dir_name, text=True) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + json.dump(data, f, indent=indent, ensure_ascii=False) + f.write("\n") + os.replace(tmp, path) + except Exception: + # Clean up temp on failure + try: + os.remove(tmp) + except OSError: + pass + raise + def _safe_write_text(self, path: str, text: str) -> None: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + dir_name = os.path.dirname(path) or "." + fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=dir_name, text=True) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + if not text.endswith("\n"): f.write("\n") + os.replace(tmp, path) + except Exception: + try: + os.remove(tmp) + except OSError: + pass + raise + + def snapshot(self, node, stage: str, + axis: Optional[str] = None, + warnings: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None) -> None: + if not self.config.record_events: + return + info = self._extract_node_info(node) + e = { + "timestamp" : self._ts(), + "node_id" : info['id'], + "stage" : stage, + "position": info['position'], + "size": info['size'], + "sizing_policy": info['sizing_policy'], + "padding": info['padding'], + "direction": info['direction'], + "axis" : axis, + "warnings" : warnings, + "extra": extra or {} + } + self._events.append(e) + + def record_final_tree(self, root) -> None: + if not self.config.record_final_tree: + return + self._final_tree = self._tree_dict(root) + self._root_ref = root + + def finalize(self, root) -> None: + """Remove all node IDs and metadata after logging is finished.""" + def rec(node): + self._remove_id(node) + for child in getattr(node, "children", []) or []: + rec(child) + rec(root) + self._node_meta.clear() + + def to_json(self) -> str: + data = { + "config" : asdict(self.config), + "events" : self._events, + "final_tree" : self._final_tree + } + return json.dumps(data) + + def to_text_tree(self, root): + lines : List[str] = [] + def rec(node, depth: int): + info = self._extract_node_info(node) + pad = getattr(node, "padding", None) + text = "" + if info['type'] == "Text": + txt = getattr(node, "text", "") + if txt is not None: + text = f' text="{self._preview(txt)}"' + branch = "|___" + indent = " " * (depth) + (branch if depth > 0 else branch) + lines.append( + f'{indent}.{info["type"]}#{info["id"]} ' + f'[({info["sizing_policy"]["width"]}, {info["sizing_policy"]["height"]}); direction:{info["direction"]}] ' + f'position=({info["position"]["x"]}, {info["position"]["y"]}) ' + f'size=({info["size"]["w"]}, {info["size"]["h"]}) ' + f'padding={self._pad_str(pad)}{text}' + ) + for child in getattr(node, "children", []) or []: + rec(child, depth+1) + rec(root, 0) + return "\n".join(lines) + + def write_json(self, path: str, indent: int = 2): + data = { + "config" : asdict(self.config), + "events" : self._events, + "final_tree" : self._final_tree + } + self._safe_write_json(path, data, indent) + + def write_text_tree(self, root, path: str): + txt = self.to_text_tree(root) + self._safe_write_text(path, txt) + + def _tree_dict(self, node) -> Dict[str, Any]: + nid = self._attach_id(node) + sizing = getattr(node, "sizing", (None, None)) + wm = sizing[0].size_policy.value if sizing[0] else None + hm = sizing[1].size_policy.value if sizing[1] else None + info = self._extract_node_info(node) + d : Dict[str, Any] = { + "node_id" : info['id'], + "type": info['type'], + "color": getattr(node, "color", "#dddddd"), + "direction": info['direction'], + "position": info['position'], + "size": info['size'], + "sizing_policy": info['sizing_policy'], + "padding": info['padding'] + } + if info['type'] == "Text": + d["text_preview"] = self._preview(getattr(node, "text", "")) + surfaces = getattr(node, "text_surfaces", None) + if surfaces is not None and hasattr(surfaces, "__len__"): + d["line_count"] = len(surfaces) + children = getattr(node, "children", None) + if children: + d["children"] = [self._tree_dict(c) for c in children] + return d + + + diff --git a/python/rlc/renderer/__init__.py b/python/rlc/renderer/__init__.py new file mode 100644 index 00000000..44fa08dc --- /dev/null +++ b/python/rlc/renderer/__init__.py @@ -0,0 +1,9 @@ +from rlc.renderer.factory import RendererFactory +from .array_renderer import ArrayRenderer +from .bint_renderer import BoundedIntRenderer +from .vector_renderer import VectorRenderer +from .struct_renderer import ContainerRenderer +from .primitive_renderer import PrimitiveRenderer +from .vector_renderer import VectorRenderer +from .renderable import Renderable +from .cpp_serializer import SerializationContext diff --git a/python/rlc/renderer/array_renderer.py b/python/rlc/renderer/array_renderer.py new file mode 100644 index 00000000..33571c04 --- /dev/null +++ b/python/rlc/renderer/array_renderer.py @@ -0,0 +1,69 @@ +from rlc.renderer.renderable import Renderable, register_renderer +from rlc.layout import Layout, Direction, FIT, Padding +from dataclasses import dataclass +import ctypes + +@register_renderer +@dataclass +class ArrayRenderer(Renderable): + length: int + element_renderer: Renderable + + def build_layout(self, obj, parent_path, direction=Direction.ROW, color="white", sizing=(FIT(), FIT()), logger=None, padding=Padding(2,2,2,2), index_bindings=None, mapping=None): + if index_bindings is None: + index_bindings = {} + + layout = self.make_layout(sizing=sizing, direction=direction, color=color, padding=padding, border=3, child_gap=5) + layout.binding = {"type": "array"} + layout.render_path = parent_path + + # Apply pre-computed interactions for the array container + self._apply_interaction_mappings(layout, index_bindings) + + color = 'lightgray' + if self.element_renderer is not None: + # Determine which index variable to bind at this array level + index_var_name = None + deepest_mappings = self.element_renderer._get_deepest_interaction_mappings() + if deepest_mappings: + interaction_mapping = deepest_mappings[0] + num_bound = len(index_bindings) + if num_bound < len(interaction_mapping.index_vars): + index_var_name = interaction_mapping.index_vars[num_bound] + + for i in range(self.length): + item = obj[i] + next_dir = ( + Direction.ROW if direction == Direction.COLUMN else Direction.COLUMN + ) + child_index_bindings = index_bindings.copy() + if index_var_name: + child_index_bindings[index_var_name] = i + + child = self.element_renderer( + item, + parent_path=parent_path + [i], + direction=next_dir, + logger=logger, + color='lightblue', + sizing=(FIT(), FIT()), + padding=Padding(2,2,2,2), + index_bindings=child_index_bindings, + mapping=mapping, + ) + + layout.add_child(child) + return layout + + def update(self, layout, obj, elapsed_time=0.0): + for i, child in enumerate(layout.children): + item = obj[i] + self.element_renderer.update(child, item, elapsed_time) + + def _iter_children(self): + return [self.element_renderer] + + def apply_interactivity(self, layout_child, index=None, parent_obj=None): + """Hook for subclasses to mark children interactive.""" + return None + diff --git a/python/rlc/renderer/bint_renderer.py b/python/rlc/renderer/bint_renderer.py new file mode 100644 index 00000000..c50230ff --- /dev/null +++ b/python/rlc/renderer/bint_renderer.py @@ -0,0 +1,35 @@ +from rlc.text import Text +from rlc.renderer.renderable import Renderable, register_renderer +from rlc.layout import Direction, FIT, Padding +from dataclasses import dataclass + +@register_renderer +class BoundedIntRenderer(Renderable): + """ + Renderer for bounded integer structs like BIntT1T10T. + """ + def build_layout(self, obj, parent_path, direction=Direction.COLUMN, + color="white", sizing=(FIT(), FIT()), logger=None, padding=Padding(2,2,2,2), index_bindings=None, mapping=None): + if index_bindings is None: + index_bindings = {} + + # Extract the 'value' field (the inner c_long) + value = getattr(obj, "value", None) + val_str = str(value if isinstance(value, int) else getattr(value, "value", value)) + + layout = self.make_text(val_str, "Arial", 16, "black") + layout.render_path = parent_path + + # Apply pre-computed interactions with index bindings + self._apply_interaction_mappings(layout, index_bindings) + + if mapping is not None: + mapping.add_entry(tuple(parent_path), self, layout) + + return layout + + def update(self, layout, obj, elapsed_time=0.0): + if isinstance(layout, Text): + value = getattr(obj, "value", None) + new_val = str(value if isinstance(value, int) else getattr(value, "value", value)) + layout.update_text(new_val) diff --git a/python/rlc/renderer/bounded_vector_renderer.py b/python/rlc/renderer/bounded_vector_renderer.py new file mode 100644 index 00000000..6b03b396 --- /dev/null +++ b/python/rlc/renderer/bounded_vector_renderer.py @@ -0,0 +1,38 @@ +from rlc.text import Text +from rlc.renderer.renderable import Renderable, register_renderer +from rlc.layout import Direction, FIT, Padding +from dataclasses import dataclass + +@register_renderer +@dataclass +class BoundedVectorRenderer(Renderable): + """ + Renderer for bounded integer structs like BIntT1T10T. + """ + vector_renderer: Renderable + + def build_layout(self, obj, parent_path, direction=Direction.COLUMN, + color="white", sizing=(FIT(), FIT()), logger=None, padding=Padding(2,2,2,2), index_bindings=None, mapping=None): + if index_bindings is None: + index_bindings = {} + + value = getattr(obj, "_data", None) + + return self.vector_renderer( + value, + parent_path=parent_path, + direction=direction, + color=color, + sizing=sizing, + logger=logger, + padding=padding, + index_bindings=index_bindings, + mapping=mapping, + ) + + def update(self, layout, obj, elapsed_time=0.0): + value = getattr(obj, "_data") + self.vector_renderer.update(layout, value, elapsed_time) + + def _iter_children(self) : + return [self.vector_renderer] diff --git a/python/rlc/renderer/config_parser.py b/python/rlc/renderer/config_parser.py new file mode 100644 index 00000000..20564bb6 --- /dev/null +++ b/python/rlc/renderer/config_parser.py @@ -0,0 +1,359 @@ +from dataclasses import dataclass +from typing import List, Any, Dict, Optional +from enum import Enum +import yaml +import os + +ACTION_REGISTRY = {} + +# Global config cache - loaded once and reused +_INTERACTION_CONFIG_CACHE = None +_INTERACTION_RULES_CACHE = None + +def action(name): + def wrapper(fn): + ACTION_REGISTRY[name] = fn + return fn + return wrapper + + +class SegmentKind(str, Enum): + ROOT = "root" + FIELD = "field" + INDEX_WILDCARD = "index_wildcard" + INDEX_VAR = "index_var" + EVENT = "event" + PARAM_VAR = "param_var" # NEW: for parameters like $value in on_key/$value + +@dataclass(frozen=True) +class PathSegment: + kind: SegmentKind + value: str # field name, var name, event name, root name + +@dataclass(frozen=True) +class ParsedPath: + raw: str + segments: List[PathSegment] + + @property + def event(self) -> str: + """Returns the event name (on_click, on_key, etc.)""" + for seg in self.segments: + if seg.kind == SegmentKind.EVENT: + return seg.value + raise ValueError(f"ParsedPath has no EVENT segment: {self.raw}") + + @property + def param_vars(self) -> List[str]: + """Returns list of parameter variable names like ['value', 'modifiers']""" + return [seg.value for seg in self.segments if seg.kind == SegmentKind.PARAM_VAR] + + @property + def index_vars(self) -> List[str]: + """Returns list of index variable names like ['x', 'y']""" + return [seg.value for seg in self.segments if seg.kind == SegmentKind.INDEX_VAR] + +@dataclass +class InteractionRule: + path: ParsedPath + handler_name: str + + +_VALID_EVENTS = {"on_click", "on_hover", "on_key"} # extend later + + +def parse_config_path(path: str) -> ParsedPath: + """ + Parse a user config path like: + Game/board/slots/$row/$col/on_click + Game/board/slots/$x/$y/on_key/$value + Game/board/slots/$x/$y/on_key/$value/$modifiers + + Returns ParsedPath with typed segments. + """ + raw = path + path = path.strip().strip("/") # normalize + + if not path: + raise ValueError("Empty config path") + + parts = [p for p in path.split("/") if p] + if len(parts) < 2: + raise ValueError(f"Path too short: '{raw}'") + + # Find the event segment + event_index = None + for i, part in enumerate(parts): + if part in _VALID_EVENTS: + event_index = i + break + + if event_index is None: + raise ValueError( + f"No valid event found in '{raw}'. " + f"Allowed: {sorted(_VALID_EVENTS)}" + ) + + root = parts[0] + event = parts[event_index] + middle = parts[1:event_index] # Between root and event + params = parts[event_index + 1:] # After event + + segments: List[PathSegment] = [PathSegment(SegmentKind.ROOT, root)] + + # Process middle segments (fields and index vars) + for seg in middle: + if seg.startswith("$"): + var = seg[1:] + if not var: + raise ValueError(f"Empty variable segment in '{raw}'") + segments.append(PathSegment(SegmentKind.INDEX_VAR, var)) + continue + + # Otherwise treat as a struct field name + segments.append(PathSegment(SegmentKind.FIELD, seg)) + + # Add the event + segments.append(PathSegment(SegmentKind.EVENT, event)) + + # Process parameter segments (must start with $) + for seg in params: + if not seg.startswith("$"): + raise ValueError( + f"Parameter '{seg}' must start with '$' in '{raw}'" + ) + var = seg[1:] + if not var: + raise ValueError(f"Empty parameter segment in '{raw}'") + segments.append(PathSegment(SegmentKind.PARAM_VAR, var)) + + return ParsedPath(raw=raw, segments=segments) + +def format_parsed_path(pp: ParsedPath) -> str: + return " -> ".join(f"{s.kind}:{s.value}" for s in pp.segments) + + +def match_parsed_path( + parsed: ParsedPath, + runtime_path: List[Any], +) -> Optional[Dict[str, int]]: + """ + Match a ParsedPath against a runtime render path. + + runtime_path example: + ["Game", "board", "slots", 1, 2] + + Returns: + dict of variable bindings if match succeeds + None if match fails + """ + + segments = parsed.segments + + # Last segment is EVENT → not part of runtime path + expected_len = len(segments) - 1 + if len(runtime_path) != expected_len: + return None + + bindings: Dict[str, int] = {} + for i, seg in enumerate(segments[:-1]): + value = runtime_path[i] + + if seg.kind == SegmentKind.ROOT: + if value != seg.value: + return None + + elif seg.kind == SegmentKind.FIELD: + if value != seg.value: + return None + + elif seg.kind == SegmentKind.INDEX_VAR: + if not isinstance(value, int): + return None + bindings[seg.value] = value + + else: + raise AssertionError(f"Unexpected segment kind: {seg.kind}") + + return bindings + +def match_parsed_path_with_params( + parsed: ParsedPath, + runtime_path: List[Any], + event_params: Optional[Dict[str, Any]] = None +) -> Optional[Dict[str, Any]]: + """ + Match a ParsedPath against a runtime render path and merge with event parameters. + + Args: + parsed: The parsed configuration path + runtime_path: The actual render path like ["Game", "board", "slots", 1, 2] + event_params: Event-specific parameters like {"value": pygame.K_5} + + Returns: + Combined dict of bindings if match succeeds, e.g., {x: 1, y: 2, value: pygame.K_5} + None if match fails + + Example: + parsed path: Game/board/slots/$x/$y/on_key/$value + runtime_path: ["Game", "board", "slots", 1, 2] + event_params: {"value": pygame.K_5} + returns: {"x": 1, "y": 2, "value": pygame.K_5} + """ + if event_params is None: + event_params = {} + + segments = parsed.segments + + # Find where EVENT segment is + event_index = None + for i, seg in enumerate(segments): + if seg.kind == SegmentKind.EVENT: + event_index = i + break + + if event_index is None: + return None + + # Everything before EVENT should match runtime_path + expected_len = event_index + if len(runtime_path) != expected_len: + return None + + bindings: Dict[str, Any] = {} + + # Match path segments + for i, seg in enumerate(segments[:event_index]): + value = runtime_path[i] + + if seg.kind == SegmentKind.ROOT: + if value != seg.value: + return None + + elif seg.kind == SegmentKind.FIELD: + if value != seg.value: + return None + + elif seg.kind == SegmentKind.INDEX_VAR: + if not isinstance(value, int): + return None + bindings[seg.value] = value + + else: + raise AssertionError(f"Unexpected segment kind before EVENT: {seg.kind}") + + # Bind event parameters (optional during config application) + for param_name in parsed.param_vars: + if param_name in event_params: + bindings[param_name] = event_params[param_name] + # If param not in event_params, that's OK - it will be filled in later during event handling + + return bindings + +def _load_interaction_rules(cfg: dict) -> list[InteractionRule]: + rules = [] + for path_str, handler in cfg.items(): + parsed = parse_config_path(path_str) + rules.append(InteractionRule(parsed, handler)) + return rules + +def _load_config_file(config_path: Optional[str] = None) -> dict: + """ + Load interaction configuration from a YAML file. + + Args: + config_path: Path to the YAML config file. If None, searches for 'interactions.yaml' + in multiple standard locations. + + Returns: + Dictionary mapping path patterns to handler names + """ + if config_path is None: + # Try multiple possible locations + search_paths = [ + "interactions.yaml", # Current directory + "test/STR/interactions.yaml", # From python/ directory + "python/test/STR/interactions.yaml", # From repo root + os.path.join(os.path.dirname(__file__), "../../../test/STR/interactions.yaml"), # Relative to this file + ] + + for path in search_paths: + if os.path.exists(path): + config_path = path + break + else: + # No config file found, return empty config + return {} + + if not os.path.exists(config_path): + # Config file not found, return empty config + return {} + + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + + return config if config else {} + +def set_interaction_config(config_path: Optional[str] = None): + """ + Load and cache the interaction configuration globally. + + Call this once at the start of your program to set the config path. + If not called, apply_config will search for the config file automatically. + + Args: + config_path: Path to the YAML config file. If None, searches standard locations. + """ + global _INTERACTION_CONFIG_CACHE, _INTERACTION_RULES_CACHE + + _INTERACTION_CONFIG_CACHE = _load_config_file(config_path) + _INTERACTION_RULES_CACHE = _load_interaction_rules(_INTERACTION_CONFIG_CACHE) + +def apply_config(layout): + """ + Apply all matching rules to this layout node. + + Uses the globally cached configuration set by set_interaction_config(). + If not set, will auto-load from standard locations on first call. + + Args: + layout: The layout node to apply config to + """ + global _INTERACTION_CONFIG_CACHE, _INTERACTION_RULES_CACHE + + # Lazy load config on first use if not explicitly set + if _INTERACTION_RULES_CACHE is None: + set_interaction_config() + + rules = _INTERACTION_RULES_CACHE + + if not hasattr(layout, "render_path"): + return + + for rule in rules: + bindings = match_parsed_path_with_params(rule.path, layout.render_path, event_params={}) + if bindings is not None: + # Attach interaction metadata based on event type + layout.interactive = True + event_type = rule.path.event + + metadata = { + "handler": rule.handler_name, + "args": bindings, + "params": rule.path.param_vars # List of parameter names like ['value'] + } + + # Attach to the appropriate event attribute + if event_type == "on_click": + layout.on_click = metadata + elif event_type == "on_key": + layout.on_key = metadata + elif event_type == "on_hover": + layout.on_hover = metadata + else: + # Generic fallback + setattr(layout, event_type, metadata) + + + + diff --git a/python/rlc/renderer/cpp_serializer.py b/python/rlc/renderer/cpp_serializer.py new file mode 100644 index 00000000..3c6de348 --- /dev/null +++ b/python/rlc/renderer/cpp_serializer.py @@ -0,0 +1,123 @@ +from functools import singledispatchmethod +from .array_renderer import ArrayRenderer +from .bint_renderer import BoundedIntRenderer +from .vector_renderer import VectorRenderer +from .struct_renderer import ContainerRenderer +from .primitive_renderer import PrimitiveRenderer +from .vector_renderer import VectorRenderer +from .renderable import Renderable +from dataclasses import dataclass +from typing import TextIO + +class Indenter: + def __init__(self, ctx: 'SerializationContext'): + self.ctx = ctx + + def __enter__(self): + self.ctx.current_indent = self.ctx.current_indent + 1 + + def __exit__(self, *args): + self.ctx.current_indent = self.ctx.current_indent - 1 + +@dataclass +class SerializationContext: + outstream: TextIO + current_indent: int = 0 + start_of_line: bool = False + + def write(self, string): + if self.start_of_line: + self._do_indent() + self.start_of_line = False + self.outstream.write(string) + + def writenl(self, string): + self.outstream.write(string) + self.endline() + + def endline(self): + self.outstream.write("\n") + self.start_of_line = True + + def _do_indent(self): + for i in range(self.current_indent): + self.outstream.write(" ") + + def indent(self): + return Indenter(self) + + def write_type_unique_id(self, renderer_type: Renderable): + if hasattr(renderer_type, "name"): + if renderer_type.name != "": + self.write(renderer_type.name + "Renderer") + else: + self.write("anon_" + str(id(renderer_type))) + else: + self.write(type(renderer_type).__name__) + + + @singledispatchmethod + def serialize_declaration(self, renderer_type: Renderable): + self.write(str(renderer_type)) + raise NotImplementedError() + + @serialize_declaration.register + def _(self, renderer_type: ArrayRenderer): + pass + + @serialize_declaration.register + def _(self, renderer_type: BoundedIntRenderer): + pass + + @serialize_declaration.register + def _(self, renderer_type: PrimitiveRenderer): + pass + + @serialize_declaration.register + def _(self, renderer_type: VectorRenderer): + pass + + @serialize_declaration.register + def _(self, renderer_type: ContainerRenderer): + self.write("class ") + self.write_type_unique_id(renderer_type) + self.writenl(" {") + with self.indent() as indenter: + for field_name, renderer in renderer_type.field_renderers.items(): + self.serialize_use(renderer) + self.write(" ") + self.write(field_name) + self.write(";") + self.endline() + self.writenl("};"); + + @singledispatchmethod + def serialize_use(self, renderer_type: Renderable): + self.write(str(renderer_type)) + raise NotImplementedError() + + @serialize_use.register + def _(self, renderer_type: ArrayRenderer): + self.write("ArrayRenderer<") + self.serialize_use(renderer_type.element_renderer) + self.write(", ") + self.write(str(renderer_type.length)) + self.write(">") + + @serialize_use.register + def _(self, renderer_type: BoundedIntRenderer): + self.write("BoundedIntRenderer") + + @serialize_use.register + def _(self, renderer_type: PrimitiveRenderer): + self.write("PrimitiveRenderer") + + @serialize_use.register + def _(self, renderer_type: VectorRenderer): + self.write("VectorRenderer<") + self.serialize_use(renderer_type.element_renderer) + self.write(">") + + @serialize_use.register + def _(self, renderer_type: ContainerRenderer): + self.write_type_unique_id(renderer_type) diff --git a/python/rlc/renderer/factory.py b/python/rlc/renderer/factory.py new file mode 100644 index 00000000..1f691a1c --- /dev/null +++ b/python/rlc/renderer/factory.py @@ -0,0 +1,212 @@ +# rlc/renderer/factory.py +from typing import Dict +from ctypes import c_long, c_bool +from rlc.renderer.renderable import Renderable +from rlc.renderer.primitive_renderer import PrimitiveRenderer +from rlc.renderer.bint_renderer import BoundedIntRenderer +from rlc.renderer.array_renderer import ArrayRenderer +from rlc.renderer.vector_renderer import VectorRenderer +from rlc.renderer.struct_renderer import ContainerRenderer +from rlc.renderer.bounded_vector_renderer import BoundedVectorRenderer + + +class RendererFactory: + """ + Stateless renderer factory used for: + - building renderers from RLC ctypes + - rebuilding renderers from JSON + """ + + _cache = {} + @classmethod + def from_rlc_type(cls, rlc_type, config : Dict[type, type], interaction_ctx=None, rlc_path=None): + """ + Build a renderer tree from RLC types with optional interaction config. + + Args: + rlc_type: The RLC ctypes structure to create renderer for + config: { rlc_type : RendererClass } for user overrides + interaction_ctx: InteractionContext for compile-time interaction resolution + rlc_path: Current path in RLC type tree for interaction mapping + """ + if config is None: + config = {} + + if rlc_path is None: + rlc_path = [] + + # Note: Cache disabled to avoid stale interaction mappings + # if rlc_type in cls._cache: + # return cls._cache[rlc_type] + + name = getattr(rlc_type, "__name__", str(rlc_type)) + + # Helper to apply interactions after creating a renderer + def _apply_interactions(renderer, current_path): + if interaction_ctx: + mappings = interaction_ctx.resolve_interactions(id(renderer), current_path) + renderer.interaction_mappings = mappings + return renderer + + # 1. User-specified renderer override (for custom classes) + custom_conf = config.get(name, {}) + custom_renderer_class = custom_conf.get("renderer") + print(name, custom_conf, custom_renderer_class) + # if custom_renderer_class is not None: + # custom_conf = {} + + def _container_renderer(renderer_cls): + fields = renderer_cls.create_fields(cls.from_rlc_type, rlc_type, rlc_path, config, interaction_ctx) + renderer = renderer_cls(rlc_type.__name__, fields) + # cls._cache[rlc_type] = renderer + # Containers themselves don't add their type name to the path + return _apply_interactions(renderer, rlc_path) + + if "Hidden" in name and hasattr(rlc_type, "_fields_"): + return None + # renderer_cls = custom_renderer_class or ContainerRenderer + # return _container_renderer(renderer_cls) + + # 2. BoundedVector (check before general vector to use correct renderer) + if "Bounded" in name and cls._is_vector(rlc_type): + renderer_cls = custom_renderer_class or BoundedVectorRenderer + field = None + for fname, ftype in getattr(rlc_type, "_fields_", []): + child_path = rlc_path + candidate = cls.from_rlc_type(ftype, config, interaction_ctx, child_path) + if candidate is not None: + field = candidate + break + renderer = renderer_cls(rlc_type.__name__, field) + # cls._cache[rlc_type] = renderer + return _apply_interactions(renderer, rlc_path) + + # 3. Vector-like containers (Vector, Dictionary, etc.) + # Uses structural detection: types with _data and _size fields + if cls._is_vector(rlc_type): + if custom_renderer_class: + renderer_cls = custom_renderer_class + else: + renderer_cls = VectorRenderer + element = cls._extract_vector_element(rlc_type) + # Vector elements use index variables in path + element_renderer = cls.from_rlc_type(element, config, interaction_ctx, rlc_path + ["$i"]) + renderer = renderer_cls(rlc_type.__name__, element_renderer) + # cls._cache[rlc_type] = renderer + return _apply_interactions(renderer, rlc_path) + + # 4. Array + if hasattr(rlc_type, "_length_") and hasattr(rlc_type, "_type_"): + if custom_renderer_class: + renderer_cls = custom_renderer_class + else: + renderer_cls = ArrayRenderer + element_renderer = cls.from_rlc_type(rlc_type._type_, config, interaction_ctx, rlc_path + ["$i"]) + renderer = renderer_cls( + rlc_type.__name__, + rlc_type._length_, + element_renderer + ) + # cls._cache[rlc_type] = renderer + return _apply_interactions(renderer, rlc_path) + + # 5. Bounded int + if name.startswith("BInt"): + if custom_renderer_class: + renderer_cls = custom_renderer_class + else: + renderer_cls = BoundedIntRenderer + renderer = renderer_cls(rlc_type.__name__) + # cls._cache[rlc_type] = renderer + # Check interactions at the current path (without adding type name) + return _apply_interactions(renderer, rlc_path) + + # 6. Primitive + if rlc_type in (c_long, c_bool): + if custom_renderer_class: + renderer_cls = custom_renderer_class + else: + renderer_cls = PrimitiveRenderer + renderer = renderer_cls(rlc_type.__name__) + # cls._cache[rlc_type] = renderer + # Check interactions at the current path (without adding type name) + return _apply_interactions(renderer, rlc_path) + + # 7. Struct (object with fields) + if hasattr(rlc_type, "_fields_"): + renderer_cls = custom_renderer_class or ContainerRenderer + return _container_renderer(renderer_cls) + + # 8. Fallback: treat as primitive + renderer = PrimitiveRenderer(rlc_type.__name__) + # cls._cache[rlc_type] = renderer + # Check interactions at the current path (without adding type name) + return _apply_interactions(renderer, rlc_path) + + @staticmethod + def _is_vector(rlc_type) -> bool: + """ + Check if type is vector by structure or name. + + Uses hybrid approach: + 1. First checks for vector structure (_data + _size fields) + 2. Falls back to name pattern matching for backward compatibility + """ + name = getattr(rlc_type, "__name__", "") + fields = getattr(rlc_type, "_fields_", []) + field_names = {fname for fname, _ in fields} + + # Check structure first (preferred for generalization) + if "_data" in field_names and "_size" in field_names: + return True + + # Fallback to name patterns for backward compatibility + return "Vector" in name and hasattr(rlc_type, "_fields_") + + # Extract element type from Vector + @staticmethod + def _extract_vector_element(rlc_type): + visited = set() + current = rlc_type + + while True: + if current in visited: + raise ValueError(f"Cannot resolve vector element type for {rlc_type}") + visited.add(current) + + name = getattr(current, "__name__", "") + + # --- Case 1: Hidden wrapper --------------------------------------- + if name.startswith("HiddenT"): + fields = getattr(current, "_fields_", []) + if len(fields) != 1: + raise ValueError(f"Hidden type {current} has {len(fields)} fields, expected 1") + _, underlying = fields[0] + current = underlying + continue + + # --- Case 2: Find _data pointer inside vector-like struct ------------ + for field_name, field_type in getattr(current, "_fields_", []): + if field_name == "_data": + # Direct pointer to element = element type found + elem = getattr(field_type, "_type_", None) + if elem is not None: + return elem + + # Otherwise the field is another wrapper: descend into it + current = field_type + break + else: + raise ValueError(f"Cannot determine element type for vector-like type: {current}") + + @staticmethod + def from_json_file(path: str): + import json + with open(path, "r") as f: + data = json.load(f) + return Renderable.from_dict(data) + + @staticmethod + def from_json_string(text: str): + import json + return Renderable.from_dict(json.loads(text)) diff --git a/python/rlc/renderer/interaction_context.py b/python/rlc/renderer/interaction_context.py new file mode 100644 index 00000000..ac0c469d --- /dev/null +++ b/python/rlc/renderer/interaction_context.py @@ -0,0 +1,204 @@ +# rlc/renderer/interaction_context.py +""" +Interaction context for compile-time config processing. + +This module handles loading interaction configs and mapping them to renderer nodes +during renderer tree construction, rather than at runtime during layout creation. +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Any +from rlc.renderer.config_parser import parse_config_path, ParsedPath, SegmentKind +import yaml +import os + + +@dataclass +class InteractionMapping: + """ + Stores interaction handler info for a specific renderer node. + Pre-computed during renderer tree construction. + """ + event_type: str # "on_click", "on_key", etc. + handler_name: str + index_vars: List[str] # e.g., ["x", "y"] + param_vars: List[str] # e.g., ["value"] + path: List[str] + + +@dataclass +class InteractionContext: + """ + Context for resolving interaction configs during renderer construction. + + This maps RLC type paths to interaction handlers, allowing us to annotate + renderer nodes with their interactions at compile-time. + """ + # Map: RLC type path pattern → list of interaction mappings + # e.g., "Game/board/slots/$x/$y" → [InteractionMapping(...), ...] + config_rules: List[tuple[ParsedPath, str]] = field(default_factory=list) + + # Map: renderer node id → path in RLC type tree + # Built during renderer construction to track structural differences + renderer_to_rlc_path: Dict[int, List[str]] = field(default_factory=dict) + + # Map: renderer node id → list of InteractionMappings + # Pre-computed interactions for each renderer node + renderer_interactions: Dict[int, List[InteractionMapping]] = field(default_factory=dict) + + @classmethod + def from_config_file(cls, config_path: Optional[str] = None) -> 'InteractionContext': + """ + Load interaction config from YAML file and create context. + + Args: + config_path: Path to interactions.yaml. If None, searches standard locations. + """ + from rlc.renderer.config_parser import _load_config_file + + config_dict = _load_config_file(config_path) + + rules = [] + for path_str, handler_name in config_dict.items(): + parsed_path = parse_config_path(path_str) + rules.append((parsed_path, handler_name)) + + return cls(config_rules=rules) + + def register_renderer_node(self, renderer_id: int, rlc_path: List[str]): + """ + Register a renderer node with its corresponding RLC type path. + + Called during renderer tree construction to track the mapping. + """ + self.renderer_to_rlc_path[renderer_id] = rlc_path + + def resolve_interactions(self, renderer_id: int, rlc_path: List[str]) -> List[InteractionMapping]: + """ + Resolve all interaction rules that match this renderer node's RLC path. + + Returns a list of InteractionMappings that should be attached to this renderer. + """ + mappings = [] + + for parsed_path, handler_name in self.config_rules: + # Try to match the RLC path against the config pattern + if self._matches_pattern(parsed_path, rlc_path): + event_type = parsed_path.event + + # Replace $i placeholders with actual variable names for readable YAML + readable_path = self._make_readable_path(rlc_path, parsed_path.index_vars) + + mapping = InteractionMapping( + event_type=event_type, + handler_name=handler_name, + index_vars=parsed_path.index_vars, + param_vars=parsed_path.param_vars, + path=readable_path + ) + mappings.append(mapping) + + if mappings: + self.renderer_interactions[renderer_id] = mappings + + return mappings + + def _make_readable_path(self, path: List[str], index_vars: List[str]) -> List[str]: + """ + Replace $i placeholders in path with actual variable names. + + Args: + path: Path with $i placeholders like ['Game', 'board', 'slots', '$i', '$i'] + index_vars: Variable names like ['x', 'y'] + + Returns: + Readable path like ['Game', 'board', 'slots', '$x', '$y'] + """ + readable_path = [] + var_index = 0 + + for segment in path: + if segment == '$i': + # Replace with actual variable name + if var_index < len(index_vars): + readable_path.append(f'${index_vars[var_index]}') + var_index += 1 + else: + # Fallback if we run out of variable names + readable_path.append(segment) + else: + readable_path.append(segment) + + return readable_path + + def _matches_pattern(self, parsed_path: ParsedPath, rlc_path: List[str]) -> bool: + """ + Check if an RLC path matches a config pattern (ignoring event and params). + + Args: + parsed_path: Parsed config path like "Game/board/slots/$x/$y/on_click" + rlc_path: Actual RLC type path like ["Game", "board", "slots", 0, 1] + + Returns: + True if the path matches the pattern structure + """ + # Get segments before the EVENT + segments = parsed_path.segments + event_index = None + for i, seg in enumerate(segments): + if seg.kind == SegmentKind.EVENT: + event_index = i + break + + if event_index is None: + return False + + pattern_segments = segments[:event_index] + + # Length must match + if len(pattern_segments) != len(rlc_path): + return False + + # Check each segment + for seg, path_value in zip(pattern_segments, rlc_path): + if seg.kind == SegmentKind.ROOT: + if path_value != seg.value: + return False + elif seg.kind == SegmentKind.FIELD: + if path_value != seg.value: + return False + elif seg.kind == SegmentKind.INDEX_VAR: + # Variable matches any integer index OR the placeholder '$i' + if not isinstance(path_value, int) and path_value != '$i': + return False + # INDEX_WILDCARD would match any index too + + return True + + def get_interactions(self, renderer_id: int) -> List[InteractionMapping]: + """Get pre-computed interactions for a renderer node.""" + return self.renderer_interactions.get(renderer_id, []) + + def apply_to_renderer_tree(self, renderer, rlc_path: Optional[List[str]] = None): + """ + Recursively apply interaction mappings to a renderer tree. + + Used when loading a renderer from YAML to regenerate interaction_mappings. + + Args: + renderer: Root renderer node + rlc_path: Current path in RLC type tree (starts with [rlc_type_name]) + """ + if rlc_path is None: + rlc_path = [renderer.rlc_type_name] + + # Resolve interactions for this node + mappings = self.resolve_interactions(id(renderer), rlc_path) + renderer.interaction_mappings = mappings + + # Recurse into children + for child_renderer in renderer._iter_children(): + # Build child path - this is renderer-specific + # For now, use child's type name + child_path = rlc_path + [child_renderer.rlc_type_name] + self.apply_to_renderer_tree(child_renderer, child_path) diff --git a/python/rlc/renderer/primitive_renderer.py b/python/rlc/renderer/primitive_renderer.py new file mode 100644 index 00000000..0207eacf --- /dev/null +++ b/python/rlc/renderer/primitive_renderer.py @@ -0,0 +1,48 @@ + +from rlc.renderer.renderable import Renderable, register_renderer +from ctypes import c_long, c_bool +from rlc.text import Text +from rlc.layout import Direction, FIT, Padding +import time +from dataclasses import dataclass + +@register_renderer +@dataclass +class PrimitiveRenderer(Renderable): + def build_layout(self, obj, parent_path, direction=Direction.COLUMN, color="white", sizing=(FIT(), FIT()), logger=None, padding=Padding(2,2,2,2), index_bindings=None, mapping=None): + if index_bindings is None: + index_bindings = {} + + if self.rlc_type_name == "c_bool": + text = "True" if obj else "False" + if self.rlc_type_name == "c_long": + text = str(obj if isinstance(obj, int) else obj.value) + else: + text = str(obj) + + layout = self.make_text(text, "Arial", 16, "black") + layout.render_path = parent_path + + # Apply pre-computed interactions with index bindings + self._apply_interaction_mappings(layout, index_bindings) + + if mapping is not None: + mapping.add_entry(tuple(parent_path), self, layout) + + return layout + + def update(self, layout, obj, elapsed_time=0.0): + """Update the text node if the value changed.""" + if isinstance(layout, Text): + new_value = self._extract_value(obj) + layout.update_text(new_value) + + def _extract_value(self, obj): + if self.rlc_type_name == "c_bool": + text = "True" if obj else "False" + if self.rlc_type_name == "c_long": + text = str(obj if isinstance(obj, int) else obj.value) + else: + text = str(obj) + return text + diff --git a/python/rlc/renderer/renderable.py b/python/rlc/renderer/renderable.py new file mode 100644 index 00000000..39740b63 --- /dev/null +++ b/python/rlc/renderer/renderable.py @@ -0,0 +1,244 @@ + +from abc import ABC, abstractmethod +from copy import deepcopy +from rlc.layout import Layout, Direction, FIT, Padding +from typing import Dict +from dataclasses import dataclass, field, fields, is_dataclass, MISSING +from rlc.text import Text +import yaml + +_renderer_registry = {} # maps class name → class + +class RenderableDumper(yaml.SafeDumper): + index = 0 + def generate_anchor(self, node: yaml.Node): + + self.index = self.index + 1 + return str(self.index) + +class RenderableLoader(yaml.FullLoader): + pass + +def renderable_representer(dumper: RenderableDumper, obj: 'Renderable'): + tag = obj.yaml_tag() + mapping = [] + for f in fields(obj): + value = getattr(obj, f.name) + + # Serialize interaction_mappings if present + if f.name == "interaction_mappings": + if value: # Only add if not empty + # Convert InteractionMapping objects to dicts for serialization + # Exclude index_vars since they're now embedded in the path (e.g., $x, $y) + mappings_as_dicts = [ + { + "event_type": m.event_type, + "handler_name": m.handler_name, + "param_vars": m.param_vars, + "path": m.path + } + for m in value + ] + mapping.append((f.name, mappings_as_dicts)) + continue + + if f.default is not MISSING and value == f.default: + continue + + if f.default_factory is not MISSING: + try: + default = f.default_factory() + if value == default: + continue + except TypeError: + pass + + mapping.append((f.name, value)) + return dumper.represent_mapping(tag, mapping) + + +def renderable_multi_constructor(loader: RenderableLoader, tag_suffix: str, node): + """ + tag_suffix is the part after the '!' when using add_multi_constructor("!", ...") + e.g. YAML tag `!FooRenderer` → tag_suffix == "FooRenderer" + """ + cls = _renderer_registry[tag_suffix] # look up the class + data = loader.construct_mapping(node, deep=True) + + # Handle interaction_mappings separately since it has init=False + interaction_mappings = None + if "interaction_mappings" in data: + from rlc.renderer.interaction_context import InteractionMapping + mappings_data = data.pop("interaction_mappings") # Remove from data dict + + # Reconstruct index_vars from path (extract $x, $y, etc.) + interaction_mappings = [] + for m in mappings_data: + # Extract variable names from path + index_vars = [seg[1:] for seg in m["path"] if isinstance(seg, str) and seg.startswith('$')] + + interaction_mappings.append(InteractionMapping( + event_type=m["event_type"], + handler_name=m["handler_name"], + index_vars=index_vars, + param_vars=m["param_vars"], + path=m["path"] + )) + + # Create instance without interaction_mappings + instance = cls(**data) + + # Set interaction_mappings after creation + if interaction_mappings is not None: + instance.interaction_mappings = interaction_mappings + + return instance + +yaml.add_multi_constructor("!", renderable_multi_constructor, Loader=RenderableLoader) + +def register_renderer(cls): + _renderer_registry[cls.__name__] = cls + return cls + +@dataclass +class Renderable(ABC): + """ + Base abstract renderer type. + Each subclasss knows how to convert its types object into a Layout tree. + """ + rlc_type_name: str + interaction_mappings: list = field(default_factory=list, repr=False, compare=False, init=False) + + def make_layout(self, direction=Direction.COLUMN, color="white", sizing=(FIT(), FIT()), logger=None, padding=Padding(2,2,2,2), border=3, child_gap=5) -> Layout: + layout = Layout(sizing=sizing, direction=direction, color=color, padding=padding, border=border, child_gap=child_gap) + + return layout + + def make_text(self, txt, font_name, font_size, color) -> Text: + text = Text(txt, font_name, font_size, color) + return text + + @abstractmethod + def build_layout(self, obj, direction=Direction.ROW, color="white", sizing=(FIT(), FIT()), logger=None, padding=Padding(2,2,2,2)) -> Layout: + """Construct and return a Layout tree for the given object.""" + pass + + def apply_interactivity(self, layout_child, index=None, parent_obj=None): + pass + + def _get_deepest_interaction_mappings(self): + """ + Recursively find the deepest renderer with interaction mappings. + Used by containers/arrays to determine which index variables to bind. + """ + if self.interaction_mappings: + return self.interaction_mappings + + # Check children recursively + for child in self._iter_children(): + child_mappings = child._get_deepest_interaction_mappings() + if child_mappings: + return child_mappings + + return [] + + def _apply_interaction_mappings(self, layout, index_bindings=None): + """ + Apply pre-computed interaction mappings to a layout node. + + Args: + layout: The layout node to apply interactions to + index_bindings: Dict of index variable bindings (e.g., {"x": 4, "y": 5}) + """ + if index_bindings is None: + index_bindings = {} + + for mapping in self.interaction_mappings: + metadata = { + "handler": mapping.handler_name, + "args": index_bindings.copy(), # Index vars from array/vector context + "params": mapping.param_vars # Event params to be filled at runtime + } + + # Attach to the appropriate event attribute + if mapping.event_type == "on_click": + layout.on_click = metadata + elif mapping.event_type == "on_key": + layout.on_key = metadata + elif mapping.event_type == "on_hover": + layout.on_hover = metadata + else: + # Generic fallback + setattr(layout, mapping.event_type, metadata) + + # Mark as interactive + layout.interactive = True + + def update(self, layout, obj, elapsed_time: float = 0.0): + """ + Update the existing layout tree in place using new data from obj. + Optionally, use elapsed_time for animations (interpolation). + """ + pass + + def __call__(self, obj, parent_path=None, mapping=None, **kwds): + + if parent_path is None: + current_path = [self.rlc_type_name] + else: + current_path = list(parent_path) + + layout = self.build_layout(obj=obj, parent_path=current_path, + mapping=mapping, **kwds) + + return layout + + def post_order_types(self): + frontier = [self] + seen = set() + output = [] + while len(frontier) != 0: + current = frontier.pop(0) + if id(current) in seen: + continue + output.append(current) + seen.add(id(current)) + for child in current._iter_children(): + frontier.append(child) + return [x for x in reversed(output)] + + + def to_yaml(self): + return yaml.dump(self.post_order_types(), Dumper=RenderableDumper, sort_keys=False) + + @classmethod + def from_yaml(cls, yaml_text): + return yaml.load(yaml_text, Loader=RenderableLoader)[-1] + + @classmethod + def yaml_tag(cls) -> str: + return f"!{cls.__name__}" + + def _iter_children(self): + """Return iterable of child renderers, if any. Override per subclass.""" + return [] + + def print_interaction_tree(self, indent=0): + """Print the renderer tree with interaction mappings for debugging.""" + prefix = " " * indent + has_interactions = len(self.interaction_mappings) > 0 + marker = " ✓" if has_interactions else "" + print(f"{prefix}{self.__class__.__name__}('{self.rlc_type_name}'){marker}") + + if has_interactions: + for mapping in self.interaction_mappings: + print(f"{prefix} → {mapping.event_type}: {mapping.handler_name}") + if mapping.index_vars: + print(f"{prefix} index_vars={mapping.index_vars}") + if mapping.param_vars: + print(f"{prefix} param_vars={mapping.param_vars}") + + for child in self._iter_children(): + child.print_interaction_tree(indent + 1) + +yaml.add_multi_representer(Renderable, renderable_representer, Dumper=RenderableDumper) diff --git a/python/rlc/renderer/struct_renderer.py b/python/rlc/renderer/struct_renderer.py new file mode 100644 index 00000000..427ae2a1 --- /dev/null +++ b/python/rlc/renderer/struct_renderer.py @@ -0,0 +1,98 @@ +from rlc.renderer.renderable import Renderable, register_renderer +from rlc.layout import Layout, FIT, Direction, Padding +from rlc.text import Text +from dataclasses import dataclass + + + + +@register_renderer +@dataclass +class ContainerRenderer(Renderable): + field_renderers: dict # {display_name: (actual_field_name, renderer)} + + + @staticmethod + def create_fields(factory, rlc_type, rlc_path, config, interaction_ctx): + """ + Create field renderers for a struct type. + + Returns: + Dict mapping display_name -> (actual_field_name, renderer) + By default, display_name == actual_field_name, but subclasses + can transform the display name while preserving the actual field name. + """ + fields = {} + for fname, ftype in getattr(rlc_type, "_fields_", []): + # Child paths include only the field name, not type names + child_path = rlc_path + [fname] + child_renderer = factory(ftype, config, interaction_ctx, child_path) + if child_renderer is None: + continue + # Store as (actual_field_name, renderer) tuple + fields[fname] = (fname, child_renderer) + return fields + + def build_layout(self, obj, parent_path, direction=Direction.COLUMN, color="white", sizing=(FIT(), FIT()), logger=None, padding=Padding(7,7,7,7), index_bindings=None, mapping=None): + if index_bindings is None: + index_bindings = {} + + layout = self.make_layout(sizing=sizing, direction=direction, child_gap=5, color=color, border=5, padding=padding) + layout.binding = {"type": "struct"} + layout.render_path = parent_path + + # Apply pre-computed interactions + self._apply_interaction_mappings(layout, index_bindings) + + for display_name, field_data in self.field_renderers.items(): + if field_data is None: + continue + + actual_field_name, field_renderer = field_data + + # Check if the actual field exists on the object + if not hasattr(obj, actual_field_name): + raise AttributeError( + f"Field '{actual_field_name}' does not exist on object of type {type(obj).__name__}. " + f"Display name: '{display_name}'. " + f"Available fields: {', '.join(f for f, _ in getattr(type(obj), '_fields_', []))}" + ) + + # Create a row for "name: value" + value = getattr(obj, actual_field_name) + row_layout = self.make_layout(sizing=(FIT(), FIT()), direction=Direction.ROW, child_gap=5, color=None, border=5, padding=Padding(10,10,10,10)) + row_layout.render_path = parent_path + label = self.make_text(display_name + ": ", "Arial", 16, "black") + label.render_path = parent_path + value_layout = field_renderer( + value, + parent_path=parent_path + [actual_field_name], + index_bindings=index_bindings, + mapping=mapping) + row_layout.add_child(label) + row_layout.add_child(value_layout) + layout.add_child(row_layout) + return layout + + def update(self, layout, obj, elapsed_time=0.0): + for (display_name, field_data), child_layout in zip(self.field_renderers.items(), layout.children): + if field_data is None: + continue + + actual_field_name, field_renderer = field_data + + # Check if the actual field exists on the object + if not hasattr(obj, actual_field_name): + raise AttributeError( + f"Field '{actual_field_name}' does not exist on object of type {type(obj).__name__}. " + f"Display name: '{display_name}'. " + f"Available fields: {', '.join(f for f, _ in getattr(type(obj), '_fields_', []))}" + ) + + value = getattr(obj, actual_field_name) + field_renderer.update(child_layout.children[-1], value, elapsed_time) + + def _iter_children(self): + # Only return child renderers (extract from tuples) + return [renderer for field_data in self.field_renderers.values() if field_data is not None for _, renderer in [field_data]] + diff --git a/python/rlc/renderer/vector_renderer.py b/python/rlc/renderer/vector_renderer.py new file mode 100644 index 00000000..4757c59d --- /dev/null +++ b/python/rlc/renderer/vector_renderer.py @@ -0,0 +1,99 @@ +from rlc.renderer.renderable import Renderable, register_renderer +from rlc.layout import Layout, Direction, FIT, Padding +from dataclasses import dataclass + +@register_renderer +@dataclass +class VectorRenderer(Renderable): + element_renderer: Renderable + + def build_layout(self, obj, parent_path, direction=Direction.ROW, color="white", sizing=(FIT(), FIT()), logger=None, padding=Padding(5, 5, 5, 5), index_bindings=None, mapping=None): + if index_bindings is None: + index_bindings = {} + + data_ptr = getattr(obj, "_data", None) + size = getattr(obj, "_size", None) + + if size is None and hasattr(obj, "_length_"): + size = obj._length_ + size = size or 0 + + layout = self.make_layout( + sizing=sizing, + direction=direction, + child_gap=5, + padding=padding, + color=color + ) + layout.render_path = parent_path + + # Apply pre-computed interactions for the vector container + self._apply_interaction_mappings(layout, index_bindings) + + # Register so VectorRenderer.update() is called on vector size changes + if mapping is not None: + mapping.add_entry(tuple(parent_path), self, layout) + + if not data_ptr or size <= 0: + return layout + + next_dir = ( + Direction.ROW if direction == Direction.COLUMN else Direction.COLUMN + ) + + index_var_name = None + deepest_mappings = self.element_renderer._get_deepest_interaction_mappings() + if deepest_mappings: + interaction_mapping = deepest_mappings[0] + num_bound = len(index_bindings) + if num_bound < len(interaction_mapping.index_vars): + index_var_name = interaction_mapping.index_vars[num_bound] + + for i in range(size): + item = data_ptr[i] + + child_index_bindings = index_bindings.copy() + if index_var_name: + child_index_bindings[index_var_name] = i + + child_layout = self.element_renderer( + item, + parent_path=parent_path + [i], + direction=next_dir, + color="lightgray", + sizing=(FIT(), FIT()), + logger=logger, + index_bindings=child_index_bindings, + mapping=mapping, + ) + + layout.add_child(child_layout) + + return layout + + def update(self, layout, obj, elapsed_time=0.0): + new_size = obj.size() + old_size = len(layout.children) + + if new_size > old_size: + for i in range(old_size, new_size): + item = obj.get(i).contents + child_layout = self.element_renderer( + item, + direction=layout.direction, + color="lightgray", + sizing=(FIT(), FIT()), + ) + layout.add_child(child_layout) + layout.is_dirty = True + elif new_size < old_size: + layout.children = layout.children[:new_size] + layout.is_dirty = True + + for i in range(min(new_size, old_size)): + item = obj.get(i).contents + self.element_renderer.update(layout.children[i], item, elapsed_time) + + def _iter_children(self): + return [self.element_renderer] + diff --git a/python/rlc/renderer_backend.py b/python/rlc/renderer_backend.py new file mode 100644 index 00000000..a47b4091 --- /dev/null +++ b/python/rlc/renderer_backend.py @@ -0,0 +1,23 @@ +from typing import Tuple, List +from abc import ABC, abstractmethod + +class RendererBackend(ABC): + @abstractmethod + def get_text_size(self, text: str, font_name: str, font_size: int) -> Tuple[int, int]: + pass + + @abstractmethod + def render_text(self, text: str, font_name: str, font_size: int, color: str) -> List['Surface']: + pass + + @abstractmethod + def render_text_lines(self, lines: List[str], font_name: str, font_size: int, color: str) -> List['Surface']: + pass + + @abstractmethod + def draw_rectangle(self, position: Tuple[int, int], size: Tuple[int, int], color: str): + pass + + @abstractmethod + def blit_surface(self, surface, position: Tuple[int, int]): + pass \ No newline at end of file diff --git a/python/rlc/renderer_type_conversion.py b/python/rlc/renderer_type_conversion.py new file mode 100644 index 00000000..3cdfff89 --- /dev/null +++ b/python/rlc/renderer_type_conversion.py @@ -0,0 +1,66 @@ +from typing import Type +from typing import Dict + +from ctypes import c_long, Array, c_bool +from rlc.renderer.primitive_renderer import PrimitiveRenderer +from rlc.renderer.array_renderer import ArrayRenderer +from rlc.renderer.vector_renderer import VectorRenderer +from rlc.renderer.struct_renderer import ContainerRenderer +from rlc.renderer.bint_renderer import BoundedIntRenderer + + +def create_renderer(rlc_type, config : Dict[type, type]): + _renderer_cache = {} + def _create_renderer(rlc_type, config : Dict[type, type]): + if rlc_type in _renderer_cache: + return _renderer_cache[rlc_type] + + name = getattr(rlc_type, "__name__", str(rlc_type)) + + if rlc_type in config: + pytyp = config.get(rlc_type) + if hasattr(rlc_type, "_fields_"): + field_renderer = { + name: _create_renderer(field_type, config) for name, field_type in rlc_type._fields_ + } + renderer = pytyp(rlc_type, field_renderer) + return renderer + + # Vector + elif "Vector" in name: + # find the _data field to determine element type + data_field = next((f for f in getattr(rlc_type, "_fields_", []) if f[0] == "_data"), None) + if data_field: + element_type = getattr(data_field[1], "_type_", None) + if element_type is None: + element_type = data_field[1] + element_renderer = _create_renderer(element_type, config) + renderer = VectorRenderer(element_renderer) + else: + renderer = PrimitiveRenderer() + + + # Array + elif hasattr(rlc_type, "_length_") and hasattr(rlc_type, "_type_"): + element_rendere = _create_renderer(rlc_type._type_, config) + renderer = ArrayRenderer(rlc_type._length_, element_rendere) + + # Primitive + elif rlc_type == c_bool or rlc_type == c_long: + renderer = PrimitiveRenderer() + + # Bounded integer + elif name.startswith("BInt"): + renderer = BoundedIntRenderer() + + # Struct + elif hasattr(rlc_type, "_fields_"): + field_renderer = { + name: _create_renderer(field_type, config) for name, field_type in rlc_type._fields_ + } + renderer = ContainerRenderer(name, field_renderer) + else: + renderer = PrimitiveRenderer(name) + _renderer_cache[rlc_type] = renderer + return renderer + return _create_renderer(rlc_type, config) diff --git a/python/rlc/serialization/__init__.py b/python/rlc/serialization/__init__.py new file mode 100644 index 00000000..1646a97c --- /dev/null +++ b/python/rlc/serialization/__init__.py @@ -0,0 +1,2 @@ + +from rlc.serialization.renderer_serializer import save_renderer \ No newline at end of file diff --git a/python/rlc/serialization/renderer_serializer.py b/python/rlc/serialization/renderer_serializer.py new file mode 100644 index 00000000..82c6b7c1 --- /dev/null +++ b/python/rlc/serialization/renderer_serializer.py @@ -0,0 +1,24 @@ +from rlc.renderer.renderable import Renderable + +def save_renderer(renderable, path): + """ + Save a renderer to a YAML file. + + Uses the Renderable.to_yaml() method which properly serializes + the renderer tree with type information. + """ + with open(path, "w") as f: + if renderable is not None: + yaml_str = renderable.to_yaml() + f.write(yaml_str) + +def load_renderer(path): + """ + Load a renderer from a YAML file. + + Uses the Renderable.from_yaml() method which reconstructs + the renderer tree with proper types. + """ + with open(path, 'r') as f: + yaml_str = f.read() + return Renderable.from_yaml(yaml_str) \ No newline at end of file diff --git a/python/rlc/sim_renderer_mapping.py b/python/rlc/sim_renderer_mapping.py new file mode 100644 index 00000000..4cd52dfb --- /dev/null +++ b/python/rlc/sim_renderer_mapping.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass +from typing import Any, List, Optional + + +@dataclass +class MappingEntry: + """A single leaf mapping between a simulation field and its renderer+layout node.""" + sim_path: tuple # e.g., ("board", "slots", 0, 0) + renderer: Any # the Renderable that renders this field + layout_node: Any # the live Layout node to update + + +class SimRendererMapping: + """ + Bidirectional mapping between simulation type tree and renderer type tree. + + Built inline during layout creation: each renderer's build_layout() + registers its own MappingEntry via the `mapping` parameter. + + Diffing is done by the RLC `diff` function (stdlib/algorithms/diff.rl), + which compares two state instances recursively and returns the dot-separated + paths of every changed leaf field as a Vector. + """ + + def __init__(self): + self.entries: List[MappingEntry] = [] + self._path_to_entry = {} # sim_path tuple → MappingEntry + self._renderer_to_entries = {} # id(renderer) → [MappingEntry] + + def add_entry(self, sim_path, renderer, layout_node): + entry = MappingEntry(sim_path, renderer, layout_node) + self.entries.append(entry) + self._path_to_entry[sim_path] = entry + rid = id(renderer) + if rid not in self._renderer_to_entries: + self._renderer_to_entries[rid] = [] + self._renderer_to_entries[rid].append(entry) + + def get_entry(self, sim_path: tuple) -> Optional[MappingEntry]: + return self._path_to_entry.get(sim_path) + + @staticmethod + def resolve_value(state_obj, sim_path: tuple): + """Walk the state object following sim_path to get the leaf value.""" + obj = state_obj + for seg in sim_path: + if isinstance(seg, int): + target = obj + while hasattr(target, '_data'): + target = target._data + obj = target[seg] + else: + obj = getattr(obj, seg) + return obj + + def print_mapping(self): + """Debug helper: print all mapping entries.""" + print(f"SimRendererMapping: {len(self.entries)} entries") + for entry in self.entries: + path_str = ".".join(str(s) for s in entry.sim_path) + print(f" {path_str:40s} renderer={entry.renderer.__class__.__name__}") diff --git a/python/rlc/text.py b/python/rlc/text.py new file mode 100644 index 00000000..1ab8f906 --- /dev/null +++ b/python/rlc/text.py @@ -0,0 +1,60 @@ +from .layout import Layout +from .renderer_backend import RendererBackend +from typing import Optional +import time + +class Text(Layout): + def __init__(self, text, font_name, font_size, color="black"): + super().__init__(color=color) # Pass color as string to Layout + self.text = text + self.color: str = color # Store as string for JSON serialization + self.font_name = font_name + self.font_size = font_size + self.text_surfaces = [] + self.alpha = 255 + self.last_value = text + self.anim_start = None + self.anim_duration = 0.3 # seconds + + def compute_size(self, available_width=None, available_height=None, logger=None, backend: Optional[RendererBackend] = None): + if backend is None: + # Default to 0 if no backend (for non-rendering modes) + self.width = 0 + self.height = 0 + return + if available_width: + lines = self.wrap_text(backend, available_width) + else: + lines = [self.text] if self.text.strip() else [" "] + + widths = [backend.get_text_size(line, self.font_name, self.font_size)[0] for line in lines] + heights = [backend.get_text_size(line, self.font_name, self.font_size)[1] for line in lines] + self.width = max(widths or [0]) + self.height = sum(heights or [0]) + + if logger: logger.snapshot(self, "text_compute") + + def wrap_text(self, backend, max_width): + words = self.text.split(" ") + lines = [] + current_line = "" + + for word in words: + line = current_line + (" " if current_line else "") + word + w, h = backend.get_text_size(line, self.font_name, self.font_size) + if w <= max_width: + current_line = line + else: + if current_line: + lines.append(current_line) + current_line = word + if current_line: + lines.append(current_line) + return lines + + def update_text(self, new_text): + """Start a smooth transition if value changed.""" + if new_text != self.text: + self.last_value = self.text + self.text = new_text + self.anim_start = time.time() \ No newline at end of file diff --git a/python/rlc_to_renderer_types.py b/python/rlc_to_renderer_types.py new file mode 100644 index 00000000..5c7ed135 --- /dev/null +++ b/python/rlc_to_renderer_types.py @@ -0,0 +1,23 @@ +from command_line import load_program_from_args, make_rlc_argparse +from rlc import Program +from rlc.renderer.renderable import Renderable +from rlc.renderer_type_conversion import create_renderer +import sys +from dataclasses import asdict +from rlc.renderer import SerializationContext + +if __name__ == "__main__": + parser = make_rlc_argparse("rlc_to_layout", description="Dumps the layout and exits") + parser.add_argument("-o", default="-", nargs="?") + parser.add_argument("-cpp", action="store_true") + args = parser.parse_args() + output = sys.stdout if args.o == "-" else open(args.o, "w+") + with load_program_from_args(args, optimize=True) as program: + config = {} + renderer = create_renderer(program.module.Game, config) + if args.cpp: + ctx = SerializationContext(output) + for renderer in renderer.post_order_types(): + ctx.serialize_declaration(renderer) + else: + output.write(renderer.to_yaml()) diff --git a/python/test/STR/blackjack.py b/python/test/STR/blackjack.py new file mode 100644 index 00000000..7b522ca1 --- /dev/null +++ b/python/test/STR/blackjack.py @@ -0,0 +1,166 @@ +from command_line import load_program_from_args, make_rlc_argparse +import os +from rlc import Program +from rlc.renderer.factory import RendererFactory +import pygame, time, random +from test.display_layout import render, PygameRenderer +from rlc import LayoutLogConfig, LayoutLogger +from rlc.renderer.config_parser import action, ACTION_REGISTRY +from simulate import new_timing_bucket, relayout, print_timings +from rlc.renderer.interaction_context import InteractionContext +from rlc.serialization.renderer_serializer import save_renderer +from rlc.event_queue import UpdateController, UpdateSignal, SignalKind +from rlc.sim_renderer_mapping import SimRendererMapping + +@action("hit") +def hit(state): + print("calling hit") + state.state.hit() + return True + +@action("stand") +def stand(state): + print("calling stand") + state.state.stand() + return True + +def play_random_turn(state, controller): + actions = state.legal_actions or [] + if not actions: + return False + action = random.choice(actions) + print(action) + state.step(action) + controller.notify_state_changed() + return True + + +if __name__ == "__main__": + parser = make_rlc_argparse("game_display", description="Display game state") + args = parser.parse_args() + with load_program_from_args(args, optimize=True) as program: + + source_file = args.source_file + base_name = os.path.splitext(os.path.basename(source_file))[0] if source_file else "renderer" + save_path = os.path.join("./logs", f"{base_name}.yaml") + + # Load interaction config at compile-time + interaction_ctx = InteractionContext.from_config_file() + + config = {} + + # Build renderer tree with interaction mappings + renderer = RendererFactory.from_rlc_type( + program.module.Game, + config, + interaction_ctx=interaction_ctx, + rlc_path=["Game"] + ) + + save_renderer(renderer, save_path) + print(f"[saved] renderer -> {save_path}") + + print("\n" + "="*80) + print("RENDERER TREE WITH INTERACTION MAPPINGS:") + print("="*80) + renderer.print_interaction_tree() + print("="*80 + "\n") + + + pygame.init() + screen = pygame.display.set_mode((1280, 720), pygame.RESIZABLE) + screen.fill("white") + clock = pygame.time.Clock() + backend = PygameRenderer(screen) + running = True + + print(renderer) + iterations = 1 + current = 0 + STEP_DELAY = 0.9 # seconds per state + logger = LayoutLogger(LayoutLogConfig()) + logger = None + state = None + + while running and current < iterations: + compute_times = new_timing_bucket() + layout_times = new_timing_bucket() + print(f"\n=== Iteration {current + 1}/{iterations} ===") + if hasattr(state, "reset"): + state.reset() + else: + state = program.start() + mapping = SimRendererMapping() + layout = renderer(state.state, parent_path=[], mapping=mapping) + actions = state.legal_actions + mapping.print_mapping() + + # Dispatch callback: blackjack handlers take (state, **args) + def dispatch_action(handler_name, args): + return ACTION_REGISTRY[handler_name](state, **args) + + def do_relayout(): + relayout(screen, backend, layout, logger, compute_times, layout_times, controller.scroll) + + controller = UpdateController(renderer, layout, do_relayout, dispatch_action, + mapping=mapping, state_obj=state.state, + program_module=program.module) + do_relayout() + + if logger: + logger.record_final_tree(root=layout) + + accumulated_time = 0.0 + elapsed = 0.0 + while running: + # Phase 1: COLLECT + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + elif event.type == pygame.VIDEORESIZE: + screen = pygame.display.set_mode((event.w, event.h), pygame.RESIZABLE) + backend = PygameRenderer(screen) + controller.enqueue(UpdateSignal(kind=SignalKind.RESIZE)) + + elif event.type == pygame.MOUSEWHEEL: + controller.enqueue(UpdateSignal( + kind=SignalKind.SCROLL, + dy=event.y * 30, + dx=event.x * 30)) + + elif event.type == pygame.MOUSEBUTTONDOWN: + mx, my = pygame.mouse.get_pos() + target = layout.find_target(mx, my) + if target and hasattr(target, "on_click") and target.on_click: + meta = target.on_click + controller.enqueue(UpdateSignal( + kind=SignalKind.ACTION, + handler_name=meta["handler"], + args=meta["args"])) + + # Phases 2-4: MUTATE, UPDATE, RELAYOUT (once per frame) + elapsed = clock.tick(60) / 1000.0 + accumulated_time += elapsed + + if accumulated_time >= STEP_DELAY: + accumulated_time = 0.0 + if not state.is_done(): + if state.state.shuffling.to_shuffle > 0: + play_random_turn(state, controller) + else: + print("Game done.") + break + + controller.process(state.state, elapsed) + + # Phase 5: RENDER + screen.fill("white") + render(backend, layout) + pygame.display.flip() + current += 1 + print_timings(f"iteration {current}", compute_times, layout_times) + time.sleep(1.0) + + pygame.quit() + \ No newline at end of file diff --git a/python/test/STR/export.py b/python/test/STR/export.py new file mode 100644 index 00000000..76efc17a --- /dev/null +++ b/python/test/STR/export.py @@ -0,0 +1,89 @@ +from command_line import load_program_from_args, make_rlc_argparse +from rlc import Program +from typing import Type +from typing import Dict +from ctypes import c_long, Array, c_bool +from rlc.renderer.factory import RendererFactory +from rlc.serialization.renderer_serializer import save_renderer +from rlc.renderer.interaction_context import InteractionContext +import os +# from red_board_renderer import RedBoard + + +def make_array_accessor(index): + def access(obj): + return obj[index] + return access + +def make_object_accessor(name): + def access(obj): + return getattr(obj, name) + return access + +def make_single_element_container_accessor(rlc_type, name=None): + if not hasattr(rlc_type, "_fields_") or len(rlc_type._fields_) == 0: + if name is None: + return (rlc_type, lambda x: x) + return (rlc_type, make_object_accessor(name)) + if len(rlc_type._fields_) > 1: # Multi-field struct, return original + return (rlc_type, lambda x: x) + (name, typ) = rlc_type._fields_[0] + accessor = make_object_accessor(name) + while hasattr(typ, "_fields_") and len(typ._fields_) == 1: + rlc_type = typ + (name, typ) = rlc_type._fields_[0] + newacc = lambda obj: make_object_accessor(name)(accessor(obj)) + accessor = newacc + return (typ, accessor) + +def dump_rlc_type(rlc_type: Type, depth=0): + print("-" * depth, rlc_type.__name__) + + + if rlc_type == c_bool: + return + if rlc_type == c_long: + return + if hasattr(rlc_type, "_length_") and hasattr(rlc_type, "_type_"): + return dump_rlc_type(rlc_type._type_, depth+1) + if hasattr(rlc_type, "_type_"): + (typ, accessor) = make_single_element_container_accessor(rlc_type._type_) + dump_rlc_type(accessor(typ), depth+1) + if hasattr(rlc_type, "_fields_") : + for field in rlc_type._fields_: + dump_rlc_type(field[1], depth+1) + + + +if __name__ == "__main__": + parser = make_rlc_argparse("game_display", description="Display game state") + args = parser.parse_args() + with load_program_from_args(args, optimize=True) as program: + # derive a save path from the input file name + source_file = args.source_file + base_name = os.path.splitext(os.path.basename(source_file))[0] if source_file else "renderer" + save_path = os.path.join("./logs", f"{base_name}.yaml") + + # dump_rlc_type(program.module.Game) + + # Load interaction config at compile-time + interaction_ctx = InteractionContext.from_config_file() + + config = { + # 'Board' : { + # 'renderer' : RedBoard + # } + } # Custom renderer overrides (e.g., {Board: RedBoard}) + + # Build renderer tree with interaction mappings + # Start with rlc_path=['Game'] so the root type name is in the path + renderer = RendererFactory.from_rlc_type( + program.module.Game, + config, + interaction_ctx=interaction_ctx, + rlc_path=['Game'] + ) + + # Save renderer with interaction mappings to YAML + save_renderer(renderer, save_path) + print(f"[saved] renderer -> {save_path}") diff --git a/python/test/STR/interactions.yaml b/python/test/STR/interactions.yaml new file mode 100644 index 00000000..fdc139c6 --- /dev/null +++ b/python/test/STR/interactions.yaml @@ -0,0 +1,13 @@ +# Interaction configuration file +# Format: "path/to/element/event_name": "handler_function_name" + +# Sudoku interactions +"Game/board/slots/$x/$y/on_click": "select_cell" +"Game/board/slots/$x/$y/on_key/$value": "input_value" + +# Tic-tac-toe interactions +# "Game/board/slots/$x/$y/on_click": "mark_cell" + +# Blackjack interactions +# "Game/hit_button/hit/on_click": "hit" +# "Game/stand_button/stand/on_click": "stand" diff --git a/python/test/STR/render.py b/python/test/STR/render.py new file mode 100644 index 00000000..914e41f7 --- /dev/null +++ b/python/test/STR/render.py @@ -0,0 +1,212 @@ +from command_line import load_program_from_args, make_rlc_argparse +from rlc import Program +from typing import Type +from typing import Dict +from ctypes import c_long, Array, c_bool +from rlc.renderer.factory import RendererFactory +from test.red_board_renderer import RedBoard +from rlc.layout import Direction +import os +import pygame, time, random +from test.display_layout import render, PygameRenderer +from rlc import LayoutLogConfig, LayoutLogger +from rlc.renderer.config_parser import ACTION_REGISTRY + + + +def any_child_dirty(layout): + if getattr(layout, "is_dirty", False): + layout.is_dirty = False + return True + return any(any_child_dirty(c) for c in layout.children if hasattr(c, "children")) + +def new_timing_bucket(): + # count, total_seconds, max_seconds + return {"count": 0, "total": 0.0, "max": 0.0} + +def _record_timing(bucket, elapsed): + bucket["count"] += 1 + bucket["total"] += elapsed + bucket["max"] = max(bucket["max"], elapsed) + +def print_timings(label, compute_bucket, layout_bucket): + def fmt(b): + if b["count"] == 0: + return "0 runs" + avg = (b["total"] / b["count"]) * 1000 + return f"{b['count']} runs | avg {avg:.3f} ms | max {b['max']*1000:.3f} ms" + print(f"[timing] {label} | compute_size: {fmt(compute_bucket)} | layout: {fmt(layout_bucket)}") + +def _clamp_scroll(layout, screen, scroll, margin): + view_w = max(0, screen.get_width() - 2 * margin) + view_h = max(0, screen.get_height() - 2 * margin) + max_x = max(0, layout.width - view_w) + max_y = max(0, layout.height - view_h) + scroll["x"] = min(0, max(-max_x, scroll["x"])) + scroll["y"] = min(0, max(-max_y, scroll["y"])) + +def relayout(screen, backend, layout, logger, compute_times, layout_times, scroll, margin=20): + """Resize-aware layout: fit inside the window minus a margin and apply scroll offsets.""" + avail_w = max(0, screen.get_width() - 2 * margin) + avail_h = max(0, screen.get_height() - 2 * margin) + t0 = time.perf_counter() + layout.compute_size(available_width=avail_w, available_height=avail_h, logger=logger, backend=backend) + _record_timing(compute_times, time.perf_counter() - t0) + _clamp_scroll(layout, screen, scroll, margin) + t0 = time.perf_counter() + layout.layout(margin + scroll["x"], margin + scroll["y"], logger=logger) + _record_timing(layout_times, time.perf_counter() - t0) + +def make_array_accessor(index): + def access(obj): + return obj[index] + return access + +def make_object_accessor(name): + def access(obj): + return getattr(obj, name) + return access + +def make_single_element_container_accessor(rlc_type, name=None): + if not hasattr(rlc_type, "_fields_") or len(rlc_type._fields_) == 0: + if name is None: + return (rlc_type, lambda x: x) + return (rlc_type, make_object_accessor(name)) + if len(rlc_type._fields_) > 1: # Multi-field struct, return original + return (rlc_type, lambda x: x) + (name, typ) = rlc_type._fields_[0] + accessor = make_object_accessor(name) + while hasattr(typ, "_fields_") and len(typ._fields_) == 1: + rlc_type = typ + (name, typ) = rlc_type._fields_[0] + newacc = lambda obj: make_object_accessor(name)(accessor(obj)) + accessor = newacc + return (typ, accessor) + +def dump_rlc_type(rlc_type: Type, depth=0): + print("-" * depth, rlc_type.__name__) + + + if rlc_type == c_bool: + return + if rlc_type == c_long: + return + if hasattr(rlc_type, "_length_") and hasattr(rlc_type, "_type_"): + return dump_rlc_type(rlc_type._type_, depth+1) + if hasattr(rlc_type, "_type_"): + (typ, accessor) = make_single_element_container_accessor(rlc_type._type_) + dump_rlc_type(accessor(typ), depth+1) + if hasattr(rlc_type, "_fields_") : + for field in rlc_type._fields_: + dump_rlc_type(field[1], depth+1) + +def play_random_turn(elapsed_time, state, renderer, layout): + actions = state.legal_actions or [] + if not actions: + return False + action = random.choice(actions) + print(action) + state.step(action) + renderer.update(layout, state.state, elapsed_time) + layout.is_dirty = True + return True + +if __name__ == "__main__": + parser = make_rlc_argparse("game_display", description="Display game state") + args = parser.parse_args() + with load_program_from_args(args, optimize=True) as program: + + # dump_rlc_type(program.module.Game) + + config = {} + + + renderer = RendererFactory.from_rlc_type(program.module.Game, config) + # print(renderer) + pygame.init() + screen = pygame.display.set_mode((1280, 720), pygame.RESIZABLE) + screen.fill("white") + clock = pygame.time.Clock() + backend = PygameRenderer(screen) + running = True + iterations = 1 + current = 0 + STEP_DELAY = 2 # seconds per state + logger = LayoutLogger(LayoutLogConfig()) + logger = None + state = None + scroll = {"x": 0, "y": 0} + + + while running and current < iterations: + compute_times = new_timing_bucket() + layout_times = new_timing_bucket() + print(f"\n=== Iteration {current + 1}/{iterations} ===") + if hasattr(state, "reset"): + state.reset() + else: + state = program.start() + layout = renderer(state.state) + layout.print_path() + actions = state.legal_actions + relayout(screen, backend, layout, logger, compute_times, layout_times, scroll) + + if logger: + logger.record_final_tree(root=layout) + # print(logger.to_text_tree(layout)) + + last_update = time.time() + accumulated_time = 0.0 + elapsed = 0.0 + while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + if event.type == pygame.VIDEORESIZE: + screen = pygame.display.set_mode((event.w, event.h), pygame.RESIZABLE) + backend = PygameRenderer(screen) + relayout(screen, backend, layout, logger, compute_times, layout_times, scroll) + if event.type == pygame.MOUSEWHEEL: + # y is vertical wheel, x is horizontal wheel; positive y = scroll up + scroll["y"] += event.y * 30 + scroll["x"] += event.x * 30 + relayout(screen, backend, layout, logger, compute_times, layout_times, scroll) + if event.type == pygame.MOUSEBUTTONDOWN: + mx, my = pygame.mouse.get_pos() + target = layout.find_target(mx, my) + # print(target.render_path, hasattr(target, "on_click")) + + if target and hasattr(target, "on_click"): + meta = target.on_click + if meta: + handler = meta["handler"] + args = meta["args"] + print(handler, ACTION_REGISTRY) + changed = ACTION_REGISTRY[handler](state, **args) + if changed: + layout.is_dirty = True + if layout.is_dirty or any_child_dirty(layout): + relayout(screen, backend, layout, logger, compute_times, layout_times, scroll) + + elapsed = clock.tick(60) / 1000.0 + accumulated_time += elapsed + + if accumulated_time >= STEP_DELAY: + accumulated_time = 0.0 + if not state.is_done(): + # if state.state.shuffling.to_shuffle > 0: + if play_random_turn(elapsed, state, renderer, layout): + relayout(screen, backend, layout, logger, compute_times, layout_times, scroll) + else: + pass + else: + print("Game done.") + break + screen.fill("white") + render(backend, layout) + pygame.display.flip() + current += 1 + print_timings(f"iteration {current}", compute_times, layout_times) + time.sleep(1.0) + + pygame.quit() diff --git a/python/test/STR/simulate.py b/python/test/STR/simulate.py new file mode 100644 index 00000000..227ce317 --- /dev/null +++ b/python/test/STR/simulate.py @@ -0,0 +1,171 @@ +import os +from command_line import load_program_from_args, make_rlc_argparse +from rlc import Program +import pygame, time, random +from test.display_layout import render, PygameRenderer +from rlc import LayoutLogConfig, LayoutLogger +from rlc.serialization.renderer_serializer import load_renderer +from rlc.renderer.config_parser import action, ACTION_REGISTRY +from rlc.event_queue import UpdateController, UpdateSignal, SignalKind +from rlc.sim_renderer_mapping import SimRendererMapping + + +def any_child_dirty(layout): + if getattr(layout, "is_dirty", False): + layout.is_dirty = False + return True + return any(any_child_dirty(c) for c in layout.children if hasattr(c, "children")) + +def new_timing_bucket(): + # count, total_seconds, max_seconds + return {"count": 0, "total": 0.0, "max": 0.0} + +def _record_timing(bucket, elapsed): + bucket["count"] += 1 + bucket["total"] += elapsed + bucket["max"] = max(bucket["max"], elapsed) + +def print_timings(label, compute_bucket, layout_bucket): + def fmt(b): + if b["count"] == 0: + return "0 runs" + avg = (b["total"] / b["count"]) * 1000 + return f"{b['count']} runs | avg {avg:.3f} ms | max {b['max']*1000:.3f} ms" + print(f"[timing] {label} | compute_size: {fmt(compute_bucket)} | layout: {fmt(layout_bucket)}") + +def _clamp_scroll(layout, screen, scroll, margin): + view_w = max(0, screen.get_width() - 2 * margin) + view_h = max(0, screen.get_height() - 2 * margin) + max_x = max(0, layout.width - view_w) + max_y = max(0, layout.height - view_h) + scroll["x"] = min(0, max(-max_x, scroll["x"])) + scroll["y"] = min(0, max(-max_y, scroll["y"])) + +def relayout(screen, backend, layout, logger, compute_times, layout_times, scroll, margin=20): + """Resize-aware layout: fit inside the window minus a margin and apply scroll offsets.""" + avail_w = max(0, screen.get_width() - 2 * margin) + avail_h = max(0, screen.get_height() - 2 * margin) + t0 = time.perf_counter() + layout.compute_size(available_width=avail_w, available_height=avail_h, logger=logger, backend=backend) + _record_timing(compute_times, time.perf_counter() - t0) + _clamp_scroll(layout, screen, scroll, margin) + t0 = time.perf_counter() + layout.layout(margin + scroll["x"], margin + scroll["y"], logger=logger) + _record_timing(layout_times, time.perf_counter() - t0) + +def play_random_turn(state, controller): + actions = state.legal_actions or [] + if not actions: + return False + action = random.choice(actions) + print(action) + state.step(action) + controller.notify_state_changed() + return True + +if __name__ == "__main__": + parser = make_rlc_argparse("game_display", description="Display game state") + args = parser.parse_args() + with load_program_from_args(args, optimize=True) as program: + + pygame.init() + screen = pygame.display.set_mode((1280, 720), pygame.RESIZABLE) + screen.fill("white") + clock = pygame.time.Clock() + backend = PygameRenderer(screen) + running = True + source_file = args.source_file + base_name = os.path.splitext(os.path.basename(source_file))[0] if source_file else "renderer" + load_path = os.path.join("./logs", f"{base_name}.yaml") + + renderer = load_renderer(load_path) + print(renderer) + iterations = 1 + current = 0 + STEP_DELAY = 2 # seconds per state + logger = LayoutLogger(LayoutLogConfig()) + logger = None + state = None + + while running and current < iterations: + compute_times = new_timing_bucket() + layout_times = new_timing_bucket() + print(f"\n=== Iteration {current + 1}/{iterations} ===") + if hasattr(state, "reset"): + state.reset() + else: + state = program.start() + mapping = SimRendererMapping() + layout = renderer(state.state, parent_path=[], mapping=mapping) + layout.print_path() + actions = state.legal_actions + mapping.print_mapping() + + # Dispatch callback: simulate handlers take (state, **args) only + def dispatch_action(handler_name, args): + return ACTION_REGISTRY[handler_name](state, **args) + + def do_relayout(): + relayout(screen, backend, layout, logger, compute_times, layout_times, controller.scroll) + + controller = UpdateController(renderer, layout, do_relayout, dispatch_action, + mapping=mapping, state_obj=state.state, + program_module=program.module) + do_relayout() + + if logger: + logger.record_final_tree(root=layout) + + accumulated_time = 0.0 + elapsed = 0.0 + while running: + # Phase 1: COLLECT + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + elif event.type == pygame.VIDEORESIZE: + screen = pygame.display.set_mode((event.w, event.h), pygame.RESIZABLE) + backend = PygameRenderer(screen) + controller.enqueue(UpdateSignal(kind=SignalKind.RESIZE)) + + elif event.type == pygame.MOUSEWHEEL: + controller.enqueue(UpdateSignal( + kind=SignalKind.SCROLL, + dy=event.y * 30, + dx=event.x * 30)) + + elif event.type == pygame.MOUSEBUTTONDOWN: + mx, my = pygame.mouse.get_pos() + target = layout.find_target(mx, my) + if target and hasattr(target, "on_click") and target.on_click: + meta = target.on_click + controller.enqueue(UpdateSignal( + kind=SignalKind.ACTION, + handler_name=meta["handler"], + args=meta["args"])) + + # Auto-play on timer + elapsed = clock.tick(60) / 1000.0 + accumulated_time += elapsed + + if accumulated_time >= STEP_DELAY: + accumulated_time = 0.0 + if not state.is_done(): + play_random_turn(state, controller) + else: + print("Game done.") + break + + # Phases 2-4: MUTATE, UPDATE, RELAYOUT (once per frame) + controller.process(state.state, elapsed) + + # Phase 5: RENDER + screen.fill("white") + render(backend, layout) + pygame.display.flip() + current += 1 + print_timings(f"iteration {current}", compute_times, layout_times) + time.sleep(1.0) + + pygame.quit() diff --git a/python/test/STR/sudoku.py b/python/test/STR/sudoku.py new file mode 100644 index 00000000..0fcea224 --- /dev/null +++ b/python/test/STR/sudoku.py @@ -0,0 +1,194 @@ +from command_line import load_program_from_args, make_rlc_argparse +from rlc import Program +from rlc.renderer.factory import RendererFactory +import pygame, time, random +from test.display_layout import render, PygameRenderer +from rlc import LayoutLogConfig, LayoutLogger +from simulate import new_timing_bucket, relayout, print_timings +import os +from rlc.renderer.config_parser import action, ACTION_REGISTRY +from rlc.serialization.renderer_serializer import load_renderer, save_renderer +from rlc.renderer.interaction_context import InteractionContext +from test.red_board_renderer import RedBoard +from rlc.event_queue import UpdateController, UpdateSignal, SignalKind +from rlc.sim_renderer_mapping import SimRendererMapping + +@action("select_cell") +def select_cell(program, state, x, y): + """Handler for clicking a cell - just returns True to indicate success""" + print(f"Selected cell at ({x}, {y})") + return True + +@action("input_value") +def input_value(program, state, x, y, value): + """Handler for keyboard input on a focused cell""" + # Convert pygame keycode to digit + if pygame.K_1 <= value <= pygame.K_9: + digit = value - pygame.K_0 + else: + print(f"Invalid key: {value}") + return False + + print(f"Input {digit} at cell ({x}, {y})") + + # Apply the move + mod = program.module + pos_r = mod.make_pos(x) + pos_c = mod.make_pos(y) + num = mod.make_num(digit) + + if hasattr(state.state, "can_place") and not state.state.can_place(num, pos_r, pos_c): + print(f"Cannot place {digit} at ({x}, {y})") + return False + + state.state.place(num, pos_r, pos_c) + return True + +if __name__ == "__main__": + parser = make_rlc_argparse("game_display", description="Display game state") + args = parser.parse_args() + with load_program_from_args(args, optimize=True) as program: + + source_file = args.source_file + base_name = os.path.splitext(os.path.basename(source_file))[0] if source_file else "renderer" + save_path = os.path.join("./logs", f"{base_name}.yaml") + + # Load interaction context from config file + interaction_ctx = InteractionContext.from_config_file() + + config = { + 'Game' : { + 'renderer' : RedBoard + } + } + renderer = RendererFactory.from_rlc_type( + program.module.Game, + config, + interaction_ctx=interaction_ctx, + rlc_path=['Game'] + ) + + save_renderer(renderer, save_path) + print(f"[saved] renderer -> {save_path}") + + # source_file = args.source_file + # base_name = os.path.splitext(os.path.basename(source_file))[0] if source_file else "renderer" + # load_path = os.path.join("./logs", f"{base_name}.yaml") + + # renderer = load_renderer(load_path) + + pygame.init() + screen = pygame.display.set_mode((1280, 720), pygame.RESIZABLE) + screen.fill("white") + clock = pygame.time.Clock() + backend = PygameRenderer(screen) + running = True + + iterations = 1 + current = 0 + STEP_DELAY = 0.9 # seconds per state + logger = LayoutLogger(LayoutLogConfig()) + logger = None + state = None + + while running and current < iterations: + compute_times = new_timing_bucket() + layout_times = new_timing_bucket() + print(f"\n=== Iteration {current + 1}/{iterations} ===") + if hasattr(state, "reset"): + state.reset() + else: + state = program.start() + mapping = SimRendererMapping() + layout = renderer(state.state, parent_path=[], mapping=mapping) + actions = state.legal_actions + mapping.print_mapping() + + # Dispatch callback: sudoku handlers take (program, state, **args) + def dispatch_action(handler_name, args): + return ACTION_REGISTRY[handler_name](program, state, **args) + + # Relayout callback + def do_relayout(): + relayout(screen, backend, layout, logger, compute_times, layout_times, controller.scroll) + + controller = UpdateController(renderer, layout, do_relayout, dispatch_action, + mapping=mapping, state_obj=state.state, + program_module=program.module) + do_relayout() + + if logger: + logger.record_final_tree(root=layout) + + accumulated_time = 0.0 + elapsed = 0.0 + while running: + # Phase 1: COLLECT - translate pygame events into signals + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + elif event.type == pygame.VIDEORESIZE: + screen = pygame.display.set_mode((event.w, event.h), pygame.RESIZABLE) + backend = PygameRenderer(screen) + controller.enqueue(UpdateSignal(kind=SignalKind.RESIZE)) + + elif event.type == pygame.MOUSEWHEEL: + controller.enqueue(UpdateSignal( + kind=SignalKind.SCROLL, + dy=event.y * 30, + dx=event.x * 30)) + + elif event.type == pygame.MOUSEBUTTONDOWN: + mx, my = pygame.mouse.get_pos() + target = layout.find_target(mx, my) + + if target and hasattr(target, "on_click") and target.on_click: + meta = target.on_click + controller.enqueue(UpdateSignal( + kind=SignalKind.ACTION, + handler_name=meta["handler"], + args=meta["args"])) + + # Focus the clicked target (or unfocus if None) + controller.enqueue(UpdateSignal( + kind=SignalKind.FOCUS, + target=target if target else None)) + + elif event.type == pygame.KEYDOWN: + focused = layout.find_focused_node() + if focused and hasattr(focused, "on_key") and focused.on_key is not None: + meta = focused.on_key + # Build event params from pygame event + event_params = {} + for param_name in meta.get("params", []): + if param_name == "value": + event_params["value"] = event.key + + all_args = {**meta["args"], **event_params} + controller.enqueue(UpdateSignal( + kind=SignalKind.ACTION, + handler_name=meta["handler"], + args=all_args)) + + # Phases 2-4: MUTATE, UPDATE, RELAYOUT (once per frame) + elapsed = clock.tick(60) / 1000.0 + controller.process(state.state, elapsed) + + accumulated_time += elapsed + if accumulated_time >= STEP_DELAY: + accumulated_time = 0.0 + if state.is_done(): + print("Game done.") + break + + # Phase 5: RENDER + screen.fill("white") + render(backend, layout) + pygame.display.flip() + current += 1 + print_timings(f"iteration {current}", compute_times, layout_times) + time.sleep(1.0) + + pygame.quit() + \ No newline at end of file diff --git a/python/test/STR/test_interactions.yaml b/python/test/STR/test_interactions.yaml new file mode 100644 index 00000000..1be73988 --- /dev/null +++ b/python/test/STR/test_interactions.yaml @@ -0,0 +1,12 @@ +# Test interaction configuration for different scenarios +# This file tests: +# 1. Arrays with BoundedInt (tic-tac-toe) +# 2. Vectors with primitives (sudoku) +# 3. Event parameters (on_key with $value) + +# Tic-tac-toe: Array[Array[BoundedInt]] +"Game/board/slots/$x/$y/on_click": "mark_cell" + +# Sudoku: Vector> with both click and key events +"Game/board/slots/$row/$col/on_click": "select_cell" +"Game/board/slots/$row/$col/on_key/$value": "input_value" diff --git a/python/test/STR/tic_tac_toe.py b/python/test/STR/tic_tac_toe.py new file mode 100644 index 00000000..e727ca48 --- /dev/null +++ b/python/test/STR/tic_tac_toe.py @@ -0,0 +1,172 @@ +from command_line import load_program_from_args, make_rlc_argparse +from rlc import Program +from rlc.renderer.factory import RendererFactory +from test.red_board_renderer import RedBoard +from rlc.renderer.interaction_context import InteractionContext +from rlc.layout import Direction +import os +import pygame, time, random +from test.display_layout import render, PygameRenderer +from rlc import LayoutLogConfig, LayoutLogger +from rlc.serialization.renderer_serializer import load_renderer +from simulate import new_timing_bucket, relayout, print_timings +from rlc.renderer.config_parser import action, ACTION_REGISTRY +from rlc.event_queue import UpdateController, UpdateSignal, SignalKind +from rlc.sim_renderer_mapping import SimRendererMapping + +@action("mark_cell") +def mark_cell(program, state, x, y): + print("calling mark", x , y) + # Only allow human (player 2) to mark when it's their turn + if hasattr(state.state.board, 'playerTurn') and state.state.board.playerTurn == False: + print("Not your turn!") + return False + mod = program.module if program else getattr(state, "program", None).module + pos_r = mod.make_pos(x) + pos_c = mod.make_pos(y) + if hasattr(state.state, "can_mark") and not state.state.can_mark(pos_r, pos_c): + return False + state.state.mark(pos_r, pos_c) + return True + +def play_random_turn(state, controller): + actions = state.legal_actions or [] + if not actions: + return False + action = random.choice(actions) + print(action) + state.step(action) + controller.notify_state_changed() + return True + + +if __name__ == "__main__": + parser = make_rlc_argparse("game_display", description="Display game state") + args = parser.parse_args() + with load_program_from_args(args, optimize=True) as program: + + # Load interaction config at compile-time + interaction_ctx = InteractionContext.from_config_file() + + config = {} + + # Build renderer tree with interaction mappings + renderer = RendererFactory.from_rlc_type( + program.module.Game, + config, + interaction_ctx=interaction_ctx, + rlc_path=["Game"] + ) + + # source_file = args.source_file + # base_name = os.path.splitext(os.path.basename(source_file))[0] if source_file else "renderer" + # load_path = os.path.join("./logs", f"{base_name}.yaml") + + # renderer = load_renderer(load_path) + + # Print debug info about interaction mappings + print("\n" + "="*80) + print("RENDERER TREE WITH INTERACTION MAPPINGS:") + print("="*80) + renderer.print_interaction_tree() + print("="*80 + "\n") + + pygame.init() + screen = pygame.display.set_mode((1280, 720), pygame.RESIZABLE) + screen.fill("white") + clock = pygame.time.Clock() + backend = PygameRenderer(screen) + running = True + + # renderer.print_tree() + # print(renderer) + iterations = 1 + current = 0 + STEP_DELAY = 0.9 # seconds per state + logger = LayoutLogger(LayoutLogConfig()) + logger = None + state = None + + while running and current < iterations: + compute_times = new_timing_bucket() + layout_times = new_timing_bucket() + print(f"\n=== Iteration {current + 1}/{iterations} ===") + if hasattr(state, "reset"): + state.reset() + else: + state = program.start() + mapping = SimRendererMapping() + layout = renderer(state.state, parent_path=[], mapping=mapping) + actions = state.legal_actions + mapping.print_mapping() + + # Dispatch callback: tic_tac_toe handlers take (program, state, **args) + def dispatch_action(handler_name, args): + return ACTION_REGISTRY[handler_name](program, state, **args) + + def do_relayout(): + relayout(screen, backend, layout, logger, compute_times, layout_times, controller.scroll) + + controller = UpdateController(renderer, layout, do_relayout, dispatch_action, + mapping=mapping, state_obj=state.state, + program_module=program.module) + do_relayout() + + if logger: + logger.record_final_tree(root=layout) + + accumulated_time = 0.0 + elapsed = 0.0 + while running: + # Phase 1: COLLECT + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + elif event.type == pygame.VIDEORESIZE: + screen = pygame.display.set_mode((event.w, event.h), pygame.RESIZABLE) + backend = PygameRenderer(screen) + controller.enqueue(UpdateSignal(kind=SignalKind.RESIZE)) + + elif event.type == pygame.MOUSEWHEEL: + controller.enqueue(UpdateSignal( + kind=SignalKind.SCROLL, + dy=event.y * 30, + dx=event.x * 30)) + + elif event.type == pygame.MOUSEBUTTONDOWN: + mx, my = pygame.mouse.get_pos() + target = layout.find_target(mx, my) + if target and hasattr(target, "on_click") and target.on_click: + meta = target.on_click + controller.enqueue(UpdateSignal( + kind=SignalKind.ACTION, + handler_name=meta["handler"], + args=meta["args"])) + + # Phases 2-4: MUTATE, UPDATE, RELAYOUT (once per frame) + elapsed = clock.tick(60) / 1000.0 + accumulated_time += elapsed + + if accumulated_time >= STEP_DELAY: + accumulated_time = 0.0 + if not state.is_done(): + # Auto-play when it's not the player's turn + if hasattr(state.state.board, 'playerTurn') and state.state.board.playerTurn == False: + play_random_turn(state, controller) + else: + print("Game done.") + break + + controller.process(state.state, elapsed) + + # Phase 5: RENDER + screen.fill("white") + render(backend, layout) + pygame.display.flip() + current += 1 + print_timings(f"iteration {current}", compute_times, layout_times) + time.sleep(1.0) + + pygame.quit() + \ No newline at end of file diff --git a/python/test/__init__.py b/python/test/__init__.py index ae354e34..897cb67b 100644 --- a/python/test/__init__.py +++ b/python/test/__init__.py @@ -7,3 +7,5 @@ # # You should have received a copy of the GNU General Public License along with RLC. If not, see . # + +from .red_board_renderer import RedBoard \ No newline at end of file diff --git a/python/test/display_layout.py b/python/test/display_layout.py new file mode 100644 index 00000000..9722c1bd --- /dev/null +++ b/python/test/display_layout.py @@ -0,0 +1,163 @@ + +import argparse +import pygame +import os +import time +from rlc import Layout, Text +from rlc import LayoutLogConfig, LayoutLogger +from rlc import RendererBackend +from typing import Tuple, List +import math + +def render(self, backend, position): + """Called when drawing text.""" + +class PygameRenderer(RendererBackend): + def __init__(self, screen): + self.screen = screen + + def get_text_size(self, text: str, font_name: str, font_size: int) -> Tuple[int, int]: + font = pygame.font.SysFont(font_name, font_size) + surface = font.render(text, True, pygame.Color("black")) # Dummy render for size + return surface.get_size() + + def render_text(self, text: str, font_name: str, font_size: int, color: str) -> List[pygame.Surface]: + + font = pygame.font.SysFont(font_name, font_size) + return [font.render(text, True, pygame.Color(color))] + + def render_text_lines(self, lines: List[str], font_name: str, font_size: int, color: str, anim_start, anim_duration, alpha) -> List[pygame.Surface]: + if anim_start: + # compute fade progress + t = (time.time() - anim_start) / anim_duration + if t >= 1: + anim_start = None + alpha = 255 + else: + # Smooth fade-out and fade-in + alpha = int(255 * math.sin(math.pi * t)) + alpha = alpha + # Render each line separately + font = pygame.font.SysFont(font_name, font_size) + return [font.render(line, True, pygame.Color(color)) for line in lines] + + def draw_rectangle(self, position: Tuple[int, int], size: Tuple[int, int], color: str, border_size=2): + x, y = position + w, h = size + if color and color.startswith("rgba("): + parts = color[5:-1].split(',') + r, g, b, a = map(int, parts) + surf = pygame.Surface((w, h), pygame.SRCALPHA) + surf.fill((r, g, b, a)) + self.blit_surface(surf, (x, y)) + else: + color = pygame.Color(color if color else "white") + pygame.draw.rect(self.screen, color, pygame.Rect(x, y, w, h)) + + + def draw_border(self, position: Tuple[int, int], size: Tuple[int, int], + border_color: str = "darkgray", border_size: int = 2): + x, y = position + w, h = size + + if border_size <= 0: + return + + rect = pygame.Rect(x, y, w, h) + pygame.draw.rect(self.screen, pygame.Color(border_color), rect, width=border_size) + + + def blit_surface(self, surface, position: Tuple[int, int]): + self.screen.blit(surface, position) + +def _auto_name(prefix: str, ext: str, out = None) -> str: + ts = time.strftime("%Y%m%d-%H%M%S") + if not out: + # default logs directory + return os.path.join("logs", f"{prefix}-{ts}.{ext}") + if out.endswith("/") or (os.path.isdir(out) if os.path.exists(out) else out.endswith(os.sep)): + return os.path.join(out, f"{prefix}-{ts}.{ext}") + # explicit file path provided + return out + +def display(build_function): + parser = argparse.ArgumentParser() + parser.add_argument("--dump", action="store_true", help="Dump layout tree as text") + parser.add_argument("--json", action="store_true", help="Dump layout tree as json") + parser.add_argument("--dump-out", nargs="?", + const="logs/", default=None, help="Write text log to this file (or directory). Auto-name if directory is missing") + parser.add_argument("--json-out", nargs="?", + const="logs/", default=None, help="Write json log to this file (or directory). Auto-name if directory is missing") + args = parser.parse_args() + + want_logger = args.dump or args.json or args.dump_out or args.json_out + logger = LayoutLogger(LayoutLogConfig()) if want_logger else None + + pygame.init() + screen = pygame.display.set_mode((2000, 700)) + screen.fill((240, 230, 220)) + backend = PygameRenderer(screen) + + root = build_function() + root.compute_size(logger=logger, backend=backend) + root.layout(20, 20, logger=logger) + + if logger: + logger.record_final_tree(root=root) + if args.dump: + print(logger.to_text_tree(root)) + if args.json: + print(logger.to_json()) + if args.json_out: + path = _auto_name("layout_log", "json", args.json_out) + logger.write_json(path=path) + print(f"[saved] json log -> {path}") + if args.dump_out: + path = _auto_name("layout_tree", "txt", args.dump_out) + logger.write_text_tree(path=path, root=root) + print(f"[saved] text tree -> {path}") + + render(backend, root) + + pygame.display.flip() + pygame.time.wait(5000) + pygame.quit() + + +def render(backend, node): + if isinstance(node, Text): + write_text(node, backend) + return + if isinstance(node, Layout): + # print("layout") + backend.draw_rectangle((node.x, node.y), (node.width, node.height), node.color, node.border) + for child in node.children: + render(backend, child) + if node.border > 0: + + if node.focused: + backend.draw_border( + (node.x, node.y), + (node.width, node.height), + "yellow", + border_size=3 + ) + else: + backend.draw_border((node.x, node.y), (node.width, node.height), border_color="darkgray", border_size=node.border) + + + +def write_text(node, backend): + lines = node.wrap_text(backend, node.width) + color = node.color + if node.focused: + color = "yellow" + surfaces = backend.render_text_lines(lines, node.font_name, node.font_size, color, node.anim_start, node.anim_duration, node.alpha) + y_offset = 0 + for surface in surfaces: + surface.set_alpha(node.alpha) + backend.blit_surface(surface, (node.x, node.y + y_offset)) + y_offset += surface.get_height() + return + + diff --git a/python/test/program_display.py b/python/test/program_display.py new file mode 100644 index 00000000..e0833456 --- /dev/null +++ b/python/test/program_display.py @@ -0,0 +1,147 @@ +from command_line import load_program_from_args, make_rlc_argparse +from rlc import Program +from rlc.renderer_type_conversion import create_renderer +from dataclasses import asdict +from typing import Type +from typing import Dict +import pygame, time, random +import json +from ctypes import c_long, Array, c_bool +from test.display_layout import render, PygameRenderer +from rlc import LayoutLogConfig, LayoutLogger +from test.red_board_renderer import RedBoard + +def make_array_accessor(index): + def access(obj): + return obj[index] + return access + +def make_object_accessor(name): + def access(obj): + return getattr(obj, name) + return access + +def make_single_element_container_accessor(rlc_type, name=None): + if not hasattr(rlc_type, "_fields_") or len(rlc_type._fields_) == 0: + if name is None: + return (rlc_type, lambda x: x) + return (rlc_type, make_object_accessor(name)) + if len(rlc_type._fields_) > 1: # Multi-field struct, return original + return (rlc_type, lambda x: x) + (name, typ) = rlc_type._fields_[0] + accessor = make_object_accessor(name) + while hasattr(typ, "_fields_") and len(typ._fields_) == 1: + rlc_type = typ + (name, typ) = rlc_type._fields_[0] + newacc = lambda obj: make_object_accessor(name)(accessor(obj)) + accessor = newacc + return (typ, accessor) + +def dump_rlc_type(rlc_type: Type, depth=0): + print("-" * depth, rlc_type.__name__) + + print(rlc_type) + if issubclass(rlc_type, Array): + return dump_rlc_type(rlc_type._type_, depth+1) + if rlc_type == c_bool: + return + if rlc_type == c_long: + return + if hasattr(rlc_type, "_type_"): + (typ, accessor) = make_single_element_container_accessor(rlc_type._type_) + dump_rlc_type(accessor(typ), depth+1) + if hasattr(rlc_type, "_fields_") : + for field in rlc_type._fields_: + dump_rlc_type(field[1], depth+1) + +def any_child_dirty(layout): + if getattr(layout, "is_dirty", False): + layout.is_dirty = False + return True + return any(any_child_dirty(c) for c in layout.children if hasattr(c, "children")) + + +if __name__ == "__main__": + parser = make_rlc_argparse("game_display", description="Display game state") + args = parser.parse_args() + with load_program_from_args(args, optimize=True) as program: + + + #dump_rlc_type(program.module.Game) + + config = { + program.module.RedBoard : RedBoard + } + + renderer = create_renderer(program.module.Game, config) + print(renderer) + json_str = json.dumps(asdict(renderer), indent=4) + print(json_str) + + pygame.init() + screen = pygame.display.set_mode((1280, 720)) + screen.fill("white") + clock = pygame.time.Clock() + backend = PygameRenderer(screen) + running = True + iterations = 1 + current = 0 + STEP_DELAY = 0.9 # seconds per state + logger = LayoutLogger(LayoutLogConfig()) + logger = None + state = None + + + while running and current < iterations: + print(f"\n=== Iteration {current + 1}/{iterations} ===") + if hasattr(state, "reset"): + state.reset() + else: + state = program.start() + layout = renderer(state.state) + actions = state.legal_actions + layout.compute_size(logger=logger, backend=backend) + layout.layout(20, 20, logger=logger) + + if logger: + logger.record_final_tree(root=layout) + # print(logger.to_text_tree(layout)) + + last_update = time.time() + accumulated_time = 0.0 + while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + elapsed = clock.tick(60) / 1000.0 + accumulated_time += elapsed + + if accumulated_time >= STEP_DELAY: + accumulated_time = 0.0 + if not state.is_done(): + actions = state.legal_actions + if len(actions) != 0: + action = random.choice(actions) + state.step(action) + # state.state.place(program.module.make_num(4), program.module.make_pos(0), program.module.make_pos(0)) + new_state = state.state + print(action, len(actions)) + renderer.update(layout, new_state, elapsed) + if layout.is_dirty or any_child_dirty(layout): + layout.compute_size(logger=logger, backend=backend) + layout.layout(20, 20, logger=logger) + else: + print("No legal actions left.") + break + else: + print("Game done.") + break + screen.fill("white") + render(backend, layout) + pygame.display.flip() + current += 1 + time.sleep(1.0) + + pygame.quit() + diff --git a/python/test/red_board_renderer.py b/python/test/red_board_renderer.py new file mode 100644 index 00000000..26eb76de --- /dev/null +++ b/python/test/red_board_renderer.py @@ -0,0 +1,57 @@ +from rlc.renderer.struct_renderer import ContainerRenderer +from rlc.renderer.renderable import Renderable, register_renderer +from rlc.layout import Layout, Direction, FIT, Padding +from rlc.text import Text +from dataclasses import dataclass + +@register_renderer +@dataclass +class RedBoard(ContainerRenderer): + + @staticmethod + def create_fields(factory, rlc_type, rlc_path, config, interaction_ctx): + fields = ContainerRenderer.create_fields(factory, rlc_type, rlc_path, config, interaction_ctx) + del fields['resume_index'] + # Transform display names by adding 'red' prefix while keeping actual field names + return {'red_' + key: value for key, value in fields.items()} + def build_layout(self, obj, parent_path, direction=Direction.COLUMN, color="red", sizing=(FIT(), FIT()), logger=None, padding=Padding(7,7,7,7), index_bindings=None, mapping=None): + if index_bindings is None: + index_bindings = {} + + layout = self.make_layout(sizing=sizing, direction=direction, child_gap=5, color=color, border=5, padding=padding) + layout.binding = {"type": "struct"} + layout.render_path = parent_path + + # Apply pre-computed interactions + self._apply_interaction_mappings(layout, index_bindings) + + for display_name, field_data in self.field_renderers.items(): + if field_data is None: + continue + + actual_field_name, field_renderer = field_data + + # Check if the actual field exists on the object + if not hasattr(obj, actual_field_name): + raise AttributeError( + f"Field '{actual_field_name}' does not exist on object of type {type(obj).__name__}. " + f"Display name: '{display_name}'. " + f"Available fields: {', '.join(f for f, _ in getattr(type(obj), '_fields_', []))}" + ) + + value = getattr(obj, actual_field_name) + row_layout = self.make_layout(sizing=(FIT(), FIT()), direction=Direction.ROW, child_gap=5, color=None, border=5, padding=Padding(10,10,10,10)) + row_layout.render_path = parent_path + label = self.make_text(display_name + ": ", "Arial", 16, "black") + label.render_path = parent_path + value_layout = field_renderer( + value, + parent_path=parent_path + [actual_field_name], + index_bindings=index_bindings, + mapping=mapping) + row_layout.add_child(label) + row_layout.add_child(value_layout) + layout.add_child(row_layout) + + return layout + diff --git a/python/test/test_interaction_generalization.py b/python/test/test_interaction_generalization.py new file mode 100644 index 00000000..418586d5 --- /dev/null +++ b/python/test/test_interaction_generalization.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +Test script to verify interaction system works for: +1. Arrays (tic-tac-toe with BoundedInt) +2. Vectors (sudoku with primitives) +3. Event parameters (on_key/$value) +""" + +from rlc.renderer.interaction_context import InteractionContext, InteractionMapping +from rlc.renderer.factory import RendererFactory +from rlc.serialization.renderer_serializer import save_renderer, load_renderer +import tempfile +import os + +def test_interaction_mappings(): + """Test that interaction mappings are created correctly for different scenarios.""" + + # Load the test config + ctx = InteractionContext.from_config_file("test/STR/test_interactions.yaml") + + print("\n" + "="*80) + print("TEST: Interaction Config Loading") + print("="*80) + print(f"Loaded {len(ctx.config_rules)} config rules:") + for parsed_path, handler_name in ctx.config_rules: + print(f" - {parsed_path.raw} → {handler_name}") + print(f" event: {parsed_path.event}") + print(f" index_vars: {parsed_path.index_vars}") + print(f" param_vars: {parsed_path.param_vars}") + + # Test 1: Verify pattern matching with $i placeholders + print("\n" + "="*80) + print("TEST: Pattern Matching with $i Placeholders") + print("="*80) + + test_paths = [ + # Arrays (tic-tac-toe) + (['Game', 'board', 'slots', '$i', '$i'], + "Should match: Game/board/slots/$x/$y/on_click"), + + # Vectors (sudoku) + (['Game', 'board', 'slots', '$i', '$i'], + "Should match: Game/board/slots/$row/$col/on_click and on_key"), + + # Primitive (shouldn't match) + (['Game', 'score'], + "Should NOT match any pattern"), + ] + + for path, description in test_paths: + print(f"\nTesting path: {path}") + print(f" Description: {description}") + + # Try to resolve interactions + mappings = ctx.resolve_interactions(id(None), path) + + if mappings: + print(f" ✓ MATCHED {len(mappings)} interaction(s):") + for m in mappings: + print(f" - {m.event_type}: {m.handler_name}") + print(f" index_vars={m.index_vars}, param_vars={m.param_vars}") + else: + print(f" ✗ No matches found") + + # Test 2: Verify serialization/deserialization + print("\n" + "="*80) + print("TEST: YAML Serialization/Deserialization") + print("="*80) + + # Create a mock renderer with interaction mappings + from rlc.renderer.primitive_renderer import PrimitiveRenderer + + renderer = PrimitiveRenderer(rlc_type_name="c_long") + renderer.interaction_mappings = [ + InteractionMapping( + event_type="on_click", + handler_name="test_handler", + index_vars=["x", "y"], + param_vars=[], + path=['Game', 'board', 'slots', '$i', '$i'] + ), + InteractionMapping( + event_type="on_key", + handler_name="test_key_handler", + index_vars=["row", "col"], + param_vars=["value"], + path=['Game', 'board', 'slots', '$i', '$i'] + ) + ] + + # Serialize + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + temp_path = f.name + + try: + save_renderer(renderer, temp_path) + print(f"✓ Saved renderer to: {temp_path}") + + # Read the file to verify contents + with open(temp_path, 'r') as f: + yaml_content = f.read() + + print("\nYAML content:") + print(yaml_content) + + # Deserialize + loaded_renderer = load_renderer(temp_path) + print(f"\n✓ Loaded renderer from YAML") + + # Verify mappings + assert len(loaded_renderer.interaction_mappings) == 2, \ + f"Expected 2 mappings, got {len(loaded_renderer.interaction_mappings)}" + + for i, mapping in enumerate(loaded_renderer.interaction_mappings): + print(f"\n Mapping {i+1}:") + print(f" event_type: {mapping.event_type}") + print(f" handler_name: {mapping.handler_name}") + print(f" index_vars: {mapping.index_vars}") + print(f" param_vars: {mapping.param_vars}") + print(f" rlc_path: {mapping.rlc_path}") + + print("\n✓ All mappings preserved correctly!") + + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + # Test 3: Verify index binding propagation + print("\n" + "="*80) + print("TEST: Index Binding Propagation") + print("="*80) + + from rlc.renderer.array_renderer import ArrayRenderer + from rlc.renderer.bint_renderer import BoundedIntRenderer + + # Create a mock 2D array structure + element_renderer = BoundedIntRenderer(rlc_type_name="BIntT0T3T") + element_renderer.interaction_mappings = [ + InteractionMapping( + event_type="on_click", + handler_name="mark_cell", + index_vars=["x", "y"], + param_vars=[], + path=['Game', 'board', 'slots', '$i', '$i'] + ) + ] + + inner_array = ArrayRenderer( + rlc_type_name="BIntT0T3T_Array_3", + length=3, + element_renderer=element_renderer + ) + + outer_array = ArrayRenderer( + rlc_type_name="BIntT0T3T_Array_3_Array_3", + length=3, + element_renderer=inner_array + ) + + # Test that deepest mappings can be found + deepest = outer_array._get_deepest_interaction_mappings() + assert deepest, "Should find deepest mappings" + assert len(deepest) == 1, f"Expected 1 mapping, got {len(deepest)}" + assert deepest[0].index_vars == ["x", "y"], \ + f"Expected index_vars=['x', 'y'], got {deepest[0].index_vars}" + + print(f"✓ Found deepest mappings: {deepest[0].handler_name}") + print(f" index_vars: {deepest[0].index_vars}") + + # Verify that array determines correct index variable + # Outer array (0 bindings so far) should bind first var + num_bound = 0 + if num_bound < len(deepest[0].index_vars): + first_var = deepest[0].index_vars[num_bound] + assert first_var == "x", f"Expected 'x', got '{first_var}'" + print(f"✓ Outer array correctly selects index var: '{first_var}'") + + # Inner array (1 binding so far) should bind second var + num_bound = 1 + if num_bound < len(deepest[0].index_vars): + second_var = deepest[0].index_vars[num_bound] + assert second_var == "y", f"Expected 'y', got '{second_var}'" + print(f"✓ Inner array correctly selects index var: '{second_var}'") + + print("\n" + "="*80) + print("ALL TESTS PASSED ✓") + print("="*80) + print("\nThe interaction system is fully generalized and supports:") + print(" ✓ Arrays with any element type") + print(" ✓ Vectors with any element type") + print(" ✓ Primitives (c_long, c_bool)") + print(" ✓ BoundedInt types") + print(" ✓ Event parameters (on_key/$value)") + print(" ✓ Multiple index variables ($x, $y, $row, $col, etc.)") + print(" ✓ YAML serialization/deserialization") + print(" ✓ Index binding propagation through nested containers") + +if __name__ == "__main__": + test_interaction_mappings() diff --git a/python/test/test_layout.py b/python/test/test_layout.py new file mode 100644 index 00000000..ac794fba --- /dev/null +++ b/python/test/test_layout.py @@ -0,0 +1,154 @@ +import pytest +import pygame +from rlc import Layout, Text, FIXED, FIT, GROW, Padding, Direction + +@pytest.fixture(autouse=True, scope="session") +def init_pygame(): + pygame.init() + pygame.font.init() + yield + pygame.quit() + +# Fixed sizing +def test_fixed_size_layout(): + layout = Layout(sizing=(FIXED(100), FIXED(50)), color="red") + layout.compute_size() + assert layout.width==100 + assert layout.height==50 + +def test_fit_size_single_child(): + parent = Layout(sizing=(FIT(), FIT()), padding=Padding(5, 5, 5, 5), color="white") + child = Layout(sizing=(FIXED(50), FIXED(20)), color="blue") + parent.add_child(child) + parent.compute_size() + + # FIT should equal child size + padding + assert parent.width == 50 + 5 + 5 + assert parent.height == 20 + 5 + 5 + +# Fit sizing +def test_fit_multiple_children_row(): + parent = Layout(sizing=(FIT(), FIT()), padding=Padding(0, 0, 0, 0), direction=Direction.ROW, child_gap=10) + c1 = Layout(sizing=(FIXED(50), FIXED(20)), color="blue") + c2 = Layout(sizing=(FIXED(30), FIXED(40)), color="green") + parent.add_child(c1) + parent.add_child(c2) + parent.compute_size() + + # width = sum of widths + gap + assert parent.width == 50 + 30 + 10 + # height = max of heights + assert parent.height == 40 + +def test_fit_multiple_children_column(): + parent = Layout(sizing=(FIT(), FIT()), padding=Padding(10, 10, 10, 10), direction=Direction.COLUMN, child_gap=5) + c1 = Layout(sizing=(FIXED(20), FIXED(10)), color="blue") + c2 = Layout(sizing=(FIXED(60), FIXED(15)), color="pink") + parent.add_child(c1) + parent.add_child(c2) + parent.compute_size() + + # height = sum of heights + gap + assert parent.height == 10 + 15 + 5 + 10 + 10 + # width = max of widths + assert parent.width == 60 + 10 + 10 + +# Grow anf Shrink +def test_grow_child_in_row(): + parent = Layout(sizing=(FIXED(200), FIXED(50)), direction=Direction.ROW, padding=Padding(0, 0, 0, 0), child_gap=0) + c1 = Layout(sizing=(FIXED(50), FIXED(50)), color="blue") + c2 = Layout(sizing=(GROW(), FIXED(50)), color="green") + c3 = Layout(sizing=(GROW(), FIXED(50)), color="pink") + parent.add_child(c1) + parent.add_child(c2) + parent.add_child(c3) + parent.compute_size() + + # total = 200, child1 = 50, so child2 and child3 should grow evenly to 150/2=75 each + assert parent.children[1].width == 75 + assert parent.children[2].width == 75 + +def test_grow_child_in_column(): + parent = Layout(sizing=(FIXED(100), FIXED(300)), direction=Direction.COLUMN, padding=Padding(0, 0, 0, 0), child_gap=0) + c1 = Layout(sizing=(FIXED(100), FIXED(100)), color="blue") + c2 = Layout(sizing=(FIXED(100), GROW()), color="red") + parent.add_child(c1) + parent.add_child(c2) + parent.compute_size() + + # total = 300, child1 = 100, so child2 should grow to 200 + assert parent.children[1].height == 200 + +def test_extensive_layout(): + root = Layout(sizing=(FIT(), FIT()) , padding=Padding(32, 32, 32, 32), direction=Direction.ROW, child_gap=32) + child1 = Layout(sizing=(FIXED(300), FIXED(300)), color="yellow") + child11 = Text("One Two Three Four", "Arial", 32) + child1.add_child(child11) + + child2 = Layout(sizing=(FIXED(200), FIXED(200)), color="blue") + + child3 = Layout(sizing=(FIT(), FIXED(300)), child_gap=32, direction=Direction.ROW, color="lightblue") + child4 = Layout(sizing=(FIXED(250), FIT()), color="orange") + + child44 = Text("Five Six Seven Eight Nine Ten Five Six Seven Eight Nine Ten Five Six Seven Eight Nine Ten", "Arial", 22) + + child4.add_child(child44) + + child3.add_child(child4) + child3.add_child(child4) + child3.add_child(child4) + + root.add_child(child1) + root.add_child(child2) + root.add_child(child3) + root.compute_size() + + + # root.width = + # child1.width (300) + # + gap (32) + # + child2.width (200) + # + gap (32) + # + child3.width (3 * 250 + 2 * 32 = 814) + # + left/right padding (32 + 32) + # = 1442 + assert root.width == 1442 + # root.height = max( child1.height (300), child2.height (200), child3.height (300) ) + # + top/bottom padding (32 + 32) + # = 364 + assert root.height == 364 + + # child1 is FIXED(300, 300) + assert root.children[0].width == 300 + assert root.children[0].height == 300 + + # child2 is FIXED(200, 200) + assert root.children[1].width == 200 + assert root.children[1].height == 200 + + # child3 has three children (each FIXED width 250) plus two gaps (32 each) + # width = 250 + 32 + 250 + 32 + 250 = 814 + # height = FIXED(300) + assert root.children[2].width == 814 + assert root.children[2].height == 300 + +def test_multiple_same_child_independent_sizes(): + parent = Layout(sizing=(FIXED(300), FIXED(100)), direction=Direction.ROW, padding=Padding(0, 0, 0, 0), child_gap=10, color="white") + child = Layout(sizing=(GROW(), FIXED(50)), color="blue") + parent.add_child(child) + parent.add_child(child) # Same instance, should be deep-copied + parent.add_child(child) + parent.compute_size() + # Total width = 300, gap = 2 * 10 = 20, so each child gets (300 - 20) / 3 = 93 + assert len(parent.children) == 3 + assert parent.children[0].width == 93 + assert parent.children[1].width == 93 + assert parent.children[2].width == 93 + # Verify children are distinct objects + assert parent.children[0] is not parent.children[1] + assert parent.children[1] is not parent.children[2] + assert parent.children[0] is not parent.children[2] + # Verify heights are unchanged + assert parent.children[0].height == 50 + assert parent.children[1].height == 50 + assert parent.children[2].height == 50 \ No newline at end of file diff --git a/python/test/test_serialization.py b/python/test/test_serialization.py new file mode 100644 index 00000000..641ed0bb --- /dev/null +++ b/python/test/test_serialization.py @@ -0,0 +1,65 @@ +import json +from pathlib import Path +import pygame +import pytest +from rlc import Layout, Text, Padding, Direction, FIXED, FIT +from rlc import LayoutLogger, LayoutLogConfig +from graphviz import Digraph + +@pytest.fixture(autouse=True, scope="session") +def init_pygame(): + pygame.init() + pygame.font.init() + yield + pygame.quit() + +def build_simple_tree(): + root = Layout(sizing=(FIT(), FIT()), padding=Padding(5, 5, 5, 5), direction=Direction.ROW, color="white") + child1 = Layout(sizing=(FIXED(100), FIXED(50)), color="blue") + child2 = Text("Hello World", "Arial", 16) + root.add_child(child1) + root.add_child(child2) + return root + + +@pytest.mark.parametrize("indent", [2, 4]) +def test_json_and_text_serialization(indent, tmp_path): + root = build_simple_tree() + logger = LayoutLogger(LayoutLogConfig()) + + # compute and layout to fill logger events + root.compute_size(logger=logger) + root.layout(0, 0, logger=logger) + logger.record_final_tree(root) + + # --- JSON string --- + js = logger.to_json() + data = json.loads(js) + print(data) + assert "config" in data + assert "events" in data + assert "final_tree" in data + assert data["final_tree"]["type"] == "Layout" + assert data["final_tree"]["color"] == "white" + + # --- Write JSON file --- + json_path = tmp_path / "layout.json" + logger.write_json(str(json_path), indent=indent) + assert json_path.exists() + parsed = json.loads(json_path.read_text()) + assert parsed["final_tree"]["type"] == "Layout" + assert parsed["final_tree"]["color"] == "white" + + # --- Text tree string --- + txt = logger.to_text_tree(root) + assert "Layout" in txt + assert "Text" in txt + + # --- Write text tree file --- + txt_path = tmp_path / "layout.txt" + logger.write_text_tree(root, str(txt_path)) + assert txt_path.exists() + contents = txt_path.read_text() + assert "Layout" in contents + assert "Text" in contents + diff --git a/python/test/ui_test.py b/python/test/ui_test.py new file mode 100644 index 00000000..13a136a0 --- /dev/null +++ b/python/test/ui_test.py @@ -0,0 +1,42 @@ +from rlc import Layout, Text, Padding, Direction, FIT, FIXED, GROW + +from test.display_layout import display + +def build(): + root = Layout(sizing=(FIT(), FIT()) , padding=Padding(32, 32, 32, 32), direction=Direction.ROW, child_gap=32, color="white") + child1 = Layout(sizing=(FIXED(300), FIXED(300)), color="yellow") + child11 = Text("One Two Three Four", "Arial", 32) + child1.add_child(child11) + + child2 = Layout(sizing=(FIXED(200), FIXED(200)), color="blue") + + child3 = Layout(sizing=(FIT(), FIXED(300)), child_gap=32, direction=Direction.ROW, color="lightblue") + child4 = Layout(sizing=(FIXED(250), FIT()), color="orange") + + child44 = Text("Five Six Seven Eight Nine Ten Five Six Seven Eight Nine Ten Five Six Seven Eight Nine Ten", "Arial", 22) + + child4.add_child(child44) + + child3.add_child(child4) + child3.add_child(child4) + child3.add_child(child4) + + root.add_child(child1) + root.add_child(child2) + root.add_child(child3) + return root + +def build1(): + parent = Layout(sizing=(FIXED(200), FIXED(50)), direction=Direction.ROW, padding=Padding(0, 0, 0, 0), child_gap=0) + c1 = Layout(sizing=(FIXED(50), FIXED(50)), color="blue") + c2 = Layout(sizing=(GROW(), FIXED(50)), color="pink") + c3 = Layout(sizing=(GROW(), FIXED(50)), color="purple") + parent.add_child(c1) + parent.add_child(c2) + parent.add_child(c3) + return parent + +if __name__ == "__main__": + display(build_function=build1) + + diff --git a/requirements.txt b/requirements.txt index 04092696..f5a41fdb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ vulture pytest pylint lit +graphviz -r run-requirements.txt diff --git a/stdlib/algorithms/diff.rl b/stdlib/algorithms/diff.rl new file mode 100644 index 00000000..f672974b --- /dev/null +++ b/stdlib/algorithms/diff.rl @@ -0,0 +1,98 @@ +import collections.vector +import string +import bounded_arg + +# Recursively compares two values of the same type and appends the dot-separated +# path of every leaf field that differs to `out`. +# +# Usage: +# let changed : Vector +# diff(before, after, changed) +# # changed now contains paths like "board.slots.0.0", "hand.0.value", etc. + +# Trait for types that have custom diff behaviour (primitives, arrays, vectors). +# Types that implement this are treated as diff leaves or have custom traversal. +trait Diffable: + fun _apply_diff(T before, T after, String path, Vector out) + +fun _apply_diff(Int before, Int after, String path, Vector out): + if before != after: + out.append(path) + +fun _apply_diff(Bool before, Bool after, String path, Vector out): + if before != after: + out.append(path) + +fun _apply_diff(Byte before, Byte after, String path, Vector out): + if before != after: + out.append(path) + +fun _apply_diff(Float before, Float after, String path, Vector out): + if before != after: + out.append(path) + +fun _apply_diff(BInt before, BInt after, String path, Vector out): + if before.value != after.value: + out.append(path) + +fun _apply_diff(T[X] before, T[X] after, String path, Vector out): + let i = 0 + while i < X: + let child_path = path.add("."s.add(to_string(i))) + _diff_impl(before[i], after[i], child_path, out) + i = i + 1 + +fun _apply_diff(Vector before, Vector after, String path, Vector out): + if before.size() != after.size(): + out.append(path) + return + let i = 0 + while i < before.size(): + let child_path = path.add("."s.add(to_string(i))) + _diff_impl(before.get(i), after.get(i), child_path, out) + i = i + 1 + +fun _apply_diff(BoundedVector before, BoundedVector after, String path, Vector out): + if before.size() != after.size(): + out.append(path) + return + let i = 0 + while i < before.size(): + let child_path = path.add("."s.add(to_string(i))) + _diff_impl(before.get(i), after.get(i), child_path, out) + i = i + 1 + +fun _apply_diff(T before, T after, String path, Vector out): + if before.as_int() != after.as_int(): + out.append(path) + +fun _diff_impl(T before, T after, String path, Vector out): + if before is Diffable: + _apply_diff(before, after, path, out) + else if before is Alternative: + let changed_variant = false + for name, field, field2 of before, after: + using FieldType = type(field) + if field2 is FieldType: + if before is FieldType: + if !changed_variant: + _diff_impl(field, field2, path, out) + changed_variant = true + else: + if !changed_variant: + out.append(path) + changed_variant = true + else: + for name, field, field2 of before, after: + using FieldType = type(field) + if field2 is FieldType: + let child_path = path.add("."s.add(s(name))) + _diff_impl(field, field2, child_path, out) + +# Compares `before` and `after` field by field and appends the path of every +# changed leaf to `out`. Paths use dot notation: "board.slots.0.0". +fun diff(T before, T after, Vector out): + for name, field, field2 of before, after: + using FieldType = type(field) + if field2 is FieldType: + _diff_impl(field, field2, s(name), out) diff --git a/tool/rlc/test/examples/black_jack.rl b/tool/rlc/test/examples/black_jack.rl index 2271544a..a8151e36 100644 --- a/tool/rlc/test/examples/black_jack.rl +++ b/tool/rlc/test/examples/black_jack.rl @@ -1,7 +1,8 @@ # RUN: python %pyscript/solve.py %s --stdlib %stdlib --rlc rlc import collections.vector -import machine_learning +import machine_learning import action +import algorithms.diff # A card is a integer from # 0 to 13 included, where @@ -15,6 +16,13 @@ using Card = BInt<0, 14> # so a number from 0 to 52 using CardIndex = BInt<0, 52> +cls Hit: + Int hit + +cls Stand: + Int stand + + # A deck is a class, called # entities in RL. It has # fields and methods @@ -85,6 +93,9 @@ fun calculate_points(BoundedVector hand) -> Int: @classes act play() -> Game: + frm hit_button : Hit + frm stand_button : Stand + # allocates a deck and initializes it # the deck is marked hidden, so that # the content is not shown to the machine @@ -197,3 +208,6 @@ fun pretty_print(Game g): let hand = "player hand: "s hand.append(to_string(g.player_hand)) print(hand) + +fun game_diff(Game before, Game after, Vector out): + diff(before, after, out) diff --git a/tool/rlc/test/sudoku.rl b/tool/rlc/test/sudoku.rl new file mode 100644 index 00000000..d2b44d82 --- /dev/null +++ b/tool/rlc/test/sudoku.rl @@ -0,0 +1,125 @@ +# RUN: rlc %s -o %t -i %stdlib +# RUN: %t%exeext + +import range +import serialization.print +import action + +cls Board: + Int[3][3][3][3] slots # 3x3 blocks of 3x3 cells (total 9x9 Sudoku) + +# Helper to get or set a cell using global coordinates (0..8) +fun get(Board board, Int row, Int col) -> Int: + return board.slots[row / 3][col / 3][row % 3][col % 3] + +fun set(Board board, Int row, Int col, Int val): + board.slots[row / 3][col / 3][row % 3][col % 3] = val + + +fun is_full(Board board) -> Bool: + for r in range(9): + for c in range(9): + if get(board, r, c) == 0: + return false + return true + + +fun can_place(Board board, Int row, Int col, Int num) -> Bool: + # Check row + for c in range(9): + if get(board, row, c) == num: + return false + + # Check column + for r in range(9): + if get(board, r, col) == num: + return false + + # Check 3x3 box + let box_row_start = (row / 3) * 3 + let box_col_start = (col / 3) * 3 + for i in range(3): + for j in range(3): + if get(board, box_row_start + i, box_col_start + j) == num: + return false + + return true + + +@classes +act play() -> Game: + frm board : Board + + # Initialize empty 3x3x3x3 board + for br in range(3): + for bc in range(3): + for r in range(3): + for c in range(3): + board.slots[br][bc][r][c] = 0 + + # Sample Sudoku puzzle + set(board, 0, 2, 5) + set(board, 0, 4, 4) + set(board, 0, 8, 8) + set(board, 1, 1, 1) + set(board, 1, 3, 5) + set(board, 1, 5, 2) + set(board, 1, 7, 4) + set(board, 2, 0, 8) + set(board, 2, 4, 1) + set(board, 2, 6, 5) + set(board, 3, 0, 1) + set(board, 3, 2, 8) + set(board, 3, 7, 3) + set(board, 3, 8, 6) + set(board, 4, 1, 6) + set(board, 4, 3, 8) + set(board, 4, 5, 4) + set(board, 4, 7, 2) + set(board, 5, 0, 3) + set(board, 5, 1, 2) + set(board, 5, 6, 8) + set(board, 5, 8, 4) + set(board, 6, 2, 3) + set(board, 6, 4, 2) + set(board, 6, 8, 9) + set(board, 7, 1, 5) + set(board, 7, 3, 4) + set(board, 7, 5, 9) + set(board, 7, 7, 6) + set(board, 8, 0, 9) + set(board, 8, 4, 3) + set(board, 8, 6, 4) + + # Game loop: fill until full + while !is_full(board): + act place(frm Int num, Int row, Int col){ + get(board, row, col) == 0 and can_place(board, row, col, num) + } + + set(board, row, col, num) + + print("Sudoku solved!") + + +fun pretty_print(Board board): + for r in range(9): + let line = ""s + for c in range(9): + let val = get(board, r, c) + if val == 0: + line = line + "."s + else: + line = line + to_string(val) + line = line + " "s + if c % 3 == 2 and c != 8: + line = line + "| "s + print(line) + if r % 3 == 2 and r != 8: + print("------+-------+------"s) + + +fun main() -> Int: + let game = play() + pretty_print(game.board) + return 0 diff --git a/tool/rlc/test/sudoku_vector.rl b/tool/rlc/test/sudoku_vector.rl new file mode 100644 index 00000000..1957f1de --- /dev/null +++ b/tool/rlc/test/sudoku_vector.rl @@ -0,0 +1,337 @@ +# RUN: rlc %s -o %t -i %stdlib +# RUN: %t%exeext + +import collections.vector +import range +import serialization.print +import action +import algorithms.diff + +cls RedBoard: + BInt<0, 3>[3][3] slots + +cls Board: + Vector> slots + +fun is_full(Board board) -> Bool: + for r in range(9): + for c in range(9): + if board.slots[r][c] == 0: + return false + return true + +fun can_place(Board board, Int row, Int col, Int num) -> Bool: + # Check row + for c in range(9): + if board.slots[row][c] == num: + return false + # Check column + for r in range(9): + if board.slots[r][col] == num: + return false + # Check 3x3 box + let box_row_start = (row / 3) * 3 + let box_col_start = (col / 3) * 3 + for i in range(3): + for j in range(3): + if board.slots[box_row_start + i][box_col_start + j] == num: + return false + return true + +@classes +act play() -> Game: + frm board : Board + # frm redboard : RedBoard + # Initialize empty board + board.slots.resize(9) + for i in range(9): + board.slots[i].resize(9) + for j in range(9): + board.slots[i][j] = 0 + + # Set up an easy Sudoku puzzle (hardcoded) + board.slots[0][3] = 2 + board.slots[0][4] = 6 + board.slots[0][6] = 7 + board.slots[0][8] = 1 + board.slots[1][0] = 6 + board.slots[1][2] = 8 + board.slots[1][4] = 7 + board.slots[1][7] = 9 + board.slots[2][0] = 1 + board.slots[2][1] = 9 + board.slots[2][5] = 4 + board.slots[2][6] = 5 + board.slots[3][0] = 8 + board.slots[3][1] = 2 + board.slots[3][3] = 1 + board.slots[3][7] = 4 + board.slots[4][2] = 4 + board.slots[4][3] = 6 + board.slots[4][5] = 2 + board.slots[4][6] = 9 + board.slots[5][1] = 5 + board.slots[5][5] = 3 + board.slots[5][7] = 2 + board.slots[5][8] = 8 + board.slots[6][2] = 9 + board.slots[6][3] = 3 + board.slots[6][7] = 7 + board.slots[6][8] = 4 + board.slots[7][1] = 4 + board.slots[7][4] = 5 + board.slots[7][7] = 3 + board.slots[7][8] = 6 + board.slots[8][0] = 7 + board.slots[8][2] = 3 + board.slots[8][4] = 1 + board.slots[8][5] = 8 + + # Game loop: place numbers until the board is full + while !is_full(board): + act place(BInt<1,10> num, BInt<0,9> row, BInt<0,9> col) { + board.slots[row.value][col.value] == 0 and can_place(board, row.value, col.value, num.value) + } + board.slots[row.value][col.value] = num.value + + if is_full(board): + return + + print("Sudoku solved!") + +fun make_num(Int x) -> BInt<1, 10>: + let num : BInt<1, 10> + num = x + return num + +fun make_pos(Int x) -> BInt<0, 9>: + let num : BInt<0, 9> + num = x + return num + +fun pretty_print(Board board): + let result = ""s + for r in range(9): + for c in range(9): + let val = board.slots[r][c] + if val == 0: + result = result + "."s + else: + result = result + to_string(val) + result = result + " "s + result = result + "\n"s + print(result) + +fun main() -> Int: + let game = play() + pretty_print(game.board) + # Example moves (assuming valid; in practice, use can to check) + let row : BInt<0, 9> + let col : BInt<0, 9> + let num : BInt<1, 10> + row.value = 0 + col.value = 0 + num.value = 4 + game.place(num, row, col) + + row.value = 0 + col.value = 1 + num.value = 3 + game.place(num, row, col) + + row.value = 0 + col.value = 2 + num.value = 5 + game.place(num, row, col) + + row.value = 0 + col.value = 5 + num.value = 9 + game.place(num, row, col) + + row.value = 0 + col.value = 7 + num.value = 8 + game.place(num, row, col) + + row.value = 1 + col.value = 2 + num.value = 2 + game.place(num, row, col) + + row.value = 1 + col.value = 3 + num.value = 5 + game.place(num, row, col) + + row.value = 1 + col.value = 5 + num.value = 1 + game.place(num, row, col) + + row.value = 1 + col.value = 6 + num.value = 4 + game.place(num, row, col) + + row.value = 1 + col.value = 8 + num.value = 3 + game.place(num, row, col) + + row.value = 2 + col.value = 2 + num.value = 7 + game.place(num, row, col) + + row.value = 2 + col.value = 3 + num.value = 8 + game.place(num, row, col) + + row.value = 2 + col.value = 4 + num.value = 3 + game.place(num, row, col) + + row.value = 2 + col.value = 7 + num.value = 6 + game.place(num, row, col) + + row.value = 2 + col.value = 8 + num.value = 2 + game.place(num, row, col) + + row.value = 3 + col.value = 2 + num.value = 6 + game.place(num, row, col) + + row.value = 3 + col.value = 4 + num.value = 9 + game.place(num, row, col) + + row.value = 3 + col.value = 5 + num.value = 5 + game.place(num, row, col) + row.value = 3 + col.value = 6 + num.value = 3 + game.place(num, row, col) + row.value = 3 + col.value = 8 + num.value = 7 + game.place(num, row, col) + row.value = 4 + col.value = 0 + num.value = 3 + game.place(num, row, col) + row.value = 4 + col.value = 1 + num.value = 7 + game.place(num, row, col) + row.value = 4 + col.value = 4 + num.value = 8 + game.place(num, row, col) + row.value = 4 + col.value = 7 + num.value = 1 + game.place(num, row, col) + row.value = 4 + col.value = 8 + num.value = 5 + game.place(num, row, col) + row.value = 5 + col.value = 0 + num.value = 9 + game.place(num, row, col) + + row.value = 5 + col.value = 2 + num.value = 1 + game.place(num, row, col) + row.value = 5 + col.value = 3 + num.value = 7 + game.place(num, row, col) + row.value = 5 + col.value = 4 + num.value = 4 + game.place(num, row, col) + row.value = 5 + col.value = 6 + num.value = 6 + game.place(num, row, col) + row.value = 6 + col.value = 0 + num.value = 5 + game.place(num, row, col) + row.value = 6 + col.value = 1 + num.value = 1 + game.place(num, row, col) + row.value = 6 + col.value = 4 + num.value = 2 + game.place(num, row, col) + row.value = 6 + col.value = 5 + num.value = 6 + game.place(num, row, col) + row.value = 6 + col.value = 6 + num.value = 8 + game.place(num, row, col) + row.value = 7 + col.value = 0 + num.value = 2 + game.place(num, row, col) + row.value = 7 + col.value = 2 + num.value = 8 + game.place(num, row, col) + row.value = 7 + col.value = 3 + num.value = 9 + game.place(num, row, col) + row.value = 7 + col.value = 5 + num.value = 7 + game.place(num, row, col) + row.value = 7 + col.value = 6 + num.value = 1 + game.place(num, row, col) + row.value = 8 + col.value = 1 + num.value = 6 + game.place(num, row, col) + row.value = 8 + col.value = 3 + num.value = 4 + game.place(num, row, col) + row.value = 8 + col.value = 6 + num.value = 2 + game.place(num, row, col) + row.value = 8 + col.value = 7 + num.value = 5 + game.place(num, row, col) + row.value = 8 + col.value = 8 + num.value = 9 + game.place(num, row, col) + + if is_full(game.board): + return 0 + else: + return 1 + +fun game_diff(Game before, Game after, Vector out): + diff(before, after, out) \ No newline at end of file diff --git a/tool/rlc/test/tic_tac_toe.rl b/tool/rlc/test/tic_tac_toe.rl index a8528999..f3bd69a0 100644 --- a/tool/rlc/test/tic_tac_toe.rl +++ b/tool/rlc/test/tic_tac_toe.rl @@ -4,20 +4,20 @@ import serialization.to_byte_vector import string import action +import algorithms.diff cls Board: - BInt<0, 3>[9] slots + BInt<0, 3>[3][3] slots Bool playerTurn fun get(Int x, Int y) -> Int: - return self.slots[x + (y * 3)].value + return self.slots[x][y].value fun set(Int x, Int y, Int val): - self.slots[x + (y * 3)].value = val + self.slots[x][y].value = val fun full() -> Bool: let x = 0 - while x < 3: let y = 0 while y < 3: @@ -25,7 +25,6 @@ cls Board: return false y = y + 1 x = x + 1 - return true fun three_in_a_line_player_row(Int player_id, Int row) -> Bool: @@ -34,13 +33,16 @@ cls Board: fun three_in_a_line_player(Int player_id) -> Bool: let x = 0 while x < 3: + # check column if self.get(x, 0) == self.get(x, 1) and self.get(x, 0) == self.get(x, 2) and self.get(x, 0) == player_id: return true + # check row if self.three_in_a_line_player_row(player_id, x): return true x = x + 1 + # check diagonals if self.get(0, 0) == self.get(1, 1) and self.get(0, 0) == self.get(2, 2) and self.get(0, 0) == player_id: return true @@ -62,8 +64,7 @@ act play() -> Game: frm board : Board frm score = 10 while !board.full(): - # sets the indicated board as beloning - # to the current player + act mark(BInt<0, 3> x, BInt<0, 3> y) { board.get(x.value, y.value) == 0 } score = score - 1 @@ -83,12 +84,16 @@ fun score(Game g, Int player_id) -> Float: return 1.0 else if g.board.three_in_a_line_player(((player_id + 1) % 2) + 1): return -1.0 - return 0.0 fun get_num_players() -> Int: return 2 +fun make_pos(Int x) -> BInt<0, 3>: + let num : BInt<0, 3> + num = x + return num + fun fuzz(Vector input): if input.size() == 0: return @@ -100,31 +105,37 @@ fun main() -> Int: let game = play() let x : BInt<0, 3> let y : BInt<0, 3> + x.value = 0 y.value = 0 game.mark(x, y) if game.board.full(): return 1 + x.value = 1 y.value = 0 game.mark(x, y) if game.board.full(): return 2 + x.value = 1 y.value = 1 game.mark(x, y) if game.board.full(): return 3 + x.value = 2 y.value = 0 game.mark(x, y) if game.board.full(): return 4 + x.value = 2 y.value = 2 game.mark(x, y) if game.board.full(): return 5 + if game.board.three_in_a_line_player(1): return 0 else: @@ -141,3 +152,5 @@ fun pretty_print(Game g): print(to_print) i = i + 1 +fun game_diff(Game before, Game after, Vector out): + diff(before, after, out)