Reorganize mission menu and separate design from logic
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build native menu layouts and the runtime palette from ui/admin_menu sources."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / 'ui/admin_menu'
|
||||
LAYOUTS = Path('addons/core/UI/layouts/ERR')
|
||||
PALETTE = Path('addons/core/Scripts/Game/ERR/00_Core/ERR_MenuTheme.c')
|
||||
PAGES = Path('addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuPages.c')
|
||||
TOKEN = re.compile(r'\{\{([^{}]+)\}\}')
|
||||
|
||||
|
||||
def navigation(source, tokens):
|
||||
config = json.loads((source / 'navigation.json').read_text(encoding='utf-8'))
|
||||
pages, groups = config['pages'], config['groups']
|
||||
if [p['id'] for p in pages] != list(range(len(pages))) or not 1 <= len(groups) <= 8:
|
||||
raise ValueError('Page IDs must be consecutive and main navigation supports 1-8 groups')
|
||||
for field in ('key', 'widget', 'button'):
|
||||
values = [p[field] for p in pages]
|
||||
if len(set(values)) != len(values) or any(not re.fullmatch(r'[A-Za-z][A-Za-z_0-9]*', v) for v in values):
|
||||
raise ValueError(f'Duplicate or invalid navigation {field}')
|
||||
keys = {p['key']: p for p in pages}
|
||||
members = [key for group in groups for key in group['pages']]
|
||||
names = [g['name'] for g in groups]
|
||||
if (len(members) != len(set(members)) or set(members) != keys.keys()
|
||||
or len(set(names)) != len(names)
|
||||
or any(not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*', n) for n in names)
|
||||
or any(not 1 <= len(g['pages']) <= 8 for g in groups)):
|
||||
raise ValueError('Every page must belong to exactly one valid sidebar group')
|
||||
for page in pages:
|
||||
if not isinstance(page['label'], str) or any(c in page['label'] for c in '\n\r"\\'):
|
||||
raise ValueError('Invalid navigation label')
|
||||
|
||||
def slot(x, y, w, h):
|
||||
return f'Slot FrameWidgetSlot {{ Anchor 0 0 0 0 PositionX {x} OffsetLeft {x} PositionY {y} OffsetTop {y} SizeX {w} OffsetRight {-x-w} SizeY {h} OffsetBottom {-y-h} }}'
|
||||
|
||||
def button(name, label, x, y, w, selected=False):
|
||||
return (f'ButtonWidgetClass : "{{75C912A1C89BE6C2}}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" {{ Name "{name}" {slot(x,y,w,42)} '
|
||||
f'components {{ SCR_ButtonTextComponent "{{5548F0143AE99095}}" {{ m_sText "{label}" }} }} }}\n'
|
||||
f'ImageWidgetClass {{ "Is Visible" {int(selected)} Name "Selected_{name}" {slot(x,y+44,w,3)} Color {tokens["COLOR_ACCENT"]} }}\n')
|
||||
|
||||
layout = ''
|
||||
assignments = {}
|
||||
for index, group in enumerate(groups):
|
||||
name = group['name']
|
||||
initial = 'MISSION' in group['pages']
|
||||
layout += button('Main_' + name, name, 28 + index * 146, 106, 138, initial)
|
||||
layout += f'FrameWidgetClass {{ "Is Visible" {int(initial)} Name "Sidebar_{name}" {slot(28,166,180,480)} {{\n'
|
||||
for row, key in enumerate(group['pages']):
|
||||
page = keys[key]
|
||||
assignments[key] = index
|
||||
layout += button(page['button'], page['label'], 0, row * 54, 180, key == 'MISSION')
|
||||
layout += '} }\n'
|
||||
|
||||
def strings(method, values):
|
||||
return f' static array<string> {method}() {{ return {{' + ', '.join('"' + v + '"' for v in values) + '}; }\n'
|
||||
|
||||
script = '// Generated by tools/build_admin_menu.py; edit ui/admin_menu/navigation.json.\n'
|
||||
script += 'enum ERR_AdminPage\n{\n' + ''.join(f' {p["key"]} = {p["id"]},\n' for p in pages) + '}\n\n'
|
||||
script += 'class ERR_AdminMenuPages\n{\n'
|
||||
script += strings('Pages', [p['widget'] for p in pages])
|
||||
script += strings('Navigation', [p['button'] for p in pages])
|
||||
script += strings('MainButtons', ['Main_' + n for n in names])
|
||||
script += strings('Sidebars', ['Sidebar_' + n for n in names])
|
||||
script += ' static int Group(int page)\n {\n array<int> groups = {' + ', '.join(str(assignments[p['key']]) for p in pages) + '};\n if (page < 0 || page >= groups.Count()) return -1;\n return groups[page];\n }\n'
|
||||
script += ' static int MainPage(int group)\n {\n array<int> pages = {' + ', '.join('ERR_AdminPage.' + g['pages'][0] for g in groups) + '};\n if (group < 0 || group >= pages.Count()) return -1;\n return pages[group];\n }\n}\n'
|
||||
visibility = {'VISIBLE_' + p['widget']: str(int(p['key'] == 'MISSION')) for p in pages}
|
||||
return layout, script, visibility
|
||||
|
||||
|
||||
def source_file(source, name):
|
||||
path = (source / name).resolve()
|
||||
if not path.is_relative_to(source.resolve()) or not path.is_file():
|
||||
raise ValueError(f'Invalid menu source: {name}')
|
||||
return path
|
||||
|
||||
|
||||
def expand(source, name, tokens, stack=()):
|
||||
path = source_file(source, name)
|
||||
if path in stack:
|
||||
raise ValueError(f'Cyclic layout include: {name}')
|
||||
|
||||
def substitute(match):
|
||||
key = match[1]
|
||||
if key.startswith('include:'):
|
||||
return expand(source, key[len('include:'):], tokens, (*stack, path)).rstrip('\n')
|
||||
if key not in tokens:
|
||||
raise ValueError(f'Unknown theme token {key} in {name}')
|
||||
return tokens[key]
|
||||
|
||||
return TOKEN.sub(substitute, path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def render(source=SOURCE):
|
||||
theme = json.loads((source / 'theme.json').read_text(encoding='utf-8'))
|
||||
tokens, colors = theme['layout'], theme['buttons']
|
||||
for name, value in tokens.items():
|
||||
if not re.fullmatch(r'[A-Z][A-Z_]*', name) or not isinstance(value, str):
|
||||
raise ValueError(f'Invalid theme token: {name}')
|
||||
if name == 'FONT':
|
||||
valid = re.fullmatch(r'\{[A-Fa-f0-9]{16}\}[A-Za-z0-9_/.-]+\.fnt', value)
|
||||
elif name.startswith('SIZE_'):
|
||||
valid = value.isascii() and value.isdigit() and 8 <= int(value) <= 96
|
||||
elif name.startswith('COLOR_'):
|
||||
parts = value.split()
|
||||
valid = len(parts) == 4 and all(re.fullmatch(r'\d+(?:\.\d+)?', p) and 0 <= float(p) <= 1 for p in parts)
|
||||
else:
|
||||
valid = False
|
||||
if not valid:
|
||||
raise ValueError(f'Invalid theme value: {name}')
|
||||
for name, value in colors.items():
|
||||
if not re.fullmatch(r'[A-Z][A-Z_]*', name) or not isinstance(value, str) or not re.fullmatch(r'0x[0-9A-F]{8}', value):
|
||||
raise ValueError(f'Invalid runtime color: {name}')
|
||||
required = {'NEUTRAL', 'NEUTRAL_HOVER', 'POSITIVE', 'POSITIVE_HOVER', 'NEGATIVE',
|
||||
'NEGATIVE_HOVER', 'MIXED', 'READY_TEXT', 'UNAVAILABLE_TEXT'}
|
||||
if not required <= colors.keys():
|
||||
raise ValueError('Missing runtime palette colors')
|
||||
nav_layout, nav_script, visibility = navigation(source, tokens)
|
||||
tokens = {**tokens, **visibility, 'NAVIGATION': nav_layout}
|
||||
manifest = json.loads((source / 'layouts.json').read_text(encoding='utf-8'))
|
||||
outputs = {}
|
||||
for output, template in manifest.items():
|
||||
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*\.layout', output):
|
||||
raise ValueError(f'Invalid layout output: {output}')
|
||||
outputs[LAYOUTS / output] = expand(source, template, tokens)
|
||||
outputs[PALETTE] = ('// Generated by tools/build_admin_menu.py; edit ui/admin_menu/theme.json.\n'
|
||||
'class ERR_MenuTheme\n{\n' + ''.join(
|
||||
f' static const int {name} = {value};\n' for name, value in colors.items()) + '}\n')
|
||||
outputs[PAGES] = nav_script
|
||||
return outputs
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--check', action='store_true', help='Fail on stale output without changing files')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
outputs = render() # Validate everything before writing any output.
|
||||
stale = [path for path, content in outputs.items()
|
||||
if not (ROOT / path).exists() or (ROOT / path).read_text(encoding='utf-8') != content]
|
||||
if args.check:
|
||||
if stale:
|
||||
print('Stale generated menu files; run python3 tools/build_admin_menu.py:', file=sys.stderr)
|
||||
for path in stale:
|
||||
print(path, file=sys.stderr)
|
||||
return 1
|
||||
print('Menu layouts and palette are up to date.')
|
||||
else:
|
||||
for path in stale:
|
||||
(ROOT / path).write_text(outputs[path], encoding='utf-8')
|
||||
print(f'Updated {len(stale)} menu files.')
|
||||
except (OSError, ValueError, KeyError, TypeError) as error:
|
||||
print(f'Cannot build admin menu: {error}', file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user