Converter - screens.py parses the screen language: containers (fixed, frame, vbox, hbox, grid, vpgrid, viewport, button, has), widgets (text, textbutton, imagebutton, add, bar ...), if/for/use/transclude, default, python, key, timer, on, inline ATL, conditional properties, and decompiled forms (unparenthesised operators, glued property values, comma separated actions) - style statements, call/show/hide screen operators and Python-level renpy.show_screen are translated; only screens the game shows are audited, unused stock screens no longer block an import; undefined screens do - ATL values may be computed at run time and transforms take parameters (at blink(0.2, 0.3)); blur property Player - screens.ts builds the DOM from the IR and rebuilds it on state changes; buttons with idle/hover/selected/insensitive states, tooltips, sounds, modal screens, timers and keys that survive re-rendering, saved with the save file - Actions: Jump, Call, Return, SetVariable, Toggle*, SetScreenVariable, Show, Hide, ToggleScreen, Function, Play, Notify, If, Language, ShowMenu, OpenURL ... - Crop/LiveCrop/Solid/Frame/Transform/DynamicDisplayable values, crop property, globals(), renpy.variant/change_language/seen_image, transition stubs Tests: screen parser and runtime (linkedom DOM), screens fixture compiled from tests/fixtures/screens.rpy. Docs: docs/SCREENS.md. Twisted-Thoughts now passes the strict compatibility check. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
466 lines
21 KiB
Python
466 lines
21 KiB
Python
"""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.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""".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"}
|
||
OPERATORS = {"+", "-", "*", "/", "//", "%", "**", "==", "!=", "<", ">", "<=", ">=", "and", "or", "in", "is",
|
||
"if", "else", "not"}
|
||
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
|
||
|
||
|
||
def read_value(words: list[str], i: int) -> tuple[str, int]:
|
||
"""One property value starting at words[i]. Decompiled sources keep operators unparenthesised
|
||
(``text "Day " + str(day)``), so a value continues while the next word is an operator."""
|
||
parts = [words[i]]
|
||
i += 1
|
||
if parts[0] in ("not", "-") and i < len(words):
|
||
parts.append(words[i])
|
||
i += 1
|
||
while i < len(words):
|
||
if words[i].startswith("(") and re.search(r"[\w\])]$", parts[-1]): # `ToggleVariable ("x")`
|
||
parts[-1] += words[i]
|
||
i += 1
|
||
elif parts[-1].endswith(",") or parts[-1] in OPERATORS: # `A(), B()` / `a +` continue with the next word
|
||
parts.append(words[i])
|
||
i += 1
|
||
elif i + 1 < len(words) and words[i] in OPERATORS:
|
||
parts.extend([words[i], words[i + 1]])
|
||
i += 2
|
||
else:
|
||
break
|
||
return " ".join(parts), i
|
||
|
||
|
||
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]
|
||
words = tokens(line.text.rstrip(":").strip() if line.block else line.text.strip())
|
||
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(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
|