mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-19 16:01:45 +02:00
- C.checklist/C.Check replace status_row: Frame 'Ready to play' (Install Lepton, Install Proton, Test, Restart the Frame when kernel keys run high), Settings Tools (Install per missing tool) and This PC - Views fade in (150 ms AnimatedSwitcher, same-route redraws swap in place); the wordmark portal pulses once on connect; Settings > Appearance 'Reduce motion' (ui.reduce_motion) turns both off - Settings: fixed index column (Appearance first, Remove FramePort last) that scrolls to sections; ui_smoke step settings-index - Tests: checklist items, section order, reduce-motion default
90 lines
4.4 KiB
Python
90 lines
4.4 KiB
Python
"""GUI wording rules: every user-visible literal goes through tr(), one vocabulary, "…" only as a real ellipsis."""
|
|
import ast
|
|
import re
|
|
from pathlib import Path
|
|
|
|
UI = Path(__file__).resolve().parents[1] / "src" / "frameport" / "ui"
|
|
|
|
# helpers whose arguments at these positions are shown to the user
|
|
TEXT_ARGS = {"Text": (0,), "body": (0,), "meta": (0,), "title": (0,), "h2": (0,), "primary": (0,),
|
|
"secondary": (0,), "ghost": (0,), "danger": (0,), "pill": (0,), "callout": (0,), "section": (0,),
|
|
"kv": (0,), "switch": (0,), "empty_state": (1, 2), "Check": (1, 2), "icon_btn": (1,),
|
|
"confirm": (1, 2, 3), "toast": (0,), "tip": (0,), "submit": (0,), "dialog": (0,), "primary_menu": (0,)}
|
|
TEXT_KWARGS = {"tooltip", "hint_text", "label", "dialog_title", "action", "subtitle", "text"}
|
|
# literals that aren't words to translate: the product name and the wordmark's halves
|
|
ALLOWED = {"FramePort", "Frame", "Port"}
|
|
|
|
|
|
def _ui_files():
|
|
return sorted(UI.rglob("*.py"))
|
|
|
|
|
|
def _name(func: ast.AST) -> str:
|
|
return func.attr if isinstance(func, ast.Attribute) else func.id if isinstance(func, ast.Name) else ""
|
|
|
|
|
|
def _is_word_literal(node: ast.AST) -> bool:
|
|
if isinstance(node, ast.JoinedStr): # f"…" with words in its literal parts
|
|
return any(isinstance(v, ast.Constant) and re.search(r"[A-Za-z]{2}", v.value) for v in node.values)
|
|
return isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value not in ALLOWED \
|
|
and not node.value.startswith(("http://", "https://")) and bool(re.search(r"[A-Za-z]{2}", node.value))
|
|
|
|
|
|
def _tr_texts():
|
|
"""(file, line or help key, text) for every tr()/tr_n() literal in ui/ and every help text."""
|
|
for path in _ui_files():
|
|
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
|
if isinstance(node, ast.Call) and _name(node.func) in ("tr", "tr_n"):
|
|
for a in node.args[:2 if _name(node.func) == "tr_n" else 1]:
|
|
if isinstance(a, ast.Constant) and isinstance(a.value, str):
|
|
yield path, node.lineno, a.value
|
|
from frameport.ui.help import HELP
|
|
|
|
for key, text in HELP.items():
|
|
yield UI / "help.py", key, text
|
|
|
|
|
|
def test_visible_literals_are_translated():
|
|
found = []
|
|
for path in _ui_files():
|
|
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
name = _name(node.func)
|
|
pos = TEXT_ARGS.get(name, ())
|
|
if name == "Text" and not (isinstance(node.func, ast.Attribute) and _name(node.func.value) == "ft"):
|
|
pos = ()
|
|
args = [node.args[i] for i in pos if i < len(node.args)]
|
|
args += [k.value for k in node.keywords if k.arg in TEXT_KWARGS]
|
|
found += [f"{path.relative_to(UI)}:{node.lineno}" for a in args if _is_word_literal(a)]
|
|
assert not found, "user-visible text not wrapped in tr(): " + ", ".join(found)
|
|
|
|
|
|
def test_no_three_dots_or_eg_in_ui_text():
|
|
texts = list(_tr_texts())
|
|
dots = [f"{p.name}:{line}" for p, line, text in texts if "..." in text]
|
|
eg = [f"{p.name}:{line}" for p, line, text in texts if re.search(r"\be\.g\.", text)]
|
|
assert not dots, "use … (one character) instead of ...: " + ", ".join(dots)
|
|
assert not eg, "write \"for example\" in user text: " + ", ".join(eg)
|
|
|
|
|
|
def test_one_vocabulary():
|
|
rules = {r"(?i)\bthis computer\b": "this PC", r"(?i)\bconfigs?\b": "recipe", r"(?i)\bexecutable\b": "program",
|
|
r"(?i)\bforce kill\b": "Force quit", r"\d %": "no space before %",
|
|
r"(?i)colour|cancelled": "US spelling", r"Steam ids": "Steam IDs", r"ARM64": "arm64"}
|
|
bad = [f"{p.name}:{line} ({fix})" for p, line, text in _tr_texts()
|
|
for rule, fix in rules.items() if re.search(rule, text)]
|
|
assert not bad, "vocabulary: " + ", ".join(bad)
|
|
|
|
|
|
def test_switch_frame_opens_a_picker_not_disconnect():
|
|
tree = ast.parse((UI / "views" / "frame.py").read_text(encoding="utf-8"))
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Call) and _name(node.func) == "ghost" and node.args and \
|
|
isinstance(node.args[0], ast.Call) and node.args[0].args and \
|
|
getattr(node.args[0].args[0], "value", None) == "Switch Frame…":
|
|
handler = ast.unparse(node.args[2])
|
|
assert "disconnect" not in handler and "switch_frame" in handler
|
|
return
|
|
raise AssertionError("no Switch Frame… button found")
|