feat: converter accepts the constructs of goodluke and SaintlyCorruption
`init -490 screen`, `import x as y` and `from m import A as B`, classes with @renpy.pure, pickle hooks
on classes, `*` items in tuples and lists, generator functions (yield statements; the player runs them
to the end), multi-line expressions that need brackets, Null() and Animation() images, images given as
expressions (`"path/{}.png".format(x)`, f-strings), window statements in init, `offset` in ATL, input
widgets with their values and the focus/confirm actions, `continue` and `break` in screens, `drag`
and DiamondBox-style `fixed` with cell_size, `getattr(...)()` calls, transforms called with arguments,
and the whitelist for the new runtime. JSON files of a game are shipped as data files. Also fixes
blocking_names failing on a call of a call during full conversion.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f2fcd05467
commit
e66160cffa
12 files changed
+176
-31
No files matched your search
@@ -223,6 +223,8 @@ class AtlParser:
|
||||
if key == "offset": # `offset (x, y)` is xoffset and yoffset together
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
out["xoffset"], out["yoffset"] = value
|
||||
elif isinstance(value, dict) and value.get("e", {}).get("k") in ("tuple", "list") and len(value["e"]["v"]) == 2: # `offset (0, -LIFT)`: each half is computed
|
||||
out["xoffset"], out["yoffset"] = ({"e": half} for half in value["e"]["v"])
|
||||
else:
|
||||
self.warn("Не поддерживается ATL: offset не пара чисел", line)
|
||||
continue
|
||||
@@ -307,7 +309,7 @@ class AtlParser:
|
||||
seconds = self.number(first, line) # `delays[1]`: a number picked from a list
|
||||
if seconds is not None:
|
||||
steps.append({"op": "pause", "t": seconds})
|
||||
elif first in POSITION_PROPS or ((g := GLUED.match(first)) and g[1] in POSITION_PROPS):
|
||||
elif first in POSITION_PROPS or first == "offset" or ((g := GLUED.match(first)) and g[1] in POSITION_PROPS):
|
||||
props = self.props(parts, line)
|
||||
if props:
|
||||
steps.append({"op": "set", "props": props})
|
||||
|
||||
@@ -124,6 +124,15 @@ CALLS |= set(NATIVE_CLASSES)
|
||||
CALLS |= {"renpy.Render", "renpy.render", "renpy.redraw", "renpy.displayable", "renpy.IgnoreEvent",
|
||||
"renpy.display.layout.LiveComposite", "renpy.display.layout.Fixed", "pygame.key.get_mods",
|
||||
"pygame.mouse.get_pressed", "pygame.mouse.get_pos"}
|
||||
CALLS |= {"ConditionSwitch", "DynamicImage", "renpy.register_sl_displayable", "renpy.capture_focus", "renpy.clear_capture_focus", "renpy.get_widget", "renpy.display.focus.set_focused",
|
||||
"renpy.file", "renpy.open_file", "loads", "dumps", "json.loads", "json.dumps", "Animation", "im.matrix", "renpy.context", "renpy.call_in_new_context", "renpy.get_focus_rect", "gui.rebuild", "FontGroup", "RenpyText",
|
||||
"MatrixColor", "im.Image", "im.AlphaMask", "GLMovie", "renpy.display.im.Image", "colorsys.rgb_to_hls",
|
||||
"colorsys.hls_to_rgb"}
|
||||
CALLS |= {"next", "repr", "dict.fromkeys", "colorsys.rgb_to_hsv", "colorsys.hsv_to_rgb", "fnmatch.fnmatch",
|
||||
"traceback.format_exc", "traceback.print_exc", "math.log1p", "math.expm1", "renpy.in_rollback",
|
||||
"renpy.is_in_test", "renpy.retain_after_load", "renpy.context_nesting_level", "renpy.current_screen",
|
||||
"renpy.substitutions.substitute", "renpy.set_screen_variable", "renpy.music.register_channel",
|
||||
"renpy.music.get_loop", "renpy.music.get_channel"}
|
||||
CALLS |= {"Matrix", "renpy.has_live2d", "renpy.fetch", "renpy.display.draw.kill_textures",
|
||||
"renpy.display.render.free_memory", "cache.get_total_size"}
|
||||
CALLS |= {"Move", "absolute", "type", "id", "vars", "Flatten", "LiveComposite", "Composite", "defaultdict", "OrderedDict", "deepcopy",
|
||||
@@ -149,6 +158,10 @@ SCREEN_CALLS |= set("""FileAction FileSave FileLoad FileDelete FileTakeScreensho
|
||||
FileLoadable FileUsedSlot FileNewest FileSaveName FileTime FileJson FileScreenshot FileCurrentScreenshot
|
||||
FileCurrentPage FilePageName FileSlotName""".split())
|
||||
SCREEN_CALLS |= {"renpy.translation.translate_string"}
|
||||
# Text fields and the actions around them (see InputValue in screens.ts)
|
||||
SCREEN_CALLS |= set("""VariableInputValue ScreenVariableInputValue FieldInputValue DictInputValue FilePageNameInputValue
|
||||
CurrentScreenName CaptureFocus ClearFocus InvertSelected UploadSync DownloadSync Confirm""".split())
|
||||
METHODS_INPUT = {"Enable", "Disable", "Toggle"}
|
||||
# Screen language helpers for audio, focus and pictures that play (see screens.ts)
|
||||
SCREEN_CALLS |= {"Movie", "MixerValue", "AudioPositionValue", "PauseAudio", "ToggleSetMembership",
|
||||
"GetFocusRect", "ToggleFocus", "XScrollValue", "YScrollValue", "Preference", "Start"}
|
||||
@@ -173,7 +186,7 @@ METHODS = set("append extend insert pop remove index count reverse sort clear co
|
||||
|
||||
METHODS |= NATIVE_METHODS
|
||||
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
|
||||
METHODS |= {"blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
|
||||
METHODS |= {"add_style_property", "add_prefix_style_property", "add_pattern", "get_context", "read", "readlines", "close", "sub", "subn", "search", "match", "findall", "finditer", "split", "time", "_clear", "Enable", "Disable", "Toggle", "encode", "blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
|
||||
"add_focus", "circle", "rect", "line", "lines", "polygon"}
|
||||
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
|
||||
|
||||
@@ -380,7 +393,8 @@ def blocking_names(data) -> list[str]:
|
||||
elif isinstance(value, dict):
|
||||
if value.get("k") == "call":
|
||||
function = value["f"]
|
||||
last = function.get("id") if function.get("k") == "name" else function.get("a")
|
||||
last = function.get("id") if function.get("k") == "name" else (
|
||||
function.get("a") if function.get("k") == "attr" else None) # `f()()` has neither
|
||||
dotted = (function["o"]["id"] + "." + function["a"]) if (function.get("k") == "attr" and
|
||||
function["o"].get("k") == "name") else last
|
||||
if dotted in waiting or last in known or last in waiting:
|
||||
@@ -504,6 +518,7 @@ def audit_ir(data) -> None:
|
||||
list(used_screens.values())])
|
||||
user_names |= {name for name, _ in data.defaults}
|
||||
user_names |= set(data.characters) | {"narrator"}
|
||||
user_names |= set(data.transforms) | set(data.atl) # `store.move_in(0.3)`: a transform called with arguments
|
||||
|
||||
def dotted(value):
|
||||
if value.get("k") == "name":
|
||||
@@ -524,8 +539,9 @@ def audit_ir(data) -> None:
|
||||
and (function.get("a") in METHODS or function.get("a") in user_methods
|
||||
or function.get("a") in user_names))
|
||||
# `MainMenu(confirm=False)()`: calling the action a screen action constructor returned
|
||||
if function.get("k") == "call" and dotted(function.get("f", {})) in SCREEN_CALLS:
|
||||
is_method = True
|
||||
if function.get("k") == "call" and (dotted(function.get("f", {})) in SCREEN_CALLS
|
||||
or dotted(function.get("f", {})) == "getattr"):
|
||||
is_method = True # an action a constructor returned, or `getattr(obj, "f", fallback)()`
|
||||
# `globals()[who + "_here"](...)`: a function picked by name; some function must carry that ending
|
||||
if function.get("k") == "sub" and function["o"].get("k") == "call" \
|
||||
and dotted(function["o"]["f"]) == "globals":
|
||||
|
||||
@@ -162,6 +162,16 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
||||
raise CompatibilityError("renpy", [Issue("media_conversion", "Не удалось конвертировать обязательное медиа", name)
|
||||
for name in failed])
|
||||
|
||||
# JSON the game reads itself (`renpy.file("data/x.json").read()`): kept as it is, loaded before the game starts
|
||||
data_files: list[str] = []
|
||||
for p in sorted(game_dir.rglob("*.json")):
|
||||
relative = p.relative_to(game_dir).as_posix()
|
||||
if _is_engine_file(game_dir, p) or relative.lower().startswith(("cache/", "saves/")) or p.stat().st_size > DATA_FILE_LIMIT:
|
||||
continue
|
||||
blob_id, size = put_file(p, ".json")
|
||||
files[f"data/{relative}"] = {"hash": blob_id, "size": size}
|
||||
data_files.append(relative)
|
||||
|
||||
progress("Сборка формата PGVN")
|
||||
_auto_images(data, aliases)
|
||||
missing: set[str] = set()
|
||||
@@ -212,6 +222,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
||||
"channels": data.channels,
|
||||
"build": data.build,
|
||||
"files": aliases,
|
||||
"dataFiles": data_files,
|
||||
"defaults": data.defaults,
|
||||
"init": data.init_code,
|
||||
"ui": _ui(gui, aliases),
|
||||
@@ -328,6 +339,9 @@ def _used_screens(data: ScriptData) -> set[str]:
|
||||
return used
|
||||
|
||||
|
||||
DATA_FILE_LIMIT = 4 * 1024 * 1024
|
||||
|
||||
|
||||
def _is_engine_file(game_dir: Path, p: Path) -> bool:
|
||||
parts = [x.lower() for x in p.relative_to(game_dir).parts]
|
||||
return len(parts) > 1 and parts[0] in ENGINE_DIRS
|
||||
|
||||
@@ -25,7 +25,7 @@ UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
|
||||
|
||||
# Modules whose import is harmless: the player provides the functions it supports.
|
||||
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
|
||||
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame"}
|
||||
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame", "colorsys", "fnmatch", "traceback", "builtins"}
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
@@ -66,9 +66,14 @@ def parse_expr(source: str) -> Expr:
|
||||
try:
|
||||
tree = ast.parse(source.strip(), mode="eval")
|
||||
except SyntaxError as e:
|
||||
try:
|
||||
tree = ast.parse(python2_operators(source).strip(), mode="eval")
|
||||
except SyntaxError:
|
||||
# a long expression continued on the next lines (`A | B`, `FontGroup().add(...)`) needs brackets
|
||||
for candidate in (python2_operators(source), "(" + source.strip() + ")"):
|
||||
try:
|
||||
tree = ast.parse(candidate.strip(), mode="eval")
|
||||
break
|
||||
except SyntaxError:
|
||||
continue
|
||||
else:
|
||||
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
|
||||
return expr(tree.body)
|
||||
|
||||
@@ -142,9 +147,9 @@ def expr(node: ast.AST) -> Expr:
|
||||
case ast.IfExp(test=t, body=b, orelse=e):
|
||||
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
|
||||
case ast.List(elts=elts) | ast.Set(elts=elts):
|
||||
return {"k": "list", "v": [expr(e) for e in elts]}
|
||||
return {"k": "list", "v": [element(e) for e in elts]}
|
||||
case ast.Tuple(elts=elts):
|
||||
return {"k": "tuple", "v": [expr(e) for e in elts]}
|
||||
return {"k": "tuple", "v": [element(e) for e in elts]}
|
||||
case ast.Dict(keys=keys, values=values):
|
||||
# `{**other, "k": v}`: a spread entry is a star key with an empty value
|
||||
return {"k": "dict", "v": [[{"k": "star", "v": expr(v)}, {"k": "const", "v": None}] if k is None
|
||||
@@ -208,6 +213,10 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
||||
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
|
||||
case ast.Expr(value=ast.Call(func=func)) if ast.unparse(func).startswith("build."):
|
||||
return [] # distribution settings (build.classify, build.archive ...) have no meaning in the browser
|
||||
case ast.Expr(value=ast.Yield(value=value)):
|
||||
return [{"op": "yield", "v": None if value is None else expr(value)}]
|
||||
case ast.Expr(value=ast.YieldFrom(value=value)):
|
||||
return [{"op": "yield_from", "v": expr(value)}]
|
||||
case ast.Expr(value=ast.Constant()):
|
||||
return [] # docstring / bare literal
|
||||
case ast.Expr(value=value):
|
||||
@@ -239,16 +248,22 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
||||
case ast.Import(names=names) | ast.ImportFrom(names=names):
|
||||
mod = getattr(node, "module", None) or names[0].name
|
||||
if mod.split(".")[0] in KNOWN_MODULES:
|
||||
return []
|
||||
# `import time as t` / `from renpy.text.text import Text as RenpyText`: the new name is the old one
|
||||
return [{"op": "set", "t": {"k": "name", "id": alias.asname},
|
||||
"v": {"k": "name", "id": alias.name.split(".")[0] if isinstance(node, ast.Import) else alias.name}}
|
||||
for alias in names if alias.asname and alias.asname != alias.name]
|
||||
raise Unsupported(f"импорт модуля {mod}")
|
||||
case ast.FunctionDef(decorator_list=decos) if any(
|
||||
ast.unparse(d).split("(")[0] in INACTIVE_VARIANTS for d in decos):
|
||||
return [] # screen-size/touch variants never apply to the desktop browser player
|
||||
case ast.FunctionDef(name=name, args=args, body=body, decorator_list=decos):
|
||||
decos = [d for d in decos if ast.unparse(d) not in TRANSPARENT_DECORATORS]
|
||||
return [{"op": "def", "name": name, "params": params(args), "body": function_body(body),
|
||||
"decos": [decorator(d) for d in decos]}]
|
||||
case ast.ClassDef(name=name, bases=bases, keywords=[], body=body, decorator_list=[]):
|
||||
node = {"op": "def", "name": name, "params": params(args), "body": function_body(body),
|
||||
"decos": [decorator(d) for d in decos]}
|
||||
if is_generator(body):
|
||||
node["gen"] = True
|
||||
return [node]
|
||||
case ast.ClassDef(name=name, bases=bases, keywords=[], body=body, decorator_list=decos) if all(ast.unparse(d) in TRANSPARENT_DECORATORS for d in decos):
|
||||
return [{"op": "class", "name": name, "bases": [expr(b) for b in bases],
|
||||
"body": class_body(name, body)}]
|
||||
case ast.ClassDef(name=name):
|
||||
@@ -264,6 +279,8 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
||||
|
||||
# Methods the player can run implicitly. Any other dunder method would silently
|
||||
# never be called, so the converter refuses it instead.
|
||||
# Pickle hooks only matter for Ren'Py's own save files; the player keeps its own
|
||||
IGNORED_DUNDERS = {"__setstate__", "__getstate__", "__reduce__", "__reduce_ex__"}
|
||||
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", "__getitem__",
|
||||
"__contains__"}
|
||||
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
|
||||
@@ -296,19 +313,39 @@ def decorator(node: ast.expr) -> Expr:
|
||||
raise Unsupported(f"декоратор {ast.unparse(node)}")
|
||||
|
||||
|
||||
def is_generator(body: list[ast.stmt]) -> bool:
|
||||
"""A function with `yield` statements. The player runs it to the end and hands out what it yielded, which is
|
||||
right for generators that end; `x = yield` (sending values in) and async code stay unsupported."""
|
||||
module = ast.Module(body=body, type_ignores=[])
|
||||
statement_yields = {id(n.value) for n in ast.walk(module)
|
||||
if isinstance(n, ast.Expr) and isinstance(n.value, (ast.Yield, ast.YieldFrom))}
|
||||
found = False
|
||||
for n in ast.walk(module):
|
||||
if isinstance(n, ast.Await):
|
||||
raise Unsupported("async")
|
||||
if isinstance(n, (ast.Yield, ast.YieldFrom)):
|
||||
if id(n) not in statement_yields:
|
||||
raise Unsupported("yield как выражение")
|
||||
found = True
|
||||
return found
|
||||
|
||||
|
||||
def function_body(body: list[ast.stmt]) -> list[Stmt]:
|
||||
for n in ast.walk(ast.Module(body=body, type_ignores=[])):
|
||||
if isinstance(n, (ast.Yield, ast.YieldFrom, ast.Await)):
|
||||
raise Unsupported("генераторы и async")
|
||||
is_generator(body)
|
||||
return stmts(body)
|
||||
|
||||
|
||||
def element(node: ast.expr) -> Expr:
|
||||
"""One item of a list or tuple; `*rest` spreads a sequence into it."""
|
||||
return {"k": "star", "v": expr(node.value)} if isinstance(node, ast.Starred) else expr(node)
|
||||
|
||||
|
||||
def class_body(name: str, body: list[ast.stmt]) -> list[Stmt]:
|
||||
for n in body:
|
||||
if (isinstance(n, ast.FunctionDef) and n.name.startswith("__") and n.name.endswith("__")
|
||||
and n.name not in SUPPORTED_DUNDERS):
|
||||
and n.name not in SUPPORTED_DUNDERS | IGNORED_DUNDERS):
|
||||
raise Unsupported(f"специальный метод {name}.{n.name}()")
|
||||
return stmts(body)
|
||||
return stmts([n for n in body if not (isinstance(n, ast.FunctionDef) and n.name in IGNORED_DUNDERS)])
|
||||
|
||||
|
||||
def generators(gens: list[ast.comprehension]) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -18,7 +18,7 @@ POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xce
|
||||
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
|
||||
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
|
||||
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
|
||||
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
|
||||
_DISPLAYABLE_CALLS = {"Null", "Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
|
||||
"ConditionSwitch", "TransitionConditionSwitch", "Image", "Text", "AlphaMask", "im.Scale", "im.FactorScale", "im.Flip",
|
||||
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
|
||||
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
|
||||
|
||||
@@ -152,6 +152,7 @@ class ScriptData:
|
||||
self.warning_examples[kind] = where
|
||||
|
||||
|
||||
LAZY_IMAGE_KINDS = {"fmt", "bin", "name", "attr", "sub", "if"}
|
||||
LIVE2D_IF = re.compile(r"^\s*if\s+renpy\.has_live2d\(\)(?:\s+and\s.*)?:\s*$")
|
||||
TRANSLATED_SPEAKER = re.compile(r"""^_{1,2}\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\)(?=\s+["'])""")
|
||||
|
||||
@@ -469,11 +470,14 @@ class RpyParser:
|
||||
c.keyword("python")
|
||||
self.init_python(line.block_source(), line)
|
||||
return []
|
||||
rest = c.rest().strip()
|
||||
if rest and rest != ":": # `init -490 screen name(...):` - one statement with a priority in front
|
||||
return self.statement(replace(line, text=rest), top_level=True)
|
||||
for child in self.fold_live2d(line.block):
|
||||
for node in self.statement(child, top_level=True):
|
||||
if node["op"] == "exec":
|
||||
self.d.init_code.extend(node["code"])
|
||||
else:
|
||||
elif node["op"] != "window": # `window hide` at init time changes nothing that is shown
|
||||
self.d.warn("Оператор сцены внутри init", self.where(child))
|
||||
return []
|
||||
|
||||
@@ -758,12 +762,15 @@ class RpyParser:
|
||||
return {"file": s}
|
||||
return {"ref": s}
|
||||
if e["k"] != "call":
|
||||
self.d.warn("Изображение задано выражением", self.where(line))
|
||||
# a name, an f-string or a sum of strings: the picture is whatever it evaluates to when it is shown
|
||||
self.d.warn("Изображение задано выражением", self.where(line), blocking=e["k"] not in LAZY_IMAGE_KINDS)
|
||||
return {"expr": e}
|
||||
fname = _dotted_name(e["f"])
|
||||
args, kw = e["a"], e["kw"]
|
||||
if fname == "Solid" and args:
|
||||
return {"color": _const(args[0])}
|
||||
if fname == "Null": # nothing to draw: a transparent picture
|
||||
return {"color": "#00000000"}
|
||||
if fname == "Text" and args:
|
||||
return {"textExpr": args[0]}
|
||||
if fname in ("Movie",):
|
||||
@@ -815,6 +822,9 @@ class RpyParser:
|
||||
layers.append({"pos": list(pos), "img": sub})
|
||||
return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple))
|
||||
else None}
|
||||
if fname == "Animation": # pictures that take turns, named by values known only while playing
|
||||
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
|
||||
return {"expr": e}
|
||||
# wrappers: Transform("x.png", zoom=.5), im.Scale(...), im.Flip(...), etc.
|
||||
for a in args:
|
||||
sub = self.displayable_expr(a, line) if a.get("k") in ("const", "call") else None
|
||||
@@ -832,6 +842,10 @@ class RpyParser:
|
||||
if props:
|
||||
sub = {**sub, "props": {**sub.get("props", {}), **props}}
|
||||
return sub
|
||||
if fname == "Animation" or e["f"].get("k") == "attr" and not fname.startswith(("im.", "renpy.")):
|
||||
# `Animation(...)` and `"path/{}.png".format(x)`: built from values known only while playing
|
||||
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
|
||||
return {"expr": e}
|
||||
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
|
||||
return None
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call
|
||||
|
||||
CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button", "side", "nearrect",
|
||||
"drag"}
|
||||
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar", "mousearea"}
|
||||
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar", "mousearea", "input"}
|
||||
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"}
|
||||
|
||||
@@ -50,11 +50,11 @@ BEHAVIOUR = set("""action alternate hovered unhovered sensitive tooltip focus_ma
|
||||
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())
|
||||
drag_name draggable droppable drag_handle drag_joined drag_offscreen cell_size fit start_lower default allow exclude mask length copypaste pixel_width multiline""".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",
|
||||
IGNORED = {"capture", "axis", "key_events", "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")
|
||||
@@ -208,6 +208,8 @@ class ScreenParser:
|
||||
node = self.use(line, words)
|
||||
if node:
|
||||
nodes.append(node)
|
||||
elif first in ("continue", "break"):
|
||||
nodes.append({"c": first})
|
||||
elif first == "transclude":
|
||||
nodes.append({"c": "transclude"})
|
||||
elif first == "pass":
|
||||
@@ -222,8 +224,10 @@ class ScreenParser:
|
||||
i = len(lines)
|
||||
elif first == "python" and line.text.strip() == "python:" or first == "$":
|
||||
nodes.extend(self.python(line, first))
|
||||
elif first == "default":
|
||||
elif first == "default" and "=" in text:
|
||||
self.default(line)
|
||||
elif first == "default": # `default "Bob"` inside an input: its starting text
|
||||
props.update(self.properties(words, line))
|
||||
elif first == "key":
|
||||
node = self.key(line, words)
|
||||
if node:
|
||||
@@ -391,6 +395,8 @@ class ScreenParser:
|
||||
return None
|
||||
if kind in ("bar", "vbar"):
|
||||
props["vertical"] = {"k": "const", "v": kind == "vbar"}
|
||||
if kind == "fixed" and "cell_size" in props: # the game's DiamondBox (decompiled as a plain `fixed`)
|
||||
kind = "diamondbox"
|
||||
node: Node = {"w": kind, "args": args, "props": props, "body": body}
|
||||
if conditional:
|
||||
node["propIf"] = conditional
|
||||
@@ -419,6 +425,8 @@ class ScreenParser:
|
||||
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 text == "None":
|
||||
return {}
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user