"""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", "side", "nearrect", "drag"} LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar", "mousearea"} POSITIONAL = {"side": 1, "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 box_wrap_spacing 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 style_group prefer_top preferred_side drag_name draggable droppable drag_handle drag_joined drag_offscreen""".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 = {"base_bar", "subpixel", "roll_forward", "xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll", "child_size", "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, closed = [], [], 0, "", False, False for ch in text: if closed: # `"text"size 15`: a quote that ends a word ends the token closed = False if (ch.isalpha() or ch == "_") and depth == 0: out.append("".join(cur)) cur = [] if quote: cur.append(ch) if escaped: escaped = False elif ch == "\\": escaped = True elif ch == quote: quote = "" closed = True elif ch in "\"'": quote = ch cur.append(ch) elif ch in "([{": depth += 1 cur.append(ch) elif ch in ")]}": depth -= 1 cur.append(ch) if depth == 0: closed = True 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() text = re.sub(r"^(add|text|label|image|textbutton)(?=[\"'])", r"\1 ", text) # `add"cover"` 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 == "pass": pass 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: # `for item index item.id in items:`: the index only keys the widgets, the loop is the same source = re.sub(r"\s+index\s+.+?\s+in\s+", " in ", line.text.strip().rstrip(":"), count=1) try: stmt = pyexpr.parse_stmts(source + ":\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: if len(words) > 1 and words[1] == "expression": return self.use_expression(line, words) 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 use_expression(self, line: Line, words: list[str]) -> Node | None: """``use expression name pass (args)``: the screen is chosen when the screen is drawn.""" match = re.fullmatch(r"(.+?)(?:\s+pass\s*(\(.*\)))?", " ".join(words[2:]), re.S) name = self.expr(match.group(1), line) if match else None call = self.expr("_use" + match.group(2), line) if match and match.group(2) else None if name is None or (match.group(2) and (call is None or call["k"] != "call")): self.warn("Не разобран use expression в screen", line) return None node: Node = {"c": "use", "nameExpr": name, "args": call["a"] if call else [], "kw": call["kw"] if call else {}} if call and call.get("ks"): node["ks"] = call["ks"] if line.block: _, 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 at_ref(self, source: str, line: Line) -> Node: """One `at` entry; `(a if cond else b)` picks a transform while the screen is shown.""" text = source.strip() if text.startswith("(") and text.endswith(")"): try: tree = ast.parse(text, mode="eval").body except SyntaxError: tree = None if isinstance(tree, ast.IfExp): cond = self.expr(ast.unparse(tree.test), line) if cond is not None: return {"choose": {"c": cond, "then": self.at_ref(ast.unparse(tree.body), line), "else": self.at_ref(ast.unparse(tree.orelse), line)}} if re.fullmatch(r"[A-Za-z_]\w*(?:\.\w+)*\[[^\]]+\]|[A-Za-z_]\w*\.\w+", text): # `icon["at"]`, `item.at` dynamic = self.expr(text, line) if dynamic is not None: return {"dyn": dynamic} return self.transform_ref(source, line) 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) or re.fullmatch(r"([a-z_]+)([+-]?(?:\d|\.\d).*)", key) if match and (known_property(match[1]) or match[1] in SCREEN_PROPS): key, glued = match[1], match[2] if key == "style_group": # the old name of style_prefix key = "style_prefix" 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.at_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