"""Ren'Py screen language → PGVN screen IR. Only the part games use for their own interface is translated (HUDs, hotspot menus, phones, inventories). Whatever is not understood is reported through ``warn`` so a game never imports with a half-working screen. A screen is ``{"params": [...], "props": {...}, "defaults": [[name, expr]], "body": [node]}``. Nodes: * widget ``{"w": kind, "args": [expr], "props": {name: expr}, "body": [node]}`` * ``{"c": "if", "branches": [{"c": expr | None, "body": [...]}]}`` * ``{"c": "for", "t": target, "it": expr, "body": [...]}`` * ``{"c": "use", "name": str, "args": [expr], "kw": {}, "body": [...]}`` (body = transcluded children) * ``{"c": "transclude"}``, ``{"c": "python", "code": [stmt]}`` * ``{"c": "key", "key": expr, "action": expr}``, ``{"c": "timer", ...}``, ``{"c": "on", "event": str, ...}`` """ import ast import re from collections.abc import Callable from typing import Any from app.converter import pyexpr from app.converter.atl import WARPER from app.converter.exprtokens import read_value from app.converter.pyexpr import Unsupported from app.converter.rpy_lexer import Line Node = dict[str, Any] # Calls whose first argument names a screen to open OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call_screen", "hide_screen"} CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button"} LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar"} POSITIONAL = {"text": 1, "label": 1, "textbutton": 1, "add": 1, "image": 1, "grid": 2} ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"} LAYOUT = set("""xpos ypos pos xanchor yanchor anchor xalign yalign align xcenter ycenter xoffset yoffset xsize ysize xysize xminimum yminimum xmaximum ymaximum minimum maximum xfill yfill area spacing xspacing yspacing padding xpadding ypadding left_padding right_padding top_padding bottom_padding margin xmargin ymargin left_margin right_margin top_margin bottom_margin width height""".split()) LOOK = set("""background foreground idle hover insensitive selected selected_idle selected_hover selected_insensitive auto ground left_bar right_bar top_bar bottom_bar thumb alpha zoom rotate xzoom yzoom clipping fit_first xfit yfit box_reverse box_wrap order_reverse transpose rows cols crop size_group xtile ytile matrixcolor blur""".split()) TEXT = set("""color size font bold italic underline strikethrough outlines text_align textalign line_spacing kerning layout first_indent min_width antialias xalign yalign justify hyperlink_functions slow_cps""".split()) BEHAVIOUR = set("""action alternate hovered unhovered sensitive tooltip focus_mask mouse hover_sound activate_sound focus keysym alt id modal zorder tag layer variant style style_prefix at value range adjustment changed mousewheel draggable scrollbars xinitial yinitial child_size edgescroll arrowkeys text_style properties substitute prefix suffix repeat""".split()) # Scrolling widgets share state through adjustments; scrollbars are cosmetic in the browser IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter", "right_gutter", "top_gutter", "bottom_gutter") IGNORED = {"xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll", "draggable", "child_size", "focus", "mouse", "alt", "properties", "substitute", "prefix", "suffix", "keysym"} STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover", "selected_insensitive") ALL = LAYOUT | LOOK | TEXT | BEHAVIOUR SCREEN_PROPS = {"zorder", "modal", "tag", "variant", "style_prefix", "layer", "sensitive", "style"} DESKTOP_VARIANTS = {"pc", "large", "web", "medium", "small"} GLUED = re.compile(r"^([a-z_][a-z_0-9]*)(\(.*\)|\[.*\])$", re.S) def known_property(name: str) -> bool: if name in ALL or name in IGNORED or name.startswith(IGNORED_PREFIXES): return True if name.startswith("text_") and name[5:] in TEXT | LAYOUT: return True for prefix in STATE_PREFIXES: if name.startswith(prefix + "_") and known_property(name[len(prefix) + 1:]): return True return False def ignorable(name: str) -> bool: return name in IGNORED or name.startswith(IGNORED_PREFIXES) def tokens(text: str) -> list[str]: """Split on whitespace outside brackets and quotes (Ren'Py "simple expressions").""" out, cur, depth, quote, escaped = [], [], 0, "", False for ch in text: if quote: cur.append(ch) if escaped: escaped = False elif ch == "\\": escaped = True elif ch == quote: quote = "" elif ch in "\"'": quote = ch cur.append(ch) elif ch in "([{": depth += 1 cur.append(ch) elif ch in ")]}": depth -= 1 cur.append(ch) elif ch.isspace() and depth == 0: if cur: out.append("".join(cur)) cur = [] else: cur.append(ch) if cur: out.append("".join(cur)) return out class ScreenParser: def __init__(self, warn: Callable[[str, Line], None], transform_ref: Callable[[str, Line], dict], atl: Callable[[list[Line]], tuple[dict, dict | None]] | None = None): self.warn = warn self.transform_ref = transform_ref self.atl = atl self.defaults: list[list[Any]] = [] def expr(self, source: str, line: Line) -> dict | None: try: return pyexpr.parse_expr(source) except Unsupported as exc: self.warn(f"Выражение в screen: {exc}", line) return None # --- definition ----------------------------------------------------------------- def screen(self, line: Line) -> tuple[str, dict] | None: header = line.text.strip().rstrip(":").strip() match = re.fullmatch(r"screen\s+([A-Za-z_]\w*)\s*(\((.*)\))?", header, re.S) if not match: self.warn("Не разобран заголовок screen", line) return None name = match.group(1) try: params = pyexpr.params(ast.parse(f"def f({match.group(3) or ''}): pass").body[0].args) except (SyntaxError, Unsupported): self.warn(f"Не разобраны параметры screen {name}", line) return None self.defaults = [] props, body = self.body(line.block, top=True) props.pop("__atl__", None) props.pop("__if__", None) if "variant" in props: variants = props["variant"] text = variants["v"] if variants.get("k") == "const" else "" if not any(v in DESKTOP_VARIANTS for v in re.findall(r"[a-z]+", str(text))): return None # touch/phone layouts never apply to the desktop browser player props.pop("variant") return name, {"params": params, "props": props, "defaults": self.defaults, "body": body} # --- statements ----------------------------------------------------------------- def is_atl(self, words: list[str]) -> bool: first = words[0] if first == "on": return len(words) > 1 and not words[1].startswith(("'", '"')) return first in ATL_WORDS or bool(WARPER.match(first)) def body(self, lines: list[Line], top: bool = False) -> tuple[dict[str, Any], list[Node]]: """Returns (properties declared in the block, child nodes).""" props: dict[str, Any] = {} nodes: list[Node] = [] atl_lines: list[Line] = [] conditional: list[list[dict]] = [] i = 0 while i < len(lines): line = lines[i] text = line.text.rstrip(":").strip() if line.block else line.text.strip() words = tokens(re.sub(r"^(if|elif|showif)\(", r"\1 (", text)) i += 1 if not words: continue first = words[0] if first in ("if", "showif"): branches = [self.branch(first, words, line)] while i < len(lines): nxt = tokens(re.sub(r"^(elif|else)\(", r"\1 (", lines[i].text.rstrip(":").strip())) if not nxt or nxt[0] not in ("elif", "else"): break branches.append(self.branch(nxt[0], nxt, lines[i])) i += 1 branches = [b for b in branches if b] if not top and branches and not any(b["body"] for b in branches) and any(b["props"] for b in branches): # `if` inside a widget that only sets properties (idle "a" / idle "b") conditional.append([{"c": b["c"], "props": b["props"]} for b in branches]) else: nodes.append({"c": "if", "branches": [{"c": b["c"], "body": b["body"]} for b in branches]}) elif first in ("elif", "else"): self.warn(f"Не поддерживается в screen: {first} без if", line) elif first == "for": node = self.for_loop(line) if node: nodes.append(node) elif first == "use": node = self.use(line, words) if node: nodes.append(node) elif first == "transclude": nodes.append({"c": "transclude"}) elif first == "has": # `has vbox`: the rest of this block goes inside a new container container = self.widget(line, words[1:]) if container: _, rest = self.body(lines[i:]) container["body"].extend(rest) nodes.append(container) i = len(lines) elif first == "python" and line.text.strip() == "python:" or first == "$": nodes.extend(self.python(line, first)) elif first == "default": self.default(line) elif first == "key": node = self.key(line, words) if node: nodes.append(node) elif first == "timer": node = self.timer(line, words) if node: nodes.append(node) elif first == "on" and not self.is_atl(words): node = self.on(line, words) if node: nodes.append(node) elif self.is_atl(words) and not top: atl_lines.append(line) elif first in CONTAINERS or first in LEAVES: node = self.widget(line, words) if node: nodes.append(node) elif (top and first in SCREEN_PROPS) or known_property(GLUED.sub(r"\1", first)): props.update(self.properties(words, line)) else: self.warn(f"Не поддерживается в screen: {first}", line) if atl_lines: props["__atl__"] = atl_lines if conditional: props["__if__"] = conditional return props, nodes def branch(self, kind: str, words: list[str], line: Line) -> dict | None: cond = None if kind != "else": cond = self.expr(" ".join(words[1:]), line) if cond is None: return None props, body = self.body(line.block) props.pop("__atl__", None) return {"c": cond, "body": body, "props": props} def for_loop(self, line: Line) -> Node | None: try: stmt = pyexpr.parse_stmts(line.text.strip().rstrip(":") + ":\n pass")[0] except Unsupported as exc: self.warn(f"Цикл в screen: {exc}", line) return None _, body = self.body(line.block) return {"c": "for", "t": stmt["t"], "it": stmt["it"], "body": body} def use(self, line: Line, words: list[str]) -> Node | None: call = self.expr(" ".join(words[1:]), line) if call is None: return None if call["k"] == "name": node = {"c": "use", "name": call["id"], "args": [], "kw": {}} elif call["k"] == "call" and call["f"]["k"] == "name": node = {"c": "use", "name": call["f"]["id"], "args": call["a"], "kw": call["kw"]} else: self.warn("Не разобран use в screen", line) return None if line.block: # children are placed where the used screen says `transclude` _, node["body"] = self.body(line.block) return node def python(self, line: Line, first: str) -> list[Node]: source = line.text.strip()[1:].strip() if first == "$" else line.block_source() try: return [{"c": "python", "code": pyexpr.parse_stmts(source)}] except Unsupported as exc: self.warn(f"Python в screen: {exc}", line) return [] def default(self, line: Line) -> None: match = re.fullmatch(r"default\s+([A-Za-z_]\w*)\s*=\s*(.+)", line.text.strip(), re.S) value = self.expr(match.group(2), line) if match else None if not match or value is None: self.warn("Не разобран default в screen", line) return self.defaults.append([match.group(1), value]) def block_properties(self, words: list[str], line: Line) -> dict[str, Any]: props = self.properties(words, line) for child in line.block: props.update(self.properties(tokens(child.text), child)) return props def key(self, line: Line, words: list[str]) -> Node | None: shortcut = self.expr(words[1], line) if len(words) > 1 else None props = self.block_properties(words[2:], line) if shortcut is None or "action" not in props: self.warn("Не разобран key в screen", line) return None return {"c": "key", "key": shortcut, "action": props["action"]} def timer(self, line: Line, words: list[str]) -> Node | None: delay = self.expr(words[1], line) if len(words) > 1 else None props = self.block_properties(words[2:], line) if delay is None or "action" not in props: self.warn("Не разобран timer в screen", line) return None return {"c": "timer", "delay": delay, "action": props["action"], "repeat": props.get("repeat")} def on(self, line: Line, words: list[str]) -> Node | None: event = self.expr(words[1], line) if len(words) > 1 else None props = self.block_properties(words[2:], line) if event is None or event.get("k") != "const" or "action" not in props: self.warn("Не разобран on в screen", line) return None if event["v"] not in ("show", "hide", "replace", "replaced"): self.warn(f"Не поддерживается в screen: событие {event['v']}", line) return None return {"c": "on", "event": event["v"], "action": props["action"]} # --- widgets -------------------------------------------------------------------- def widget(self, line: Line, words: list[str]) -> Node | None: kind = words[0] if kind == "image": kind = "add" if kind not in CONTAINERS and kind not in LEAVES: self.warn(f"Не поддерживается в screen: {kind}", line) return None args = [] i = 1 for _ in range(POSITIONAL.get(kind, 0)): if i >= len(words): break source, i = read_value(words, i) value = self.expr(source, line) if value is None: return None args.append(value) if len(args) < POSITIONAL.get(kind, 0) and kind not in ("text", "label"): self.warn(f"У {kind} в screen не хватает аргументов", line) return None props = self.properties(words[i:], line) body: list[Node] = [] if line.block: more, body = self.body(line.block) props.update(more) atl_lines = props.pop("__atl__", None) conditional = props.pop("__if__", None) if atl_lines: self.inline_atl(props, atl_lines, line) if body and kind in LEAVES: self.warn(f"{kind} в screen не может содержать вложенные элементы", line) return None if kind in ("bar", "vbar"): props["vertical"] = {"k": "const", "v": kind == "vbar"} node: Node = {"w": kind, "args": args, "props": props, "body": body} if conditional: node["propIf"] = conditional return node def inline_atl(self, props: dict[str, Any], lines: list[Line], line: Line) -> None: if self.atl is None: self.warn("Не поддерживается в screen: ATL в виджете", line) return static, animation = self.atl(lines) spec: dict[str, Any] = {"props": static} if animation: spec["atl"] = animation props["at"] = [*props.get("at", []), spec] def properties(self, words: list[str], line: Line, lenient: bool = False) -> dict[str, Any]: props: dict[str, Any] = {} i = 0 while i < len(words): key, glued = words[i], None match = GLUED.match(key) if match and (known_property(match[1]) or match[1] in SCREEN_PROPS): key, glued = match[1], match[2] if known_property(key) or key in SCREEN_PROPS: pass elif key in ("with", "nopredict"): i += 1 continue elif lenient: # styles carry many purely cosmetic properties we do not render i += 2 continue else: self.warn(f"Не поддерживается в screen: свойство {key}", line) return props if glued is None and i + 1 >= len(words): self.warn(f"У свойства {key} нет значения", line) return props if key == "at" and glued is None: # `at a, b` / `at transform_name(args)`; transforms are not Python values items, i = [], i + 1 while i < len(words): items.append(words[i].rstrip(",")) more = words[i].endswith(",") i += 1 if not more: break props["at"] = [self.transform_ref(item, line) for item in items if item] continue if glued is not None: source, i = glued, i + 1 else: source, i = read_value(words, i + 1) if ignorable(key): continue value = self.expr(source, line) if value is not None: props[key] = value return props def referenced_screens(definition: Any, known: dict[str, Any]) -> set[str]: """Screens a screen may open: `use` targets and string constants naming a screen.""" found: set[str] = set() def visit(value: Any) -> None: if isinstance(value, dict): if value.get("c") == "use" and value.get("name") in known: found.add(value["name"]) if value.get("k") == "call" and value.get("a"): function, first = value["f"], value["a"][0] name = function.get("id") if function.get("k") == "name" else ( function.get("a") if function.get("k") == "attr" else "") if (name in OPENERS and first.get("k") == "const" and first.get("v") in known): found.add(first["v"]) for child in value.values(): visit(child) elif isinstance(value, list): for child in value: visit(child) visit(definition) return found