Files
NX2navi/launcher/ui/selftest.py
epicmeme546 3fe6965df9 Give the Custom colour scheme a Panel Color for the panels and the title band, and have Button Color change the buttons alone, their orange choices included.
Button Color recoloured the list panel, the description box and the title band, and left the orange choices in the rows as they were, so the buttons themselves seemed not to change. It now takes the rows' label cells, their value cells and the orange choices, which come out a lighter shade of the picked colour; the new Panel Color takes the panels, the dividers and the title band. Text on a light button or panel turns dark, and so does the text in choices that come out bright. The colour is kept as custom_panel in launcher.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 03:11:09 -05:00

254 lines
12 KiB
Python

"""--selftest [--shots DIR]: draw every screen with made-up states, optionally save a PNG of each, and close.
Nothing runs: the jobs are built and their steps set by hand."""
import os
import subprocess
import sys
import time
from pathlib import Path
from .. import feed, jobs, versions
def grab_gdi(x, y, w, h, path):
"""Windows: copy the screen rectangle with GDI and write it as PNG (art.png_bytes); no extra packages."""
import ctypes
from ctypes import wintypes
import numpy as np
from . import art
# private handles with every argument typed: a handle is a pointer, and one above 2^31 passed as a plain int
# failed with "int too long to convert", so some screens went without their picture
user32, gdi32 = ctypes.WinDLL('user32'), ctypes.WinDLL('gdi32')
vp = ctypes.c_void_p
user32.GetDC.restype = gdi32.CreateCompatibleDC.restype = gdi32.CreateCompatibleBitmap.restype = vp
gdi32.SelectObject.restype = vp
user32.GetDC.argtypes = gdi32.CreateCompatibleDC.argtypes = gdi32.DeleteObject.argtypes = [vp]
gdi32.DeleteDC.argtypes = [vp]
user32.ReleaseDC.argtypes = gdi32.SelectObject.argtypes = [vp, vp]
gdi32.CreateCompatibleBitmap.argtypes = [vp, ctypes.c_int, ctypes.c_int]
gdi32.BitBlt.argtypes = [vp, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, vp, ctypes.c_int,
ctypes.c_int, wintypes.DWORD]
gdi32.GetDIBits.argtypes = [vp, vp, ctypes.c_uint, ctypes.c_uint, vp, vp, ctypes.c_uint]
screen = user32.GetDC(None)
mem = gdi32.CreateCompatibleDC(screen)
bmp = gdi32.CreateCompatibleBitmap(screen, w, h)
old = gdi32.SelectObject(mem, bmp)
gdi32.BitBlt(mem, 0, 0, w, h, screen, x, y, 0x00CC0020) # SRCCOPY
class BITMAPINFOHEADER(ctypes.Structure):
_fields_ = [('biSize', wintypes.DWORD), ('biWidth', wintypes.LONG), ('biHeight', wintypes.LONG),
('biPlanes', wintypes.WORD), ('biBitCount', wintypes.WORD), ('biCompression', wintypes.DWORD),
('biSizeImage', wintypes.DWORD), ('biXPelsPerMeter', wintypes.LONG),
('biYPelsPerMeter', wintypes.LONG), ('biClrUsed', wintypes.DWORD),
('biClrImportant', wintypes.DWORD)]
bi = BITMAPINFOHEADER(ctypes.sizeof(BITMAPINFOHEADER), w, -h, 1, 32, 0, 0, 0, 0, 0, 0)
buf = (ctypes.c_ubyte * (w * h * 4))()
gdi32.GetDIBits(mem, bmp, 0, h, buf, ctypes.byref(bi), 0)
gdi32.SelectObject(mem, old)
gdi32.DeleteObject(bmp)
gdi32.DeleteDC(mem)
user32.ReleaseDC(None, screen)
a = np.frombuffer(buf, dtype=np.uint8).reshape(h, w, 4)[..., [2, 1, 0, 3]].copy()
a[..., 3] = 255
Path(path).write_bytes(art.png_bytes(a))
return True
def grab(app, path):
"""A PNG of the window: GDI on Windows, else PIL's ImageGrab, else ImageMagick's import on X11."""
app.root.update()
x, y = app.root.winfo_rootx(), app.root.winfo_rooty()
w, h = app.root.winfo_width(), app.root.winfo_height()
if os.name == 'nt':
try:
return grab_gdi(x, y, w, h, path)
except Exception as e:
app.ctx.log(f'selftest: screen capture failed: {e!r}')
try:
from PIL import ImageGrab
ImageGrab.grab(bbox=(x, y, x + w, y + h), xdisplay=os.environ.get('DISPLAY') if os.name != 'nt' else None
).save(path)
return True
except Exception:
pass
try:
subprocess.run(['import', '-window', str(app.root.winfo_id()), str(path)], check=True, capture_output=True)
return True
except (OSError, subprocess.CalledProcessError):
return False
def ahead_of(version):
"""A version a minor step past version: the made-up release must be newer than the launcher drawing it, or the
update screens show "Up to Date" instead (a fixed 0.3.11 did from 0.4.0 on)."""
try:
nums, _ = versions.parse(version)
except ValueError:
return '99.0.0'
return f'{nums[0]}.{nums[1] + 1}.0'
def fake_release(version='0.3.11'):
m = {'format': 1, 'version': version, 'app': {'name': f'NX2navi-app-{version}.zip', 'size': 1, 'sha256': '0'}}
notes = ('- Each program module comes from the .nup package that holds it whole.\n'
'- The PC stays awake while the game builds.\n- This launcher: one-click install, updates and play.')
return feed.Release(m, notes)
def states(app):
ctx = app.ctx
s = app.settings
s['dump'] = s.get('dump') or str(Path.home() / 'Downloads' / 'Ocarina of Time.nup')
s['game_dir'] = s.get('game_dir') or ('D:\\Games\\NX2navi' if os.name == 'nt' else '/games/NX2navi')
app.dumps = [Path(s['dump'])]
def first_run():
app.show('first_run', len(app.rows_first_run()) - 1)
def installing():
j = jobs.InstallJob(ctx, s['dump'], s['game_dir'])
for sid, val in (('tools', 'Done · 0:48'), ('check', 'Version 1.1.1'), ('exefs', 'Done · 0:04'),
('romfs', 'Done · 0:31')):
j.step(sid).state, j.step(sid).value, j.step(sid).fraction = jobs.DONE, val, 1.0
j.begin('lift')
j.progress('lift', 0.34, '412 of 1,204 chunks')
j.started = time.time() - 192
for ln in ('check: Ocarina of Time 1.1.1 (build e0f3ccfa)', 'romfs: 26.0 of 26.0 GB, 31 s', 'lift:',
' emitting with 16 workers', ' emitted 412/1204 chunks, 18s'):
j.lines.append(ln)
app.job, app.job_return, app.job_back = j, 'home', 'first_run'
app.show('job')
def home():
app.release, app.checked_at, app.check, app.check_error, app.message = None, time.time() - 5, None, '', ''
app.show('home', 0)
def update_ready():
app.release = fake_release(ahead_of(ctx.version))
app.show('home', 1)
def updating():
j = jobs.UpdateDownloadJob(ctx, fake_release(ahead_of(ctx.version)))
for sid, val in (('download', 'Done · 0:06'), ('verify', 'Checksum OK')):
j.step(sid).state, j.step(sid).value, j.step(sid).fraction = jobs.DONE, val, 1.0
j.skip('tools_update', 'Unchanged')
j.step('lift').state, j.step('lift').value, j.step('lift').fraction = jobs.DONE, 'Done · 0:41', 1.0
j.begin('compile_changed')
j.progress('compile_changed', 0.61, '238 of 391 changed files')
j.started = time.time() - 104
app.job, app.job_return = j, 'home'
app.show('job')
def settings():
app.show('settings', 0)
def failed():
j = jobs.InstallJob(ctx, s['dump'], s['game_dir'])
for sid, val in (('tools', 'Done · 0:48'), ('check', 'Version 1.1.1'), ('exefs', 'Done · 0:04'),
('romfs', 'Done · 0:31'), ('lift', 'Done · 0:48')):
j.step(sid).state, j.step(sid).value, j.step(sid).fraction = jobs.DONE, val, 1.0
j.step('compile').state, j.step('compile').value = jobs.FAILED, 'Stopped'
j.result, j.error = 'failed', '3 files did not compile'
j.started, j.finished = time.time() - 362, time.time()
for ln in ('compile:', ' compiled 1200 of 1212, 236 s', 'error: 3 files did not compile'):
j.lines.append(ln)
app.job, app.job_return, app.job_back = j, 'home', 'first_run'
app.show('job')
def uninstall():
app.show('uninstall', 0)
def shaders(): # the counts and the wording tools/make_game.py shader_progress sends
j = jobs.InstallJob(ctx, s['dump'], s['game_dir'])
for sid, val in (('tools', 'Done · 0:48'), ('check', 'Version 1.1.1'), ('exefs', 'Done · 0:04'),
('romfs', 'Done · 0:31'), ('lift', 'Done · 0:48'), ('compile', 'Done · 4:12')):
j.step(sid).state, j.step(sid).value, j.step(sid).fraction = jobs.DONE, val, 1.0
j.begin('shaders')
j.progress('shaders', 0.5 + 0.5 * 6214 / 20480, 'Built 6,214 of 20,480 shaders for your graphics card')
j.started = time.time() - 611
for ln in (' shaders: translated 20480 of 20480, 141 s', ' shaders: built 6214 of 20480, 402 s'):
j.lines.append(ln)
app.job, app.job_return, app.job_back = j, 'home', 'first_run'
app.show('job')
def import_saves():
app.release, app.message, app.export_message = None, '', ''
app.save_message = ('Save imported from NX2navi-save-2026-11-09_17-30-05.zip (exported 2026-11-09 with NX2navi '
'0.6.1). Your old save is in saves' + chr(92) + 'backup 2026-11-09 18.02.44.')
app.show('saves', next(i for i, r in enumerate(app.rows_saves()) if r.label == 'Import a Save'))
def export_save():
app.save_message = ''
app.export_message = ('Exported 14 files with the game' + chr(39) + 's log and settings to '
+ str(Path.home() / 'Downloads' / 'NX2navi-save-2026-11-09_17-30-05-bug-report.zip')
+ '.')
app.show('saves', 0)
def update_staged(): # a release downloaded in the background, waiting for the game to be closed
rel = fake_release(ahead_of(ctx.version))
staged = {'version': rel.version, 'runtime': '', 'prerelease': False, 'notes': rel.notes}
app.bg.ready = lambda release=None: staged
app.release, app.message = rel, ''
app.show('home', 1)
def customize():
app.bg.ready = lambda release=None: None
app.release, app.message, app.look_message = None, '', ''
app.show('customize', 0)
def color_scheme(): # another colour scheme on the home screen, then the first one again
from . import art
was = s.get('theme')
s['theme'] = list(art.THEMES)[2]
app.apply_look()
app.show('home', 0)
s['theme'] = was
def custom_picker(): # the Custom scheme with two picked colours, and its picker on the button colour
from . import art
was = {k: s.get(k) for k in ('theme', 'custom_background', 'custom_panel', 'custom_buttons')}
s.update(theme='custom', custom_background='#3a1d5c', custom_panel='#24324a', custom_buttons='#2f6fb0')
app.apply_look()
app.open_picker('buttons')
s.update(was)
art.set_theme(was['theme'], app.custom_colors()) # the next screen's colours; this one is drawn already
def restore_scheme():
app.apply_look()
app.show('home', 0)
return [('1-first-run', first_run), ('2-installing', installing), ('3-home', home),
('4-update-ready', update_ready), ('5-updating', updating), ('6-settings', settings),
('7-build-failed', failed), ('8-uninstall', uninstall), ('9-import-saves', import_saves),
('10-shaders', shaders), ('11-update-staged', update_staged), ('12-export-save', export_save),
('13-customize', customize), ('14-color-scheme', color_scheme), ('15-custom-picker', custom_picker),
('16-first-scheme', restore_scheme)]
def run(app, shots):
out = Path(shots) if shots else None
if out:
out.mkdir(parents=True, exist_ok=True)
seq = states(app)
ok = True
def step(i=0):
nonlocal ok
if i >= len(seq):
app.ctx.log(f'selftest: {len(seq)} screens drawn')
print(f'selftest: {len(seq)} screens drawn' + (f', shots in {out}' if out else ''), flush=True)
app.root.after(100, app.root.destroy)
return
name, fn = seq[i]
try:
fn()
app.root.update()
except Exception as e:
ok = False
print(f'selftest: {name}: {e!r}', file=sys.stderr, flush=True)
raise
if out:
app.root.after(350, lambda: (grab(app, out / f'{name}.png'), step(i + 1)))
else:
app.root.after(50, lambda: step(i + 1))
step()