mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
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>
2391 lines
115 KiB
Python
2391 lines
115 KiB
Python
"""The launcher's window, laid out like the game's options menu (docs/launcher.md, "Look"): a wooden title band with
|
|
Navi and the name in the game's Cetra Display across the whole top (the window's own title bar: drag it to move the
|
|
window, minimise and close at its right end), a list of rows on the left (a bronze label cell and a dark value cell each), a white
|
|
glowing cursor on the selected row, filigree dividers between groups and a description box on the right that
|
|
explains the selected row.
|
|
|
|
The window is one Tk canvas at 1000 x 640 logical pixels, scaled to the display. The pictures come from art.py; text,
|
|
flat cells and the orange choices are canvas items. Screens:
|
|
|
|
first_run the dump, the install folder, three choices and Begin Installation
|
|
home Play, Updates, the game folder, Saves, Rebuild the Game, Customize Launcher, Settings
|
|
customize the colour scheme, the player's own background picture, animations and sounds; Add to Steam
|
|
color the Custom scheme's colour picker: a colour square, a hue bar and the hex code
|
|
saves Export Save (a zip for a bug report or another player), Import a Save (such a zip), a save folder
|
|
job the steps of an install, update or rebuild, with their progress, and Cancel; after a failure Try Again
|
|
and Copy Report
|
|
settings the launcher's choices, the folders, the logs and Uninstall
|
|
uninstall what to remove, then Uninstall or Cancel
|
|
|
|
Mouse, keyboard (arrows, Enter or Space, Esc, F5) and an XInput gamepad (d-pad or stick, A, B, Y) all move the cursor.
|
|
|
|
The look is the player's (Customize Launcher): art.set_theme fills the palette every picture is drawn from, a picture
|
|
of the player's own can replace the backdrop (picture.py), and unless Animations is off the cursor glides between
|
|
rows, a new screen slides in, sparks drift over the backdrop and Navi bobs in the title band (animate). sound.py
|
|
plays the game's own decide and cancel sounds, read from the player's game, for a choice and for going back.
|
|
"""
|
|
import argparse
|
|
import colorsys
|
|
import math
|
|
import os
|
|
import random
|
|
import shutil
|
|
import sys
|
|
import time
|
|
import tkinter as tk
|
|
import tkinter.font as tkfont
|
|
from pathlib import Path
|
|
from tkinter import filedialog
|
|
|
|
from .. import NAME, build, feed, gameini, gamefiles, jobs, saves, steam, versions, winsys
|
|
from ..context import Context
|
|
from . import art, picture, sound
|
|
|
|
W, H = 1000, 640
|
|
BAND = (0, 1000, 56) # x0, x1, height: the whole top of the window, which is also its title bar
|
|
PIC_TOP = 60 # the player's background picture starts under the band and its two lines
|
|
TITLE_BUTTONS = (('min', 908, 954), ('close', 954, 1000)) # x ranges on the band
|
|
PANEL = (112, 60, 580) # x0, top, x1
|
|
ROWS = (140, 552, 96) # x0, x1, top
|
|
LABEL_W = 222 # 54 % of the row
|
|
GAP = 12
|
|
DESC = (614, 336) # x, width
|
|
P = art.PALETTE # the colour scheme in use: art.set_theme changes it in place
|
|
FRAME_MS = 15 # between two steps of the animations; they move by the time that really passed
|
|
SPARKS = 22 # the dust drifting over the backdrop
|
|
DEPTH = {'first_run': 0, 'home': 0, 'job': 1, 'saves': 1, 'settings': 1, 'customize': 1, 'uninstall': 2,
|
|
'color': 2}
|
|
# the Custom scheme's picker (draw_picker), logical pixels: its box (x, top, width, height), the colour square and
|
|
# the hue bar (x0, y0, x1, y1)
|
|
PICKER_BOX = (614, 100, 336, 452)
|
|
SQUARE = (667, 160, 897, 390)
|
|
BAR = (667, 408, 897, 430)
|
|
CUSTOM_LABELS = {'background': 'Background Color', 'panel': 'Panel Color', 'buttons': 'Button Color'}
|
|
CUSTOM_DESC = {'background': 'The color behind the menus.', 'panel': 'The color of the panels and the top bar.',
|
|
'buttons': 'The color of the buttons.'}
|
|
DIMS = (0, 20, 40, 60, 80) # Background Dimming, in percent
|
|
SOUND_LEVELS = (('off', 'Off'), ('low', 'Low'), ('medium', 'Medium'), ('high', 'High'))
|
|
|
|
|
|
class Row:
|
|
def __init__(self, kind, label='', value='', style='normal', big=False, icon=None, badge=False, desc=None,
|
|
on=None, left=None, right=None, get=None, options=None, fraction=0.0, actions=None, check=False,
|
|
focus_value=False, height=None, enabled=True):
|
|
self.kind, self.label, self.value, self.style = kind, label, value, style
|
|
self.big, self.icon, self.badge, self.desc, self.check = big, icon, badge, desc, check
|
|
self.on, self.left, self.right, self.get, self.options = on, left, right, get, options
|
|
self.fraction, self.actions, self.focus_value, self.enabled = fraction, actions or [], focus_value, enabled
|
|
default_h = {'divider': 26, 'action': 54, 'pair': 44}.get(kind, 64 if big else 40)
|
|
self.h = height or default_h
|
|
self.rect = self.value_rect = None
|
|
self.parts = [] # pair: [(rect, label, callback)]
|
|
|
|
@property
|
|
def focusable(self):
|
|
return self.kind != 'divider'
|
|
|
|
|
|
def fit(font, text, width):
|
|
"""text shortened in the middle with an ellipsis so it fits width pixels."""
|
|
if font.measure(text) <= width:
|
|
return text
|
|
lo, hi = 1, len(text)
|
|
while lo < hi:
|
|
mid = (lo + hi + 1) // 2
|
|
keep = mid // 2
|
|
cand = text[:keep] + '…' + text[len(text) - (mid - keep):]
|
|
if font.measure(cand) <= width:
|
|
lo = mid
|
|
else:
|
|
hi = mid - 1
|
|
keep = lo // 2
|
|
return text[:keep] + '…' + text[len(text) - (lo - keep):]
|
|
|
|
|
|
def free_gb(path):
|
|
p = Path(path)
|
|
while not p.exists() and p.parent != p:
|
|
p = p.parent
|
|
try:
|
|
return shutil.disk_usage(p).free / 1e9
|
|
except OSError:
|
|
return 0.0
|
|
|
|
|
|
# Exit codes that end the game, and what they mean. The game exits with 70 after a FATAL line of its own: an error it
|
|
# cannot go on from, most often an allocation that failed because the memory ran out (runtime/dispatch.c rt_fatal), or
|
|
# a failed re-run of itself (runtime/platform/win32.c plat_reexec). Its crash report exits with 71 after writing its
|
|
# FATAL lines (runtime/main.c crash_handler). The others come from Windows when something ends the process before or
|
|
# around that report.
|
|
EXIT_CODES = {
|
|
70: 'a fatal error: the FATAL line near the end of its log says which, often memory that ran out',
|
|
71: 'the crash report ended the game',
|
|
0xC0000005: 'access violation',
|
|
0xC00000FD: 'stack overflow',
|
|
0xC0000142: 'a DLL failed to start',
|
|
0xC0000374: 'heap corruption',
|
|
0xC0000409: 'fail-fast: a security check or abort ended the process without an exception',
|
|
0xC0000417: 'invalid parameter to a C runtime function',
|
|
0xC000041D: 'a fault inside a Windows callback',
|
|
0xE06D7363: 'a C++ exception no one caught',
|
|
}
|
|
|
|
|
|
def exit_code_text(code):
|
|
"""The exit code as the game's log and Windows show it: hex for a Windows status, with its meaning when known."""
|
|
code &= 0xFFFFFFFF
|
|
meaning = EXIT_CODES.get(code)
|
|
shown = f'0x{code:08X}' if code >= 0x80000000 else str(code)
|
|
return f'{shown} ({meaning})' if meaning else shown
|
|
|
|
|
|
def note_exit_in_log(game_dir, text):
|
|
"""Adds the exit code to the end of the game's log, so a log a player sends shows how the run ended even when the
|
|
process died before it could write a line itself."""
|
|
if not game_dir:
|
|
return
|
|
log = Path(game_dir) / 'logs' / 'nx2navi.log'
|
|
try:
|
|
with open(log, 'a', encoding='utf-8') as f:
|
|
f.write(f'[launcher] the game ended with code {text} at {time.strftime("%Y-%m-%d %H:%M:%S")}\n')
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def drive_of(path):
|
|
return Path(path).anchor.rstrip('\\/') or str(Path(path).parent)
|
|
|
|
|
|
class App:
|
|
def __init__(self, root, ctx, args):
|
|
self.root, self.ctx, self.args = root, ctx, args
|
|
self.layout, self.settings = ctx.layout, ctx.settings
|
|
self.s = self.compute_scale()
|
|
self.sprites = {}
|
|
self.photos = {}
|
|
self.rows, self.focus = [], 0
|
|
self.screen = None
|
|
self.job = None
|
|
self.job_rev = -1
|
|
self.job_return = 'home'
|
|
self.check = None
|
|
self.release = None
|
|
self.check_error = ''
|
|
self.checked_at = 0.0
|
|
self.check_started = 0.0
|
|
# the update fetched in the background and installed when the game is not running; a checkout and the
|
|
# selftest have nothing to switch to
|
|
self.bg = jobs.BackgroundUpdate(ctx, lambda a: winsys.restart(self.layout, a),
|
|
enabled=not self.layout.dev and not args.selftest)
|
|
self.row_gap = GAP
|
|
self.message = ''
|
|
self.look_message = '' # why the last background picture was not taken, in Customize Launcher's descriptions
|
|
self.save_message = '' # the last import's result, shown in the import rows' descriptions
|
|
self.export_message = '' # the last Export Save result, shown in its description
|
|
self.steam_message = '' # the last Add to Steam result, shown in its description
|
|
self.steam_busy = False # Add to Steam is making the pictures or writing Steam's files
|
|
self.pick = None # the colour picker: {'slot', 'h', 's', 'v'} (HSV in 0..1) of the colour being picked
|
|
self.pick_drag = None # 'square' or 'bar' while the mouse drags on the picker
|
|
self.hex_entry = None # the box the hex code is typed into
|
|
self.look_after = None # the redraw a picked colour waits for (look_soon)
|
|
self.dumps = None
|
|
self.dump_search = None
|
|
self.uninstall_game = False
|
|
self.game_proc = None
|
|
self.cursor_frames, self.cursor_k = [], 0
|
|
self.last_second = 0
|
|
self.gamepad = winsys.Gamepad()
|
|
self.tb_hover = None
|
|
self.drag = None
|
|
self.ico = None
|
|
# the look: the colour scheme, the player's background picture at the window's size (None: the scheme's own
|
|
# backdrop), the sounds (none while the selftest draws the screens)
|
|
art.set_theme(self.settings.get('theme'), self.custom_colors())
|
|
self.bg_store = self.layout.data / 'background.npy'
|
|
self.picture = None
|
|
self.sounds = sound.Sounds(self.layout.cache, 'off' if args.selftest else self.settings.get('sounds'), ctx.log)
|
|
# the animations' state (animate): the clock and the last step's time, the cursor's place on its way to the
|
|
# focused row, how far the screen still stands from its place after a change, the sparks, Navi and its halo
|
|
self.anim_t = 0.0
|
|
self.anim_last = time.perf_counter()
|
|
self.cur_xy = self.cur_target = None
|
|
self.cur_screen = None
|
|
self.slide = 0.0
|
|
self.slide_applied = 0
|
|
self.sparks = []
|
|
self.navi_xy = None
|
|
self.halo = []
|
|
|
|
root.title(NAME)
|
|
root.resizable(False, False)
|
|
root.configure(background=P['window'])
|
|
self.canvas = tk.Canvas(root, width=self.X(W), height=self.X(H), highlightthickness=0, bd=0,
|
|
background=P['window'], cursor='arrow')
|
|
self.canvas.pack()
|
|
self.set_icon()
|
|
self.load_fonts()
|
|
self.load_picture()
|
|
self.draw_static()
|
|
self.bind_input()
|
|
self.start()
|
|
self.root.after(80, self.animate)
|
|
self.root.after(150, self.tick)
|
|
self.root.after(50, self.poll_gamepad)
|
|
self.root.after(60000, self.minute)
|
|
self.center()
|
|
self.framed = not winsys.frameless(root) # Windows: the band is the title bar
|
|
self.root.after(60, self.native_window)
|
|
|
|
# ---------------------------------------------------------------- scale, icon, fonts
|
|
|
|
def compute_scale(self):
|
|
try:
|
|
s = self.root.winfo_fpixels('1i') / 96.0
|
|
except tk.TclError:
|
|
s = 1.0
|
|
sw, sh = self.root.winfo_screenwidth(), self.root.winfo_screenheight()
|
|
s = min(max(s, 1.0), sw * 0.94 / W, sh * 0.88 / H)
|
|
return max(0.75, round(s * 4) / 4)
|
|
|
|
def X(self, v):
|
|
return int(round(v * self.s))
|
|
|
|
def center(self):
|
|
self.root.update_idletasks()
|
|
w, h = self.X(W), self.X(H)
|
|
x = max(0, (self.root.winfo_screenwidth() - w) // 2)
|
|
y = max(0, (self.root.winfo_screenheight() - h) // 3)
|
|
self.root.geometry(f'{w}x{h}+{x}+{y}')
|
|
|
|
def set_icon(self):
|
|
"""Navi as the window's icon: Tk's photo icon everywhere, and on Windows the .ico set on the window itself, so
|
|
the taskbar button shows Navi rather than Python's icon."""
|
|
try:
|
|
self.photos['icon'] = art.photo(self.root, art.navi_icon(64))
|
|
self.root.iconphoto(True, self.photos['icon'])
|
|
if os.name == 'nt':
|
|
data = art.navi_ico()
|
|
self.ico = self.layout.cache / 'nx2navi.ico'
|
|
if not self.ico.is_file() or self.ico.read_bytes() != data:
|
|
self.ico.write_bytes(data)
|
|
except (tk.TclError, OSError) as e:
|
|
self.ctx.log(f'icon: {e}')
|
|
|
|
def native_window(self):
|
|
if self.ico:
|
|
winsys.set_window_icon(self.root, self.ico)
|
|
try:
|
|
self.root.focus_force()
|
|
except tk.TclError:
|
|
pass
|
|
|
|
def load_fonts(self):
|
|
"""The game's Cetra Display when this PC has decoded it from the player's game (data/cache/fonts), else the
|
|
system's interface font."""
|
|
fams = {}
|
|
for role, (path, family) in gamefiles.cached_fonts(self.layout).items():
|
|
if winsys.register_font(path):
|
|
fams[role] = family
|
|
available = set(tkfont.families(self.root))
|
|
fallback = next((f for f in ('Segoe UI', 'Source Sans 3', 'Noto Sans', 'DejaVu Sans', 'Helvetica')
|
|
if f in available), 'TkDefaultFont')
|
|
# Every text is in the game's Cetra Display; only the build log keeps a monospaced face so its lines align.
|
|
text = title = fams.get('title') if fams.get('title') in available else fallback
|
|
mono = next((f for f in ('Consolas', 'Cascadia Mono', 'DejaVu Sans Mono', 'Courier New') if f in available),
|
|
'TkFixedFont')
|
|
self.game_fonts = bool(fams.get('title') and fams['title'] in available)
|
|
self.ctx.log(f'fonts: {text}' + (' (the game\'s, from data/cache/fonts)' if self.game_fonts else
|
|
' (the game\'s Cetra Display is not decoded yet)'))
|
|
|
|
self.fspec = {}
|
|
|
|
def F(family, px, weight='normal'):
|
|
font = tkfont.Font(root=self.root, family=family, size=-max(8, int(round(px * self.s))), weight=weight)
|
|
self.fspec[id(font)] = (family, max(8, int(round(px * self.s))), weight)
|
|
return font
|
|
self.fcache = {}
|
|
self.f = {'band': F(title, 25), 'big': F(text, 23), 'big_small': F(text, 21), 'label': F(text, 17),
|
|
'value': F(text, 16), 'desc': F(text, 17), 'small': F(text, 15), 'heading': F(title, 21),
|
|
'sub': F(title, 17), 'action': F(text, 18), 'status': F(text, 15), 'mono': F(mono, 11.5),
|
|
'badge': F(text, 15, 'bold'), 'action_big': F(text, 21)}
|
|
|
|
def fit_font(self, font, text, width, smallest=0.72):
|
|
"""font, or the same face a few pixels smaller so text fits width (down to smallest of its size)."""
|
|
if font.measure(text) <= width:
|
|
return font
|
|
family, px, weight = self.fspec[id(font)]
|
|
for size in range(px - 1, max(7, int(px * smallest)) - 1, -1):
|
|
key = (family, size, weight)
|
|
if key not in self.fcache:
|
|
self.fcache[key] = tkfont.Font(root=self.root, family=family, size=-size, weight=weight)
|
|
if self.fcache[key].measure(text) <= width:
|
|
return self.fcache[key]
|
|
return self.fcache[(family, max(7, int(px * smallest)), weight)]
|
|
|
|
def fonts_from_game(self, game_dir='', dump=''):
|
|
"""Decode the game's fonts and its interface sounds in the background, redraw with the fonts and take up
|
|
the sounds."""
|
|
fonts, sounds = not self.game_fonts, not self.sounds.game
|
|
if not fonts and not sounds:
|
|
return
|
|
|
|
def work():
|
|
got = heard = {}
|
|
try:
|
|
if fonts:
|
|
got = gamefiles.extract_fonts(self.layout, game_dir, dump)
|
|
except Exception as e:
|
|
self.ctx.log(f'fonts: {e}')
|
|
try:
|
|
if sounds:
|
|
heard = gamefiles.extract_sounds(self.layout, game_dir, dump)
|
|
self.ctx.log(f'sounds: {len(heard)} of {len(gamefiles.SOUNDS)} interface sounds from the game')
|
|
except Exception as e:
|
|
self.ctx.log(f'sounds: {e}')
|
|
if got:
|
|
self.root.after(0, self.reload_fonts)
|
|
if heard:
|
|
self.root.after(0, self.sounds.load_game)
|
|
import threading
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def reload_fonts(self):
|
|
self.load_fonts()
|
|
self.draw_static()
|
|
self.render()
|
|
|
|
# ---------------------------------------------------------------- pictures
|
|
|
|
def sprite(self, key, make):
|
|
if key not in self.sprites:
|
|
self.sprites[key] = art.photo(self.root, make())
|
|
return self.sprites[key]
|
|
|
|
def animated(self):
|
|
"""Animations on (Customize Launcher); never while the selftest draws the screens, whose pictures must not
|
|
depend on the moment they are taken."""
|
|
return bool(self.settings.get('animations')) and not self.args.selftest
|
|
|
|
def load_picture(self):
|
|
"""The player's background at the size of the window below the title band, when one is set and its kept copy
|
|
is readable. A copy an older launcher kept for the whole window is made again from the picture when the file
|
|
is still there, so no more of it is cut off than the new shape needs."""
|
|
self.picture = None
|
|
src = self.settings.get('background')
|
|
if src:
|
|
if picture.kept_shape(self.bg_store) not in (None, (picture.KEEP_H, picture.KEEP_W)) \
|
|
and Path(src).is_file():
|
|
try:
|
|
picture.keep(src, self.bg_store, self.root)
|
|
except (picture.PictureError, OSError, MemoryError, ValueError) as e:
|
|
self.ctx.log(f'background: {src}: {e!r}; the copy kept before stays')
|
|
self.picture = picture.kept(self.bg_store, self.X(W), self.X(H) - self.X(PIC_TOP))
|
|
if self.picture is None:
|
|
self.ctx.log(f'background: {self.bg_store} is missing or unreadable; the colour scheme\'s backdrop')
|
|
|
|
def see_through(self):
|
|
"""How solid the panel and the description box are: a little see-through over the player's own picture."""
|
|
return 0.8 if self.picture is not None else 1.0
|
|
|
|
def draw_static(self):
|
|
c, s = self.canvas, self.s
|
|
c.delete('static')
|
|
dim = max(0, min(100, self.settings.get('background_dim', 40))) / 100.0
|
|
c.create_image(0, 0, anchor='nw', tags='static',
|
|
image=self.sprite('backdrop', lambda: art.backdrop(self.X(W), self.X(H), s, self.picture, dim,
|
|
self.X(PIC_TOP))))
|
|
self.make_sparks() # over the backdrop, under the panel and the band
|
|
bx0, bx1, bh = BAND
|
|
px0, ptop, px1 = PANEL
|
|
c.create_image(self.X(px0), self.X(ptop), anchor='nw', tags='static',
|
|
image=self.sprite('panel', lambda: art.panel(self.X(px1) - self.X(px0), self.X(H - ptop), s,
|
|
self.see_through())))
|
|
c.create_image(self.X(bx0), 0, anchor='nw', tags='static',
|
|
image=self.sprite('band', lambda: art.band(self.X(bx1) - self.X(bx0), self.X(bh), s)))
|
|
k = max(1, int(round(2 * s))) # Navi in whole pixels: two screen pixels per artwork pixel at 100 %
|
|
emblem = self.sprite(('navi', k), lambda: art.navi(k))
|
|
ew = emblem.width()
|
|
title_w = self.f['band'].measure(NAME)
|
|
gap = self.X(12)
|
|
x = self.X(W / 2) - (ew + gap + title_w) // 2
|
|
# Navi's halo, behind her: a soft light that pulses while the animations run
|
|
size = self.X(bh) - 2
|
|
if ('halo', size) not in self.sprites:
|
|
self.sprites[('halo', size)] = [art.photo(self.root, f) for f in art.halo_frames(size)]
|
|
self.halo = self.sprites[('halo', size)]
|
|
c.create_image(x + ew // 2, self.X(bh / 2), image=self.halo[0], tags=('static', 'halo'))
|
|
self.navi_xy = (x, self.X(bh / 2))
|
|
c.create_image(x, self.X(bh / 2), anchor='w', image=emblem, tags=('static', 'navi'))
|
|
tx = x + ew + gap
|
|
c.create_text(tx + 1, self.X(bh / 2) + self.X(2), text=NAME, anchor='w', font=self.f['band'],
|
|
fill=P['band_shadow'], tags='static')
|
|
c.create_text(tx, self.X(bh / 2), text=NAME, anchor='w', font=self.f['band'], fill=P['band_text'],
|
|
tags='static')
|
|
c.create_rectangle(0, 0, self.X(W) - 1, self.X(H) - 1, outline=P['frame'], tags='static')
|
|
c.tag_lower('static')
|
|
self.draw_title_buttons()
|
|
|
|
def make_sparks(self):
|
|
"""The dust over the backdrop: SPARKS soft dots of three sizes, each with its own speed, sway and twinkle, at
|
|
places of their own from the first frame on. None while Animations is off."""
|
|
self.sparks = []
|
|
if not self.animated():
|
|
return
|
|
rnd = random.Random(7)
|
|
for i in range(SPARKS):
|
|
size = self.X((5, 8, 12)[i % 3])
|
|
pics = [self.sprite(('spark', size, a), lambda sz=size, al=a: art.spark(sz, al)) for a in (0.9, 0.55, 0.25)]
|
|
# logical pixels: its speed upwards a second, how far it sways to each side and how fast
|
|
sp = {'x': rnd.uniform(0, W), 'y': rnd.uniform(BAND[2], H), 'vy': rnd.uniform(4, 14),
|
|
'sway': rnd.uniform(4, 16), 'rate': rnd.uniform(0.4, 1.1), 'phase': rnd.uniform(0, 6.28),
|
|
'pics': pics, 'shown': 0}
|
|
sp['id'] = self.canvas.create_image(self.X(sp['x']), self.X(sp['y']), image=pics[0], tags=('static', 'spark'))
|
|
self.sparks.append(sp)
|
|
|
|
def apply_look(self):
|
|
"""Draw everything again after the colour scheme, the background or Animations changed."""
|
|
art.set_theme(self.settings.get('theme'), self.custom_colors())
|
|
self.sprites.clear()
|
|
self.root.configure(background=P['window'])
|
|
self.canvas.configure(background=P['window'])
|
|
self.load_picture()
|
|
self.draw_static()
|
|
self.render()
|
|
|
|
def draw_title_buttons(self):
|
|
"""Minimise and close at the band's right end, lit when the mouse is over them."""
|
|
c, X = self.canvas, self.X
|
|
c.delete('tb')
|
|
bh = X(BAND[2])
|
|
for kind, x0, x1 in TITLE_BUTTONS:
|
|
if self.tb_hover == kind:
|
|
c.create_rectangle(X(x0), 0, X(x1), bh - 1, outline='', tags='tb',
|
|
fill=P['close_hover'] if kind == 'close' else P['button_hover'])
|
|
size = X(12)
|
|
glyph = self.sprite(('glyph', kind, size), lambda kd=kind: art.title_glyph(kd, size, self.s))
|
|
c.create_image((X(x0) + X(x1)) // 2, bh // 2, image=glyph, tags='tb')
|
|
|
|
def title_button_at(self, x, y):
|
|
if y >= self.X(BAND[2]):
|
|
return None
|
|
for kind, x0, x1 in TITLE_BUTTONS:
|
|
if self.X(x0) <= x < self.X(x1):
|
|
return kind
|
|
return None
|
|
|
|
# ---------------------------------------------------------------- input
|
|
|
|
def bind_input(self):
|
|
r = self.root
|
|
for key, fn in (('<Up>', lambda e: self.move(-1)), ('<Down>', lambda e: self.move(1)),
|
|
('<Left>', lambda e: self.side(-1)), ('<Right>', lambda e: self.side(1)),
|
|
('<Return>', lambda e: self.activate()), ('<KP_Enter>', lambda e: self.activate()),
|
|
('<space>', lambda e: self.activate()), ('<Escape>', lambda e: self.back()),
|
|
('<F5>', lambda e: self.check_now(force=True))):
|
|
r.bind(key, fn)
|
|
self.canvas.bind('<Motion>', self.on_motion)
|
|
self.canvas.bind('<Button-1>', self.on_click)
|
|
self.canvas.bind('<B1-Motion>', self.on_drag)
|
|
self.canvas.bind('<ButtonRelease-1>', self.on_release)
|
|
self.canvas.bind('<Leave>', lambda e: self.set_tb_hover(None))
|
|
r.bind('<Alt-F4>', lambda e: self.on_close())
|
|
r.protocol('WM_DELETE_WINDOW', self.on_close)
|
|
|
|
def poll_gamepad(self):
|
|
try:
|
|
for b in self.gamepad.poll(winsys.in_front(self.root)):
|
|
{'up': lambda: self.move(-1), 'down': lambda: self.move(1), 'left': lambda: self.side(-1),
|
|
'right': lambda: self.side(1), 'a': self.activate, 'b': self.back,
|
|
'y': lambda: self.check_now(force=True)}.get(b, lambda: None)()
|
|
except Exception as e:
|
|
self.ctx.log(f'gamepad: {e}')
|
|
self.gamepad.dll = None
|
|
self.root.after(50, self.poll_gamepad)
|
|
|
|
def focusables(self):
|
|
return [i for i, r in enumerate(self.rows) if r.focusable]
|
|
|
|
def move(self, d):
|
|
idx = self.focusables()
|
|
if not idx:
|
|
return
|
|
pos = idx.index(self.focus) if self.focus in idx else 0
|
|
self.focus = idx[max(0, min(len(idx) - 1, pos + d))]
|
|
self.render()
|
|
|
|
def focused(self):
|
|
return self.rows[self.focus] if 0 <= self.focus < len(self.rows) else None
|
|
|
|
def side(self, d):
|
|
r = self.focused()
|
|
if not r:
|
|
return
|
|
if r.kind == 'toggle':
|
|
r.on(d < 0) # left = On, right = Off
|
|
elif r.kind == 'enum':
|
|
(r.left if d < 0 else r.right)()
|
|
elif r.kind == 'pair':
|
|
r.pick = max(0, min(len(r.actions) - 1, getattr(r, 'pick', 0) + d))
|
|
else:
|
|
return
|
|
if r.kind != 'pair': # a switch or a choice changed
|
|
self.sounds.play('select')
|
|
self.render()
|
|
|
|
def activate(self):
|
|
r = self.focused()
|
|
if not r or not r.enabled:
|
|
return
|
|
if r.kind == 'toggle':
|
|
self.sounds.play('select')
|
|
r.on(not r.get())
|
|
elif r.kind == 'enum':
|
|
self.sounds.play('select')
|
|
r.right()
|
|
elif r.kind == 'pair':
|
|
self.sounds.play('select')
|
|
r.actions[getattr(r, 'pick', 0)][1]()
|
|
return
|
|
elif r.on:
|
|
self.sounds.play('back' if r.kind == 'action' and r.label == 'Back' else 'select')
|
|
r.on()
|
|
return
|
|
self.render()
|
|
|
|
def home_row(self, label):
|
|
"""The index of the home screen's row with this label, for coming back to it."""
|
|
return next((i for i, r in enumerate(self.rows_home()) if r.label == label), None)
|
|
|
|
def back(self):
|
|
if self.screen in ('settings', 'uninstall', 'saves', 'customize', 'job', 'color'):
|
|
self.sounds.play('back')
|
|
if self.screen == 'color':
|
|
self.pick_done()
|
|
elif self.screen == 'settings':
|
|
self.show('home', self.home_row('Settings'))
|
|
elif self.screen == 'uninstall':
|
|
self.show('settings')
|
|
elif self.screen == 'saves':
|
|
self.show('home', self.home_row('Saves'))
|
|
elif self.screen == 'customize':
|
|
self.show('home', self.home_row('Customize Launcher'))
|
|
elif self.screen == 'job' and self.job and self.job.result is None:
|
|
self.cancel_job()
|
|
elif self.screen == 'job':
|
|
self.leave_job()
|
|
|
|
def hit(self, x, y):
|
|
for i, r in enumerate(self.rows):
|
|
if r.rect and r.focusable:
|
|
x0, y0, x1, y1 = r.rect
|
|
if x0 <= x <= x1 and y0 - self.X(self.row_gap / 2) <= y <= y1 + self.X(self.row_gap / 2):
|
|
return i
|
|
return None
|
|
|
|
def set_tb_hover(self, kind):
|
|
if kind != self.tb_hover:
|
|
self.tb_hover = kind
|
|
self.draw_title_buttons()
|
|
|
|
def on_drag(self, e):
|
|
if self.pick_drag:
|
|
self.picker_at(e.x, e.y, self.pick_drag)
|
|
return
|
|
if self.drag: # moving the window without the system's help (Linux)
|
|
dx, dy = self.drag
|
|
self.root.geometry(f'+{e.x_root - dx}+{e.y_root - dy}')
|
|
|
|
def on_motion(self, e):
|
|
self.set_tb_hover(self.title_button_at(e.x, e.y))
|
|
i = self.hit(e.x, e.y)
|
|
if i is not None and i != self.focus:
|
|
self.focus = i
|
|
r = self.rows[i]
|
|
if r.kind == 'pair':
|
|
r.pick = 0 if e.x < (r.rect[0] + r.rect[2]) / 2 else 1
|
|
self.render()
|
|
elif i is not None and self.rows[i].kind == 'pair':
|
|
r = self.rows[i]
|
|
pick = 0 if e.x < (r.rect[0] + r.rect[2]) / 2 else 1
|
|
if pick != getattr(r, 'pick', 0):
|
|
r.pick = pick
|
|
self.render()
|
|
|
|
def on_click(self, e):
|
|
try:
|
|
self.root.focus_force()
|
|
except tk.TclError:
|
|
pass
|
|
kind = self.title_button_at(e.x, e.y)
|
|
if kind == 'close':
|
|
self.on_close()
|
|
return
|
|
if kind == 'min':
|
|
self.set_tb_hover(None)
|
|
winsys.minimize(self.root)
|
|
return
|
|
if e.y < self.X(BAND[2]): # the band moves the window, like a title bar
|
|
if not winsys.start_drag(self.root):
|
|
self.drag = (e.x_root - self.root.winfo_x(), e.y_root - self.root.winfo_y())
|
|
return
|
|
if self.screen == 'color':
|
|
part = self.picker_at(e.x, e.y)
|
|
if part:
|
|
self.pick_drag = part
|
|
return
|
|
i = self.hit(e.x, e.y)
|
|
if i is None:
|
|
return
|
|
self.focus = i
|
|
r = self.rows[i]
|
|
if not r.enabled:
|
|
self.render()
|
|
return
|
|
if r.kind == 'toggle' and r.value_rect:
|
|
x0, _, x1, _ = r.value_rect
|
|
self.sounds.play('select')
|
|
r.on(e.x < (x0 + x1) / 2)
|
|
self.render()
|
|
elif r.kind == 'enum' and r.value_rect:
|
|
x0, _, x1, _ = r.value_rect
|
|
self.sounds.play('select')
|
|
(r.left if e.x < x0 + (x1 - x0) / 3 else r.right)()
|
|
self.render()
|
|
elif r.kind == 'pair':
|
|
r.pick = 0 if e.x < (r.rect[0] + r.rect[2]) / 2 else 1
|
|
self.sounds.play('select')
|
|
r.actions[r.pick][1]()
|
|
else:
|
|
self.activate()
|
|
|
|
def on_release(self, e):
|
|
self.drag = None
|
|
if self.pick_drag:
|
|
self.pick_drag = None
|
|
self.pick_changed(final=True)
|
|
|
|
def on_close(self):
|
|
if self.job and self.job.result is None:
|
|
self.job.cancel()
|
|
if self.bg.staging(): # what it downloaded is resumed, and its unfinished folder replaced, next time
|
|
self.bg.job.cancel()
|
|
self.sounds.stop()
|
|
self.root.destroy()
|
|
|
|
# ---------------------------------------------------------------- screens
|
|
|
|
def start(self):
|
|
a = self.args
|
|
gd = self.settings.get('game_dir')
|
|
if a.rolled_back:
|
|
self.ctx.log(f'the stub went back from {a.rolled_back} to {self.layout.version}')
|
|
self.settings['rolled_back'] = versions.display(a.rolled_back) # never installed again by itself
|
|
self.settings.save()
|
|
for old in self.layout.root.glob('python.old*'): # the Python an update replaced (launcher/native/nx2navi.c)
|
|
shutil.rmtree(old, ignore_errors=True)
|
|
if gd or self.settings.get('dump'):
|
|
self.fonts_from_game(gd, self.settings.get('dump'))
|
|
if a.uninstall:
|
|
self.show('uninstall')
|
|
elif a.continue_update is not None and not a.rolled_back:
|
|
# after a rollback the stub passes the failed version's --continue-update on to this one: the game was
|
|
# never rebuilt for the new version, so there is nothing to finish, and home tells why the version is back
|
|
self.run_job(jobs.ContinueUpdateJob(self.ctx, gd if gd and build.built_version(gd) else '',
|
|
a.continue_update, self.settings.get('dump')), 'home')
|
|
elif gd and build.built_version(gd):
|
|
self.show('home')
|
|
self.after_update_notice()
|
|
if self.install_staged('a staged update waited for the launcher to start'):
|
|
return
|
|
if self.settings.get('auto_check'):
|
|
self.check_now()
|
|
else:
|
|
self.show('first_run')
|
|
self.find_dumps()
|
|
|
|
def after_update_notice(self):
|
|
if self.args.rolled_back:
|
|
self.message = (f'Version {self.args.rolled_back} did not start, so NX2navi went back to '
|
|
f'{versions.display(self.layout.version)}.')
|
|
|
|
def show(self, screen, focus=None):
|
|
if screen != self.screen and self.screen is not None and self.animated():
|
|
# another screen slides into place: from the right when it lies deeper, from the left on the way back
|
|
deeper = DEPTH.get(screen, 1) >= DEPTH.get(self.screen, 0)
|
|
self.slide = float(self.X(44) if deeper else -self.X(44))
|
|
self.close_hex()
|
|
self.screen = screen
|
|
self.rows = getattr(self, 'rows_' + screen)()
|
|
idx = self.focusables()
|
|
self.focus = focus if focus is not None and focus in idx else (idx[0] if idx else 0)
|
|
if screen == 'job' and focus is None:
|
|
self.focus = self.job_focus()
|
|
self.render()
|
|
|
|
# ---- first run
|
|
|
|
def find_dumps(self):
|
|
import threading
|
|
extra = [self.args.source] if self.args.source else []
|
|
extra.append(str(self.layout.root.parent))
|
|
|
|
def work():
|
|
try:
|
|
found = gamefiles.find_dumps(self.layout, extra)
|
|
except Exception as e:
|
|
self.ctx.log(f'dump search: {e}')
|
|
found = []
|
|
self.root.after(0, lambda: self.dumps_found(found))
|
|
self.dump_search = threading.Thread(target=work, daemon=True)
|
|
self.dump_search.start()
|
|
|
|
def dumps_found(self, found):
|
|
self.dumps = found
|
|
if found and not self.settings.get('dump'):
|
|
self.settings['dump'] = str(found[0])
|
|
self.settings.save()
|
|
self.fonts_from_game('', str(found[0]))
|
|
if self.screen == 'first_run':
|
|
self.show('first_run', self.focus)
|
|
|
|
def game_dir(self):
|
|
return self.settings.get('game_dir') or str(self.layout.default_game_dir())
|
|
|
|
def rows_first_run(self):
|
|
dump = self.settings.get('dump')
|
|
if dump:
|
|
dump_value, dump_style, check = Path(dump).name, 'normal', True
|
|
where = str(Path(dump).parent)
|
|
dump_desc = (f'Your dump: {Path(dump).name}, in {where}. Select this row to choose another .nup file.',)
|
|
elif self.dumps is None:
|
|
dump_value, dump_style, check = 'Looking for your dump…', 'dim', False
|
|
dump_desc = ('Looking for your dump of Ocarina of Time in Downloads, Desktop, Documents and the folder '
|
|
'the setup ran from.',)
|
|
else:
|
|
dump_value, dump_style, check = 'Choose your .nup file', 'salmon', False
|
|
dump_desc = ('Choose your own dump of Ocarina of Time with the 1.1.1 update, as one .nup file. None was '
|
|
'found in Downloads, Desktop or Documents.',)
|
|
gd = self.game_dir()
|
|
need = 30
|
|
free = free_gb(gd)
|
|
folder_desc = (f'The game is built into this folder. {free:.0f} GB free on {drive_of(gd)}; the game needs '
|
|
f'about {need} GB. Select this row to choose another folder.',)
|
|
ready = bool(dump)
|
|
begin_desc = (f'Build Ocarina of Time on this PC from your own dump. It takes 10 to 25 minutes, once, and '
|
|
f'downloads 170 MB of build tools. The game needs about {need} GB on {drive_of(gd)} '
|
|
f'({free:.0f} GB free).',) if ready else \
|
|
('Choose your dump first: select Game Dump above.',)
|
|
return [
|
|
Row('value', 'Game Dump', dump_value, dump_style, big=True, check=check, on=self.choose_dump,
|
|
desc=dump_desc),
|
|
Row('value', 'Install Folder', gd, big=True, on=self.choose_folder, desc=folder_desc),
|
|
Row('divider'),
|
|
self.toggle('Desktop Shortcut', 'desktop_shortcut',
|
|
'Put an NX2navi shortcut on the desktop. The Start menu always has one.'),
|
|
self.toggle('Check for Updates at Start', 'auto_check',
|
|
'Look for a new version of NX2navi when the launcher opens, and every hour while it stays '
|
|
'open. A new version downloads in the background and installs when you are not playing; '
|
|
'Settings can make it wait until you choose Updates.'),
|
|
self.channel_row(),
|
|
Row('divider'),
|
|
Row('action', 'Begin Installation', on=self.begin_install, desc=begin_desc, enabled=True),
|
|
]
|
|
|
|
def toggle(self, label, key, desc):
|
|
def on(v):
|
|
self.settings[key] = bool(v)
|
|
self.settings.save()
|
|
if key == 'desktop_shortcut':
|
|
winsys.desktop_shortcut(self.layout, bool(v))
|
|
return Row('toggle', label, on=on, get=lambda: bool(self.settings.get(key)), desc=(desc,))
|
|
|
|
def channel_row(self):
|
|
def flip():
|
|
self.settings['channel'] = 'preview' if self.settings.get('channel') == 'stable' else 'stable'
|
|
self.settings.save()
|
|
self.release, self.checked_at = None, 0
|
|
return Row('enum', 'Update Channel', options=('Stable', 'Preview'),
|
|
get=lambda: 'Preview' if self.settings.get('channel') == 'preview' else 'Stable',
|
|
left=flip, right=flip,
|
|
desc=('Stable gets each finished release. Preview also gets test builds before they are '
|
|
'released, which may have new problems.',))
|
|
|
|
def discord_row(self, game_dir):
|
|
"""The game's discord_rpc setting (runtime/discord_rpc.c), kept in the game's nx2navi.ini as the game's own
|
|
menu keeps it; the game reads it when it starts. On unless the file says 0, as the game reads it (cfg_bool,
|
|
default on)."""
|
|
def on(v):
|
|
try:
|
|
gameini.put(game_dir, 'discord', 'discord_rpc', '1' if v else '0')
|
|
except OSError as e:
|
|
self.message = f'The game\'s settings could not be written: {e}'
|
|
|
|
def get():
|
|
try:
|
|
return gameini.get(game_dir, 'discord_rpc') != '0'
|
|
except OSError: # nx2navi.ini there but unreadable: the game's default
|
|
return True
|
|
return Row('toggle', 'Discord Status', on=on, get=get,
|
|
desc=('Show "Playing NX2navi" with your play time in your Discord status while the game runs. '
|
|
'The game\'s menu switches it too (Game tab).',))
|
|
|
|
def choose_dump(self):
|
|
start = str(Path(self.settings.get('dump')).parent) if self.settings.get('dump') else str(Path.home())
|
|
path = filedialog.askopenfilename(parent=self.root, title='Choose your Ocarina of Time dump',
|
|
initialdir=start, filetypes=[('Game dump', '*.nup'), ('All files', '*.*')])
|
|
if not path:
|
|
return
|
|
if not gamefiles.is_game_nup(self.layout, path):
|
|
self.message = f'{Path(path).name} is not a dump of Ocarina of Time.'
|
|
self.render()
|
|
return
|
|
self.settings['dump'] = path
|
|
self.settings.save()
|
|
self.message = ''
|
|
self.fonts_from_game('', path)
|
|
self.show(self.screen, self.focus)
|
|
|
|
def choose_folder(self):
|
|
path = filedialog.askdirectory(parent=self.root, title='Choose the folder for the game',
|
|
initialdir=str(Path(self.game_dir()).parent))
|
|
if not path:
|
|
return
|
|
p = Path(path)
|
|
if p.exists() and any(p.iterdir()) and not (p / 'build-cache').is_dir() and p.name.lower() != 'nx2navi':
|
|
p = p / 'NX2navi'
|
|
self.settings['game_dir'] = str(p)
|
|
self.settings.save()
|
|
self.show(self.screen, self.focus)
|
|
|
|
def begin_install(self):
|
|
dump = self.settings.get('dump')
|
|
if not dump:
|
|
self.focus = 0
|
|
self.choose_dump()
|
|
return
|
|
gd = self.game_dir()
|
|
if free_gb(gd) < 31 and not (Path(gd) / 'romfs' / 'romfs.bin').is_file():
|
|
self.message = f'{drive_of(gd)} has {free_gb(gd):.0f} GB free; the game needs about 30 GB.'
|
|
self.render()
|
|
return
|
|
self.settings['game_dir'] = gd
|
|
self.settings.save()
|
|
if self.settings.get('desktop_shortcut'):
|
|
winsys.desktop_shortcut(self.layout, True)
|
|
self.run_job(jobs.InstallJob(self.ctx, dump, gd), 'home', back='first_run')
|
|
|
|
# ---- home
|
|
|
|
def rows_home(self):
|
|
gd = self.settings.get('game_dir')
|
|
built = build.built_version(gd)
|
|
mine = versions.display(self.layout.version)
|
|
play_value = 'Ocarina of Time'
|
|
staged = self.bg.ready(self.release)
|
|
if staged:
|
|
upd = Row('value', 'Updates', f'Ready · {staged["version"]}', 'orange_arrow', big=True, icon='refresh',
|
|
badge=True, on=self.start_update, focus_value=True, desc=lambda: self.staged_desc(staged))
|
|
elif self.bg.staging():
|
|
upd = Row('value', 'Updates', f'Downloading {self.bg.job.ver}', 'orange_arrow', big=True,
|
|
icon='refresh', on=self.start_update, focus_value=True, desc=self.staging_desc)
|
|
elif self.release and jobs.update_available(self.ctx, self.release):
|
|
nv = versions.display(self.release.version)
|
|
upd = Row('value', 'Updates', f'Version {nv}', 'orange_arrow', big=True, icon='refresh', badge=True,
|
|
on=self.start_update, focus_value=True, desc=self.update_desc)
|
|
elif self.check and self.check.result is None:
|
|
upd = Row('value', 'Updates', 'Checking…', 'dim', big=True, icon='refresh', on=lambda: None,
|
|
desc=('Looking for a new version of NX2navi.',))
|
|
elif self.check_error:
|
|
upd = Row('value', 'Updates', 'Could not check', 'dim', big=True, icon='refresh',
|
|
on=lambda: self.check_now(force=True),
|
|
desc=(f'The update check failed: {self.check_error}', 'Select this row to try again.'))
|
|
elif self.checked_at:
|
|
upd = Row('value', 'Updates', f'Up to Date · {mine}', 'normal', big=True, icon='refresh', check=True,
|
|
on=lambda: self.check_now(force=True),
|
|
desc=(f'{mine} is the newest version. Checked {self.ago(self.checked_at)}.',
|
|
'Select this row to check again.'))
|
|
else:
|
|
upd = Row('value', 'Updates', f'Version {mine}', 'normal', big=True, icon='refresh',
|
|
on=lambda: self.check_now(force=True),
|
|
desc=(f'You have NX2navi {mine}. Select this row to look for a new version.',))
|
|
rebuild_value = f'Built with {versions.display(built)}' if built else 'Not built'
|
|
rebuild_style = 'normal' if versions.display(built) == mine else 'salmon'
|
|
play_desc = ('Start Ocarina of Time. The launcher steps aside while you play and comes back when you quit.',)
|
|
if built and versions.display(built) != mine:
|
|
play_desc += (f'The game was built with {versions.display(built)}. Rebuild the Game brings it to {mine}.',)
|
|
if staged and self.settings.get('auto_install'):
|
|
play_desc += (f'NX2navi {staged["version"]} is downloaded and installs after you quit the game.',)
|
|
if self.message:
|
|
play_desc = (self.message,) + play_desc
|
|
return [
|
|
Row('value', 'Play', play_value, big=True, icon='play', on=self.play, desc=play_desc),
|
|
upd,
|
|
Row('divider'),
|
|
Row('value', 'Game Folder', gd, on=lambda: self.open(gd),
|
|
desc=('Open the game folder. The game keeps your save in its profile folder, its settings in '
|
|
'nx2navi.ini and its logs in logs. Its saves folder holds saves to import and backups.',)),
|
|
self.saves_row(gd),
|
|
Row('value', 'Rebuild the Game', rebuild_value, rebuild_style, on=self.start_rebuild,
|
|
desc=('Build the game again from the files of the last build. Only what changed is compiled. Use it '
|
|
'after changing build tools, or if the game stopped starting.',)),
|
|
Row('value', 'Customize Launcher', art.THEMES[art.theme]['label'], 'arrow',
|
|
on=lambda: self.show('customize'),
|
|
desc=('Colour scheme, background picture, animations and sounds.',)),
|
|
Row('value', 'Settings', '', 'arrow', on=lambda: self.show('settings'),
|
|
desc=('Updates, the game folder, the logs and uninstalling.',)),
|
|
]
|
|
|
|
# ---- customize
|
|
|
|
def rows_customize(self):
|
|
names = list(art.THEMES)
|
|
custom = bool(self.settings.get('background'))
|
|
|
|
def scheme(d):
|
|
self.settings['theme'] = names[(names.index(art.theme) + d) % len(names)]
|
|
self.settings.save()
|
|
# the rows again: Background Color and Button Color are there only while the scheme is Custom; the
|
|
# cursor stays on Color Scheme, the first row
|
|
art.set_theme(self.settings['theme'], self.custom_colors())
|
|
self.rows = self.rows_customize()
|
|
self.apply_look()
|
|
|
|
def dim(d):
|
|
now = self.settings.get('background_dim', 40)
|
|
at = min(range(len(DIMS)), key=lambda i: abs(DIMS[i] - now))
|
|
self.settings['background_dim'] = DIMS[max(0, min(len(DIMS) - 1, at + d))]
|
|
self.settings.save()
|
|
self.apply_look()
|
|
|
|
def animations(v):
|
|
self.settings['animations'] = bool(v)
|
|
self.settings.save()
|
|
self.cur_xy = None
|
|
self.slide = 0.0
|
|
self.apply_look()
|
|
|
|
def sounds(d):
|
|
keys = [k for k, _ in SOUND_LEVELS]
|
|
now = self.settings.get('sounds')
|
|
at = keys.index(now if now in keys else sound.DEFAULT_LEVEL)
|
|
self.settings['sounds'] = keys[max(0, min(len(keys) - 1, at + d))]
|
|
self.settings.save()
|
|
self.sounds.set_level(self.settings['sounds'])
|
|
self.sounds.play('select') # the new loudness, heard at once
|
|
|
|
bg_desc = ('Use a picture of your own as the background.',)
|
|
if self.look_message:
|
|
bg_desc = (self.look_message,) + bg_desc
|
|
rows = [
|
|
Row('enum', 'Color Scheme', get=lambda: art.THEMES[art.theme]['label'],
|
|
left=lambda: scheme(-1), right=lambda: scheme(1),
|
|
desc=('The launcher\'s colours. Custom lets you pick your own.',)),
|
|
]
|
|
if art.theme == 'custom':
|
|
rows += [Row('value', CUSTOM_LABELS[slot], art.CUSTOM[slot], 'swatch',
|
|
on=lambda sl=slot: self.open_picker(sl), desc=(CUSTOM_DESC[slot],))
|
|
for slot in art.CUSTOM_SLOTS]
|
|
rows += [
|
|
Row('value', 'Background', Path(self.settings['background']).name if custom else 'Choose a Picture',
|
|
'arrow', on=self.choose_background, desc=bg_desc),
|
|
]
|
|
if custom:
|
|
rows += [
|
|
Row('enum', 'Background Dimming', get=lambda: f'{self.settings.get("background_dim", 40)} %',
|
|
left=lambda: dim(-1), right=lambda: dim(1),
|
|
desc=('Darkens your picture so the text stays easy to read.',)),
|
|
Row('value', 'Remove Background', '', 'arrow', on=self.clear_background,
|
|
desc=('Go back to the colour scheme\'s own background.',)),
|
|
]
|
|
rows += [
|
|
Row('divider'),
|
|
Row('toggle', 'Animations', on=animations, get=lambda: bool(self.settings.get('animations')),
|
|
desc=('A gliding cursor, sliding screens and drifting sparks.',)),
|
|
Row('enum', 'Sounds', get=lambda: dict(SOUND_LEVELS).get(self.settings.get('sounds'),
|
|
dict(SOUND_LEVELS)[sound.DEFAULT_LEVEL]),
|
|
left=lambda: sounds(-1), right=lambda: sounds(1),
|
|
desc=('The game\'s select and back sounds. Low by default.',)),
|
|
Row('divider'),
|
|
]
|
|
if os.name == 'nt':
|
|
rows += [self.steam_row(), Row('divider')]
|
|
rows += [
|
|
Row('action', 'Back', on=lambda: self.show('home', self.home_row('Customize Launcher')), height=40,
|
|
desc=('Back to the main menu.',)),
|
|
]
|
|
return rows
|
|
|
|
# ---- the Custom scheme's colour picker
|
|
|
|
def custom_colors(self):
|
|
return {slot: self.settings.get('custom_' + slot, '') for slot in art.CUSTOM_SLOTS}
|
|
|
|
def open_picker(self, slot):
|
|
h = art.CUSTOM.get(slot) or art.CUSTOM_DEFAULT[slot]
|
|
hh, ss, vv = colorsys.rgb_to_hsv(*(int(h[i:i + 2], 16) / 255.0 for i in (1, 3, 5)))
|
|
self.pick = {'slot': slot, 'h': hh, 's': ss, 'v': vv}
|
|
self.show('color', 0)
|
|
|
|
def pick_hex(self):
|
|
p = self.pick
|
|
return art.hexcode(*colorsys.hsv_to_rgb(p['h'], p['s'], p['v']))
|
|
|
|
def pick_step(self, key, d):
|
|
"""Hue by 5 degrees along the hue bar, saturation and brightness by 5 % (the arrows and the gamepad)."""
|
|
p = self.pick
|
|
if key == 'h':
|
|
p['h'] = (round(p['h'] * 72) + d) % 72 / 72.0
|
|
else:
|
|
p[key] = max(0.0, min(1.0, (round(p[key] * 20) + d) / 20.0))
|
|
self.pick_changed(final=True)
|
|
|
|
def pick_changed(self, final):
|
|
"""The picked colour into the settings and onto the screen: the picker at once, the launcher's colours a
|
|
moment later (look_soon), so dragging over the square stays smooth. final: saved to launcher.json."""
|
|
if not self.pick:
|
|
return
|
|
self.settings['custom_' + self.pick['slot']] = self.pick_hex()
|
|
if final:
|
|
self.settings.save()
|
|
if self.screen == 'color':
|
|
self.rows = self.rows_color()
|
|
self.render()
|
|
self.look_soon(0 if final else 120)
|
|
|
|
def look_soon(self, ms):
|
|
if self.look_after:
|
|
self.root.after_cancel(self.look_after)
|
|
self.look_after = self.root.after(ms, self.look_now)
|
|
|
|
def look_now(self):
|
|
self.look_after = None
|
|
self.apply_look()
|
|
|
|
def pick_reset(self):
|
|
slot = self.pick['slot']
|
|
self.settings['custom_' + slot] = ''
|
|
self.settings.save()
|
|
art.CUSTOM[slot] = art.CUSTOM_DEFAULT[slot]
|
|
self.open_picker(slot)
|
|
self.focus = next(i for i, r in enumerate(self.rows) if r.label == 'Reset')
|
|
self.apply_look()
|
|
|
|
def pick_done(self):
|
|
slot = self.pick['slot'] if self.pick else 'background'
|
|
if self.look_after:
|
|
self.root.after_cancel(self.look_after)
|
|
self.look_after = None
|
|
self.apply_look()
|
|
rows = self.rows_customize()
|
|
self.show('customize', next((i for i, r in enumerate(rows) if r.label == CUSTOM_LABELS[slot]), 0))
|
|
|
|
def rows_color(self):
|
|
p = self.pick
|
|
return [
|
|
Row('enum', 'Hue', get=lambda: f'{round(p["h"] * 360) % 360}°',
|
|
left=lambda: self.pick_step('h', -1), right=lambda: self.pick_step('h', 1)),
|
|
Row('enum', 'Saturation', get=lambda: f'{round(p["s"] * 100)} %',
|
|
left=lambda: self.pick_step('s', -1), right=lambda: self.pick_step('s', 1)),
|
|
Row('enum', 'Brightness', get=lambda: f'{round(p["v"] * 100)} %',
|
|
left=lambda: self.pick_step('v', -1), right=lambda: self.pick_step('v', 1)),
|
|
Row('value', 'Hex Code', self.pick_hex().upper(), 'arrow', on=self.type_hex),
|
|
Row('divider'),
|
|
Row('value', 'Reset', '', 'arrow', on=self.pick_reset),
|
|
Row('divider'),
|
|
Row('action', 'Done', on=self.pick_done, height=40),
|
|
]
|
|
|
|
def picker_at(self, x, y, part=None):
|
|
"""The colour under the mouse at (x, y) on the square or the bar into the picker; which of the two it was
|
|
(part: the one being dragged, which takes the mouse also outside it), or None."""
|
|
X, p = self.X, self.pick
|
|
|
|
def inside(box, m=0):
|
|
x0, y0, x1, y1 = box
|
|
return X(x0) - X(m) <= x <= X(x1) + X(m) and X(y0) - X(m) <= y <= X(y1) + X(m)
|
|
|
|
def frac(v, a, b):
|
|
return max(0.0, min(1.0, (v - X(a)) / max(1, X(b) - X(a))))
|
|
sx0, sy0, sx1, sy1 = SQUARE
|
|
bx0, _, bx1, _ = BAR
|
|
if part == 'square' or (part is None and inside(SQUARE, 4)):
|
|
p['s'] = frac(x, sx0, sx1)
|
|
p['v'] = 1 - frac(y, sy0, sy1)
|
|
part = 'square'
|
|
elif part == 'bar' or (part is None and inside(BAR, 8)):
|
|
p['h'] = min(frac(x, bx0, bx1), 0.9999)
|
|
part = 'bar'
|
|
else:
|
|
return None
|
|
self.pick_changed(final=False)
|
|
return part
|
|
|
|
def type_hex(self):
|
|
"""A box over the Hex Code row to type a colour into, like #3A7BD5; Enter takes it, Esc leaves it."""
|
|
r = self.focused()
|
|
if self.hex_entry or not r or not r.value_rect:
|
|
return
|
|
x0, y0, x1, y1 = r.value_rect
|
|
X = self.X
|
|
e = tk.Entry(self.canvas, font=self.f['label'], bg=P['value'], fg=P['value_text'],
|
|
insertbackground=P['value_text'], selectbackground=P['orange'], selectforeground=P['on_text'],
|
|
relief='flat', justify='center', highlightthickness=1, highlightcolor=P['orange'],
|
|
highlightbackground=P['value_border'])
|
|
e.insert(0, self.pick_hex().upper())
|
|
e.select_range(0, 'end')
|
|
self.hex_entry = e
|
|
self.canvas.create_window(x0 + X(3), y0 + X(3), anchor='nw', window=e, width=x1 - x0 - X(6),
|
|
height=y1 - y0 - X(6), tags='hexentry')
|
|
e.focus_set()
|
|
|
|
def take(ev):
|
|
self.hex_typed(e.get())
|
|
return 'break'
|
|
|
|
def leave(ev):
|
|
self.close_hex()
|
|
return 'break'
|
|
for key in ('<Return>', '<KP_Enter>'):
|
|
e.bind(key, take)
|
|
e.bind('<Escape>', leave)
|
|
for key in ('<Up>', '<Down>', '<space>'):
|
|
e.bind(key, lambda ev: 'break')
|
|
e.bind('<FocusOut>', lambda ev: self.close_hex())
|
|
|
|
@staticmethod
|
|
def parse_hex(text):
|
|
"""(r, g, b) in 0..255 of a typed hex code (#3A7BD5, 3A7BD5 or #37D), or None."""
|
|
t = text.strip().lstrip('#').lower()
|
|
if len(t) == 3:
|
|
t = ''.join(ch * 2 for ch in t)
|
|
return tuple(int(t[i:i + 2], 16) for i in (0, 2, 4)) if art.HEX.match('#' + t) else None
|
|
|
|
def hex_typed(self, text):
|
|
got = self.parse_hex(text)
|
|
if got is None:
|
|
self.sounds.play('back')
|
|
self.hex_entry.select_range(0, 'end')
|
|
return
|
|
self.sounds.play('select')
|
|
hh, ss, vv = colorsys.rgb_to_hsv(*(v / 255.0 for v in got))
|
|
self.pick.update(h=hh, s=ss, v=vv)
|
|
self.close_hex()
|
|
self.pick_changed(final=True)
|
|
|
|
def close_hex(self):
|
|
if self.hex_entry:
|
|
e, self.hex_entry = self.hex_entry, None
|
|
self.canvas.delete('hexentry')
|
|
e.destroy()
|
|
try:
|
|
self.root.focus_force()
|
|
except tk.TclError:
|
|
pass
|
|
|
|
def draw_picker(self):
|
|
"""The colour picker in the description box's place: the slot's name, the colour square of the picked hue
|
|
with a ring on the picked colour, the hue bar, and the colour itself with its hex code."""
|
|
c, X, s, p = self.canvas, self.X, self.s, self.pick
|
|
bx, by, bw, bh = PICKER_BOX
|
|
key = ('desc', X(bw), X(bh) // 4)
|
|
if key not in self.sprites:
|
|
img, pad = art.desc_box(X(bw), X(bh), s, self.see_through())
|
|
self.sprites[key] = (art.photo(self.root, img), pad)
|
|
pic, pad = self.sprites[key]
|
|
c.create_image(X(bx) - pad, X(by) - pad, anchor='nw', image=pic, tags='dyn')
|
|
self.text(X(bx + 26), X(by + 34), CUSTOM_LABELS[p['slot']], self.f['heading'], P['text_bright'])
|
|
sx0, sy0, sx1, sy1 = SQUARE
|
|
sw, sh = X(sx1) - X(sx0), X(sy1) - X(sy0)
|
|
hue = round(p['h'], 3)
|
|
square = self.sprite(('square', sw, sh, hue), lambda: art.color_square(sw, sh, hue))
|
|
c.create_rectangle(X(sx0) - 1, X(sy0) - 1, X(sx0) + sw, X(sy0) + sh, outline=P['value_border'], tags='dyn')
|
|
c.create_image(X(sx0), X(sy0), anchor='nw', image=square, tags='dyn')
|
|
size = X(18)
|
|
mark = self.sprite(('mark', size), lambda: art.picker_mark(size, s))
|
|
c.create_image(X(sx0) + round(p['s'] * sw), X(sy0) + round((1 - p['v']) * sh), image=mark, tags='dyn')
|
|
bx0, by0, bx1, by1 = BAR
|
|
bw_, bh_ = X(bx1) - X(bx0), X(by1) - X(by0)
|
|
bar = self.sprite(('hues', bw_, bh_), lambda: art.hue_bar(bw_, bh_))
|
|
c.create_rectangle(X(bx0) - 1, X(by0) - 1, X(bx0) + bw_, X(by0) + bh_, outline=P['value_border'], tags='dyn')
|
|
c.create_image(X(bx0), X(by0), anchor='nw', image=bar, tags='dyn')
|
|
c.create_image(X(bx0) + round(p['h'] * bw_), X(by0) + bh_ // 2, image=mark, tags='dyn')
|
|
hexc = self.pick_hex()
|
|
c.create_rectangle(X(bx0), X(452), X(bx0 + 64), X(500), fill=hexc, outline=P['value_border'], tags='dyn')
|
|
self.text(X(bx0 + 80), X(476), hexc.upper(), self.f['label'], P['text_bright'])
|
|
self.text(X(bx + 26), X(526), 'Click or drag the square and the bar.', self.f['small'], P['muted'])
|
|
|
|
def steam_row(self):
|
|
"""Add to Steam: the game in the Steam library, started from there without the launcher (launcher/steam.py)."""
|
|
found = steam.find_steam()
|
|
desc = ('Adds the game to your Steam library with its own icon and artwork, so you can start it from Steam.',
|
|
'Steam restarts while it is added.')
|
|
if self.steam_message:
|
|
desc = (self.steam_message,) + desc
|
|
if self.steam_busy:
|
|
value, style = 'Adding…', 'dim'
|
|
elif not found:
|
|
value, style = 'Steam not found', 'dim'
|
|
else:
|
|
value, style = ('Added' if steam.added(found[0]) else ''), 'arrow'
|
|
return Row('value', 'Add to Steam', value, style, on=self.add_to_steam, desc=desc)
|
|
|
|
def add_to_steam(self):
|
|
import threading
|
|
if self.steam_busy:
|
|
return
|
|
gd = self.settings.get('game_dir')
|
|
if not steam.find_steam():
|
|
self.steam_message = 'Steam is not installed on this PC.'
|
|
self.sounds.play('back')
|
|
self.show('customize', self.focus)
|
|
return
|
|
if not gd or not (Path(gd) / 'ocarina.exe').is_file():
|
|
self.steam_message = 'The game is not built yet.'
|
|
self.sounds.play('back')
|
|
self.show('customize', self.focus)
|
|
return
|
|
self.steam_busy = True
|
|
self.steam_message = 'Adding to Steam…'
|
|
self.sounds.play('select')
|
|
self.show('customize', self.focus)
|
|
dump = self.settings.get('dump')
|
|
|
|
def work():
|
|
try:
|
|
text, ok = steam.add(self.layout, gd, dump, self.ctx.log), True
|
|
except steam.SteamError as e:
|
|
text, ok = str(e), False
|
|
except Exception as e: # a Steam folder in a state nobody expected: say so instead of dying
|
|
import traceback
|
|
self.ctx.log('steam: ' + traceback.format_exc())
|
|
text, ok = f'Adding to Steam failed: {e}', False
|
|
self.ctx.log(f'steam: {text}')
|
|
self.root.after(0, lambda: self.steam_done(text, ok))
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def steam_done(self, text, ok):
|
|
self.steam_busy = False
|
|
self.steam_message = text
|
|
if not ok:
|
|
self.sounds.play('back')
|
|
if self.screen == 'customize':
|
|
self.show('customize', self.focus)
|
|
|
|
def choose_background(self):
|
|
start = self.settings.get('background')
|
|
start = str(Path(start).parent) if start and Path(start).parent.is_dir() else \
|
|
str(winsys.known_folder('pictures') or Path.home())
|
|
pattern = ' '.join('*' + t for t in picture.TYPES)
|
|
path = filedialog.askopenfilename(parent=self.root, title='Choose a background picture', initialdir=start,
|
|
filetypes=[('Pictures', pattern), ('All files', '*.*')])
|
|
if not path:
|
|
return
|
|
try:
|
|
picture.keep(path, self.bg_store, self.root)
|
|
except (picture.PictureError, OSError, MemoryError, ValueError) as e:
|
|
self.ctx.log(f'background: {path}: {e!r}')
|
|
self.look_message = str(e) if isinstance(e, picture.PictureError) else \
|
|
f'{Path(path).name} could not be used as the background: {e}'
|
|
self.sounds.play('back')
|
|
self.show('customize', self.focus)
|
|
return
|
|
self.look_message = ''
|
|
self.settings['background'] = str(Path(path))
|
|
self.settings.save()
|
|
self.apply_look()
|
|
self.show('customize', self.focus)
|
|
|
|
def clear_background(self):
|
|
self.settings['background'] = ''
|
|
self.settings.save()
|
|
try:
|
|
self.bg_store.unlink(missing_ok=True)
|
|
except OSError as e:
|
|
self.ctx.log(f'background: {e}')
|
|
self.look_message = ''
|
|
self.apply_look()
|
|
self.show('customize', 1)
|
|
|
|
def save_waiting(self, gd):
|
|
"""A save put into the game folder's saves folder (a shared zip, a save folder), waiting to be imported."""
|
|
if gd and Path(gd).is_dir():
|
|
try:
|
|
saves.ensure_saves_dir(gd)
|
|
return saves.waiting(gd)[0] is not None
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
def saves_row(self, gd):
|
|
"""Saves: the screen that exports and imports saves; orange while a save waits in the saves folder."""
|
|
ready = self.save_waiting(gd)
|
|
desc = ('Export your save into one zip, to attach to a bug report or give to another player, or import a '
|
|
'save someone shared, a Switch save backup or an emulator\'s save.',)
|
|
if ready:
|
|
desc += ('A save waits in the saves folder of the game folder: select this row, then Import a Save.',)
|
|
return Row('value', 'Saves', 'Import Waiting' if ready else '', 'orange_arrow' if ready else 'arrow',
|
|
on=lambda: self.show('saves'), desc=desc)
|
|
|
|
# ---- saves
|
|
|
|
def export_dir(self):
|
|
d = self.settings.get('save_export_dir')
|
|
return Path(d) if d and Path(d).is_dir() else saves.default_export_dir()
|
|
|
|
def rows_saves(self):
|
|
gd = self.settings.get('game_dir')
|
|
ready = self.save_waiting(gd)
|
|
out = self.export_dir()
|
|
export_desc = ('Write your save into one zip file, to attach to a bug report or give to another player: every '
|
|
'save slot and a note of the NX2navi and game versions, never your keys or game files. The '
|
|
'folder opens with the zip selected.',)
|
|
if self.export_message:
|
|
export_desc = (self.export_message,) + export_desc
|
|
import_desc = ('Make a save someone shared (an NX2navi-save zip) your save: every slot is replaced. Put the zip '
|
|
'into the saves folder of the game folder and select this row, or select this row and choose '
|
|
'the zip.', 'Your current save is copied to the saves folder first.')
|
|
if gd and saves.pending_import(gd):
|
|
import_desc += ('A save imported in the game\'s menu waits for the game\'s next start; an import here '
|
|
'replaces it.',)
|
|
folder_desc = ('Bring in a save folder: a Switch save backup, an emulator\'s save, another NX2navi install, or '
|
|
'one save slot (a folder named slot_00 to slot_05: only that slot is replaced).',
|
|
'Your current save is copied to the saves folder first.')
|
|
if self.save_message:
|
|
import_desc, folder_desc = (self.save_message,) + import_desc, (self.save_message,) + folder_desc
|
|
|
|
def report(v):
|
|
self.settings['export_with_log'] = bool(v)
|
|
self.settings.save()
|
|
return [
|
|
Row('value', 'Export Save', out.name or str(out), on=self.export_save, desc=export_desc),
|
|
Row('toggle', 'Add Log and Settings', on=report, get=lambda: bool(self.settings.get('export_with_log')),
|
|
desc=('On: the zip also holds the game\'s latest log (logs\\nx2navi.log) and its settings '
|
|
'(nx2navi.ini), which show what happened and with which settings. Keep it on for a bug report.',
|
|
'Off: the save alone, to give to another player.')),
|
|
Row('value', 'Export To', str(out), on=self.choose_export_dir,
|
|
desc=(f'Export Save writes into {out}.', 'Select this row to choose another folder.')),
|
|
Row('divider'),
|
|
Row('value', 'Import a Save', 'From the Saves Folder' if ready else 'Choose a Zip',
|
|
'orange_arrow' if ready else 'arrow', on=lambda: self.import_saves(folder=False), desc=import_desc),
|
|
Row('value', 'Import a Save Folder', 'Choose a Folder', 'arrow',
|
|
on=lambda: self.import_saves(folder=True), desc=folder_desc),
|
|
Row('value', 'Open Saves Folder', '', 'arrow',
|
|
on=lambda: gd and Path(gd).is_dir() and self.open(saves.ensure_saves_dir(gd)),
|
|
desc=('The saves folder of the game folder: saves to import, and a backup of each save an import '
|
|
'replaced.',)),
|
|
Row('action', 'Back', on=lambda: self.show('home'), height=40, desc=('Back to the main menu.',)),
|
|
]
|
|
|
|
def choose_export_dir(self):
|
|
d = filedialog.askdirectory(parent=self.root, title='Choose where Export Save writes its zip',
|
|
initialdir=str(self.export_dir()), mustexist=True)
|
|
if d:
|
|
self.settings['save_export_dir'] = str(Path(d))
|
|
self.settings.save()
|
|
self.show('saves', self.focus)
|
|
|
|
def export_save(self):
|
|
gd = self.settings.get('game_dir')
|
|
if not gd or not Path(gd).is_dir():
|
|
self.export_message = 'Install the game first.'
|
|
else:
|
|
try:
|
|
r = saves.export_save(gd, self.export_dir(), bug_report=bool(self.settings.get('export_with_log')),
|
|
version=versions.display(build.built_version(gd) or self.layout.version),
|
|
log=self.ctx.log)
|
|
except (saves.SaveError, OSError) as e:
|
|
self.export_message = f'The save was not exported: {e}'
|
|
else:
|
|
extra = ' with the game\'s log and settings' if r['extras'] else ''
|
|
self.export_message = f'Exported {r["files"]} files{extra} to {r["path"]}.'
|
|
try:
|
|
winsys.reveal(r['path'])
|
|
except OSError:
|
|
pass
|
|
self.show('saves', self.focus)
|
|
|
|
def import_saves(self, folder):
|
|
"""Import a Save (folder False): what waits in the saves folder, else a zip the player picks. Import a Save
|
|
Folder (folder True): a folder the player picks; the saves folder itself takes what waits there."""
|
|
gd = self.settings.get('game_dir')
|
|
if not gd or not Path(gd).is_dir():
|
|
self.save_message = 'Install the game first.'
|
|
elif (self.game_proc and self.game_proc.poll() is None) or saves.game_running(gd):
|
|
self.save_message = 'Quit the game first: it keeps its save open while it runs.'
|
|
else:
|
|
try:
|
|
src = None
|
|
if folder:
|
|
src = filedialog.askdirectory(parent=self.root, title='Choose the folder with the save',
|
|
initialdir=str(saves.ensure_saves_dir(gd)), mustexist=True)
|
|
if not src:
|
|
return
|
|
elif saves.waiting(gd)[0] is None:
|
|
src = filedialog.askopenfilename(parent=self.root, title='Choose the save to import',
|
|
initialdir=str(self.export_dir()),
|
|
filetypes=[('NX2navi save', '*.zip'), ('All files', '*.*')])
|
|
if not src:
|
|
return
|
|
r = saves.import_save(gd, src, log=self.ctx.log)
|
|
except (saves.SaveError, OSError) as e:
|
|
self.save_message = f'The save was not imported: {e}'
|
|
else:
|
|
self.save_message = self.imported_text(r)
|
|
self.show('saves', self.focus)
|
|
|
|
@staticmethod
|
|
def imported_text(r):
|
|
"""What an import did, for the import rows' descriptions."""
|
|
m = r.get('manifest')
|
|
if m:
|
|
text = (f'Save imported from {r["source"].name} (exported {str(m.get("exported", ""))[:10]} with NX2navi '
|
|
f'{m.get("nx2navi") or "?"}).')
|
|
elif r['slot']:
|
|
text = f'Save imported into {r["slot"]}.'
|
|
else:
|
|
text = f'Save imported: {r["files"]} files from {r["source"].name}.'
|
|
return text + (f' Your old save is in saves\\{r["backup"].name}.' if r['backup'] else '')
|
|
|
|
def update_desc(self):
|
|
r = self.release
|
|
nv, mine = versions.display(r.version), versions.display(self.layout.version)
|
|
notes = feed.plain_notes(r.notes, 6)
|
|
return {'title': f'Version {nv} Is Ready',
|
|
'text': [f'You have {mine}. Updating downloads the new version and rebuilds the game in a few '
|
|
f'minutes. Your saves and settings stay as they are.'],
|
|
'divider': bool(notes), 'subtitle': "What's New" if notes else '', 'bullets': notes}
|
|
|
|
def staged_desc(self, m):
|
|
"""The Updates row of a release downloaded in the background (BackgroundUpdate.ready)."""
|
|
nv, mine = m['version'], versions.display(self.layout.version)
|
|
notes = feed.plain_notes(m.get('notes', ''), 4)
|
|
if self.settings.get('auto_install'):
|
|
text = (f'You have {mine}. Version {nv} is downloaded and installs when you are not playing: after you '
|
|
'quit the game, or when NX2navi next opens. Select this row to install it now.')
|
|
else:
|
|
text = f'You have {mine}. Version {nv} is downloaded. Select this row to install it.'
|
|
return {'title': f'Version {nv} Is Ready', 'text': [text],
|
|
'divider': bool(notes), 'subtitle': "What's New" if notes else '', 'bullets': notes,
|
|
'note': 'Installing restarts the launcher and rebuilds the game in a few minutes. Your saves and '
|
|
'settings stay as they are.'}
|
|
|
|
def staging_desc(self):
|
|
nv, mine = self.bg.job.ver, versions.display(self.layout.version)
|
|
if self.bg.asked:
|
|
then = 'It installs as soon as the download is done.'
|
|
elif self.settings.get('auto_install'):
|
|
then = 'It installs when you are not playing. Select this row to install it as soon as it is downloaded.'
|
|
else:
|
|
then = 'Select this row to install it as soon as it is downloaded.'
|
|
return {'title': f'Downloading Version {nv}',
|
|
'text': [f'You have {mine}. Version {nv} is downloading in the background; you can play meanwhile. '
|
|
f'{then}']}
|
|
|
|
def ago(self, t):
|
|
d = time.time() - t
|
|
if d < 90:
|
|
return 'just now'
|
|
if d < 3600:
|
|
return f'{int(d // 60)} minutes ago'
|
|
return time.strftime('at %H:%M', time.localtime(t))
|
|
|
|
def check_now(self, force=False):
|
|
if self.check and self.check.result is None:
|
|
return
|
|
if not force and time.time() - self.settings.get('last_check', 0) < 600 and self.release:
|
|
return
|
|
self.check_error = ''
|
|
self.check_started = time.time()
|
|
self.check = jobs.CheckJob(self.ctx).start()
|
|
if self.screen == 'home':
|
|
self.show('home', self.focus)
|
|
|
|
def check_done(self):
|
|
c = self.check
|
|
self.bg.checked() # the release found is staged at the next tick, also one whose staging failed before
|
|
if c.result == 'ok':
|
|
self.release = c.release
|
|
self.checked_at = time.time()
|
|
self.settings['last_check'] = self.checked_at
|
|
if self.release:
|
|
self.settings['last_seen'] = self.release.version
|
|
self.settings.save()
|
|
self.ctx.log(f'update check: {self.release.version if self.release else "no release"}')
|
|
else:
|
|
self.check_error = c.error or 'cancelled'
|
|
self.ctx.log(f'update check: {self.check_error}')
|
|
if self.screen == 'home':
|
|
self.show('home', self.focus)
|
|
|
|
def play(self):
|
|
gd = self.settings.get('game_dir')
|
|
exe = Path(gd) / 'ocarina.exe'
|
|
if not exe.is_file():
|
|
self.message = 'The game is not built yet.'
|
|
self.render()
|
|
return
|
|
if self.game_proc and self.game_proc.poll() is None:
|
|
return
|
|
if saves.game_running(gd): # a copy this launcher did not start (another launcher, a shortcut, a test run)
|
|
self.message = 'The game is already running.'
|
|
self.render()
|
|
return
|
|
try:
|
|
self.game_proc = winsys.start_game(exe)
|
|
except OSError as e:
|
|
self.message = f'The game could not start: {e}'
|
|
self.render()
|
|
return
|
|
self.ctx.log(f'play: {exe}')
|
|
self.sounds.play('select')
|
|
if self.settings.get('hide_while_playing'):
|
|
self.root.withdraw()
|
|
self.root.after(1000, self.watch_game)
|
|
|
|
def watch_game(self):
|
|
# The game's own Restart ends the process started here and starts another (gfx_present.c gfx_window_closed)
|
|
gd = self.settings.get('game_dir')
|
|
if (self.game_proc and self.game_proc.poll() is None) or (gd and saves.game_running(gd)):
|
|
self.root.after(1000, self.watch_game)
|
|
return
|
|
code = self.game_proc.returncode if self.game_proc else 0
|
|
self.game_proc = None
|
|
self.root.deiconify()
|
|
self.root.lift()
|
|
if code not in (0, None):
|
|
text = exit_code_text(code)
|
|
self.ctx.log(f'the game ended with code {text}')
|
|
self.message = f'The game ended with code {text}. Its log is logs\\nx2navi.log in the game folder.'
|
|
note_exit_in_log(self.game_dir(), text)
|
|
if self.install_staged('the game exited'):
|
|
return
|
|
self.show('home', self.focus)
|
|
|
|
def open(self, path):
|
|
try:
|
|
winsys.open_path(path)
|
|
except OSError as e:
|
|
self.message = str(e)
|
|
self.render()
|
|
|
|
def start_update(self):
|
|
"""The Updates row: install the release staged in the background, have the one downloading install once it
|
|
is staged, or else download it here with its steps on screen (as when the background download failed)."""
|
|
gd = self.settings.get('game_dir')
|
|
if gd and build.game_running(gd):
|
|
self.message = 'Close the game first: the update rebuilds it.'
|
|
self.show('home', self.focus)
|
|
return
|
|
if self.bg.staging():
|
|
self.bg.asked = True
|
|
self.message = f'Version {self.bg.job.ver} installs as soon as it is downloaded.'
|
|
self.show('home', self.focus)
|
|
return
|
|
if self.bg.ready(self.release):
|
|
if not self.install_staged('selected', asked=True):
|
|
self.message = f'The update could not be installed: {self.bg.error or "see the launcher log"}.'
|
|
self.show('home', self.focus)
|
|
return
|
|
if not self.release:
|
|
return
|
|
self.run_job(jobs.UpdateDownloadJob(self.ctx, self.release), 'home')
|
|
|
|
def install_staged(self, why, asked=False):
|
|
"""Install the release staged in the background when nothing stops it (BackgroundUpdate.install) and close
|
|
for the restart into it; True when it was installed."""
|
|
if self.job and self.job.result is None:
|
|
return False
|
|
running = bool(self.game_proc and self.game_proc.poll() is None)
|
|
ver = self.bg.install(self.settings.get('game_dir'), self.release, why, asked=asked, running=running)
|
|
if not ver:
|
|
return False
|
|
if self.bg.restarting:
|
|
self.message = f'Installing NX2navi {ver}…'
|
|
self.root.after(200, self.root.destroy)
|
|
else:
|
|
self.message = f'NX2navi {ver} is installed: start NX2navi again to finish it.'
|
|
if self.screen == 'home':
|
|
self.show('home', self.focus)
|
|
return True
|
|
|
|
def background_event(self, ev):
|
|
"""A background staging started or ended (BackgroundUpdate.poll)."""
|
|
if ev in ('staged', 'failed') and (self.bg.asked or self.bg.waiting):
|
|
if self.install_staged('the download finished'):
|
|
return
|
|
if ev == 'failed' and self.bg.asked:
|
|
# the player chose to update: download it here instead, where its error and Try Again show
|
|
self.bg.asked = False
|
|
gd = self.settings.get('game_dir')
|
|
if (self.screen == 'home' and self.release and jobs.update_available(self.ctx, self.release)
|
|
and not (self.game_proc and self.game_proc.poll() is None)
|
|
and not (gd and build.game_running(gd))):
|
|
self.run_job(jobs.UpdateDownloadJob(self.ctx, self.release), 'home')
|
|
return
|
|
if self.screen == 'home':
|
|
self.show('home', self.focus)
|
|
|
|
def start_rebuild(self):
|
|
gd = self.settings.get('game_dir')
|
|
self.run_job(jobs.RebuildJob(self.ctx, gd, self.settings.get('dump')), 'home')
|
|
|
|
# ---- settings
|
|
|
|
def rows_settings(self):
|
|
gd = self.settings.get('game_dir')
|
|
dump = self.settings.get('dump')
|
|
return [
|
|
self.toggle('Check for Updates at Start', 'auto_check',
|
|
'Look for a new version of NX2navi when the launcher opens, and every hour while it stays '
|
|
'open (also while you play). A new version downloads in the background.'),
|
|
self.toggle('Install Updates Automatically', 'auto_install',
|
|
'Install a downloaded update by itself when you are not playing: after you quit the game, '
|
|
'or when NX2navi opens. Off: it waits until you select Updates. Updates download in the '
|
|
'background either way.'),
|
|
self.toggle('Hide Launcher While Playing', 'hide_while_playing',
|
|
'Hide this window while the game runs; it comes back when you quit the game.'),
|
|
self.toggle('Keep Build Cache', 'keep_cache',
|
|
'Keep the translated code and compiled files (about 5 GB) in the game folder, so an update '
|
|
'compiles only what changed. Off: they are deleted after each build.'),
|
|
self.channel_row(),
|
|
*([self.discord_row(gd)] if gd and Path(gd).is_dir() else []),
|
|
Row('divider'),
|
|
Row('value', 'Game Folder', gd or 'Not chosen', on=lambda: gd and self.open(gd),
|
|
desc=('Open the game folder: saves in profile, the game\'s settings in nx2navi.ini, its logs in '
|
|
'logs.',)),
|
|
Row('value', 'Game Dump', Path(dump).name if dump else 'Not chosen', on=self.choose_dump,
|
|
desc=(f'The dump the game was built from: {dump}.' if dump else 'No dump chosen yet.',
|
|
'Select this row to choose another .nup file.')),
|
|
Row('divider'),
|
|
Row('value', 'Open Logs Folder', '', 'arrow', on=lambda: self.open(self.layout.logs),
|
|
desc=('The launcher\'s logs. Attach launcher.log when you report a problem with installing or '
|
|
'updating; never send game files.',)),
|
|
Row('value', 'Uninstall NX2navi', 'Keeps your saves', 'salmon', on=lambda: self.show('uninstall'),
|
|
desc=('Remove the launcher and its build tools. You choose on the next screen whether the built '
|
|
'game goes too.',)),
|
|
Row('action', 'Back', on=lambda: self.show('home'), height=40, desc=('Back to the main menu.',)),
|
|
]
|
|
|
|
# ---- uninstall
|
|
|
|
def rows_uninstall(self):
|
|
def on(v):
|
|
self.uninstall_game = bool(v)
|
|
gd = self.settings.get('game_dir')
|
|
return [
|
|
Row('toggle', 'Remove the Built Game', on=on, get=lambda: self.uninstall_game,
|
|
desc=(f'On: the game folder ({gd or "none"}) is deleted too, saves included.',
|
|
'Off: the game folder and your saves stay where they are.')),
|
|
Row('divider'),
|
|
Row('pair', actions=[('Uninstall', self.do_uninstall), ('Cancel', lambda: self.show('settings'))],
|
|
desc=('Uninstall removes NX2navi, its build tools and its shortcuts. Your dump is never touched.',)),
|
|
]
|
|
|
|
def do_uninstall(self):
|
|
gd = self.settings.get('game_dir') if self.uninstall_game else ''
|
|
if gd and build.game_running(gd):
|
|
self.message = 'Close the game first.'
|
|
self.render()
|
|
return
|
|
self.ctx.log(f'uninstall (game folder too: {bool(gd)})')
|
|
winsys.uninstall(self.layout, gd, self.ctx.log)
|
|
self.root.destroy()
|
|
|
|
# ---- jobs
|
|
|
|
def run_job(self, job, return_to, back=None):
|
|
self.job, self.job_return, self.job_back = job, return_to, back or return_to
|
|
self.job_rev = -1
|
|
self.message = ''
|
|
winsys.keep_awake(True)
|
|
job.start()
|
|
self.show('job')
|
|
|
|
def rows_job(self):
|
|
j = self.job
|
|
rows = []
|
|
for st in j.steps:
|
|
if st.state == jobs.RUNNING:
|
|
rows.append(Row('progress', st.label, fraction=st.fraction, value=f'{int(st.fraction * 100)}%',
|
|
focus_value=True, desc=None))
|
|
else:
|
|
style = {jobs.DONE: 'normal', jobs.SKIPPED: 'dim', jobs.PENDING: 'dim', jobs.FAILED: 'salmon'}[st.state]
|
|
rows.append(Row('value', st.label, st.value, style, check=st.state == jobs.DONE, desc=None,
|
|
focus_value=st.state == jobs.FAILED))
|
|
if st.id in ('romfs', 'verify') and st is not j.steps[-1]:
|
|
rows.append(Row('divider'))
|
|
if j.result is None:
|
|
label = {'install': 'Cancel Installation', 'update': 'Cancel Update'}.get(j.kind, 'Cancel')
|
|
rows.append(Row('action', label, on=self.cancel_job, height=44))
|
|
elif j.result == 'failed':
|
|
rows.append(Row('pair', actions=[('Try Again', self.retry_job), ('Copy Report', self.copy_report)]))
|
|
for r in rows:
|
|
r.desc = self.job_desc
|
|
return rows
|
|
|
|
def job_focus(self):
|
|
"""The cursor sits on the running step, or on Try Again after a failure."""
|
|
for i, r in enumerate(self.rows):
|
|
if self.job.result == 'failed' and r.kind == 'pair':
|
|
return i
|
|
if r.kind == 'progress':
|
|
return i
|
|
return self.focusables()[-1] if self.focusables() else 0
|
|
|
|
def job_desc(self):
|
|
j = self.job
|
|
cur = j.current()
|
|
lines = [ln for ln in j.lines if ln.strip() and not ln.startswith('@@')][-6:]
|
|
if j.result == 'failed':
|
|
return {'title': 'The Build Stopped' if j.kind != 'update' or cur.id not in ('download', 'verify')
|
|
else 'The Update Stopped',
|
|
'text': [self.friendly(j.error), 'Nothing in your game folder was changed.' if cur.id not in
|
|
('download', 'verify', 'tools', 'tools_update') else ''],
|
|
'log': lines, 'note': 'Try again first. If it stops again, use Copy Report and send it with your '
|
|
'issue. Never send game files.'}
|
|
if j.result == 'cancelled':
|
|
return {'title': 'Cancelled', 'text': ['Nothing was changed that a new start cannot finish.']}
|
|
if j.result == 'ok':
|
|
return {'title': 'Done', 'text': [f'Finished in {jobs.mmss(j.elapsed())}.']}
|
|
text = [cur.desc + (f' {cur.detail}.' if cur.detail else '')]
|
|
eta = j.eta()
|
|
return {'title': self.step_title(cur), 'text': text, 'progress': j.overall(),
|
|
'progress_label': ('Overall', self.eta_text(eta)),
|
|
'log': lines,
|
|
'note': 'Keep using your PC if you like. It stays awake until the build is done.'
|
|
if j.kind != 'update' or cur.id not in ('download', 'verify') else
|
|
'Your saves and settings stay as they are.'}
|
|
|
|
@staticmethod
|
|
def eta_text(eta):
|
|
if not eta:
|
|
return ''
|
|
minutes = max(1, int(eta // 60 + 1))
|
|
return 'about 1 minute left' if minutes == 1 else f'about {minutes} minutes left'
|
|
|
|
def step_title(self, st):
|
|
return {'tools': 'Gathering Build Tools', 'tools_update': 'Updating Build Tools', 'check': 'Inspecting Your Game',
|
|
'exefs': 'Unpacking the Program', 'romfs': 'Packing the Game Data', 'lift': 'Translating the Code',
|
|
'compile': 'Compiling', 'compile_changed': 'Compiling Changed Files', 'shaders': 'Preparing the Shaders',
|
|
'prompts': 'Drawing the Button Prompts', 'scan': 'Checking the Game Files', 'ready': 'Ready to Play',
|
|
'download': 'Downloading', 'verify': 'Verifying', 'swap': 'Swapping In the New Build'}.get(st.id,
|
|
st.label)
|
|
|
|
@staticmethod
|
|
def friendly(err):
|
|
e = (err or 'unknown error').strip()
|
|
return e[0].upper() + e[1:] + ('' if e.endswith('.') else '.')
|
|
|
|
def cancel_job(self):
|
|
if self.job and self.job.result is None:
|
|
self.job.cancel()
|
|
|
|
def retry_job(self):
|
|
j = self.job
|
|
if isinstance(j, jobs.InstallJob):
|
|
self.run_job(jobs.InstallJob(self.ctx, j.dump, j.game_dir), self.job_return, self.job_back)
|
|
elif isinstance(j, jobs.ContinueUpdateJob):
|
|
self.run_job(jobs.ContinueUpdateJob(self.ctx, j.game_dir, j.old, j.dump), self.job_return)
|
|
elif isinstance(j, jobs.RebuildJob):
|
|
self.run_job(jobs.RebuildJob(self.ctx, j.game_dir, j.dump), self.job_return)
|
|
elif isinstance(j, jobs.UpdateDownloadJob):
|
|
self.run_job(jobs.UpdateDownloadJob(self.ctx, j.release), self.job_return)
|
|
|
|
def copy_report(self):
|
|
j = self.job
|
|
report = [f'NX2navi {self.layout.version} ({"checkout" if self.layout.dev else "release"}), {sys.platform}',
|
|
f'Job: {j.kind}, step: {j.current().label}', f'Error: {j.error}', '', 'Last lines:']
|
|
report += [ln for ln in list(j.lines)[-60:]]
|
|
self.root.clipboard_clear()
|
|
self.root.clipboard_append('\n'.join(report))
|
|
self.message = 'Report copied.'
|
|
self.render()
|
|
|
|
def leave_job(self):
|
|
j = self.job
|
|
if j.result == 'ok':
|
|
self.show(self.job_return)
|
|
elif j.result == 'cancelled':
|
|
self.show(self.job_back)
|
|
else:
|
|
self.show(self.job_back)
|
|
|
|
def job_finished(self):
|
|
j = self.job
|
|
winsys.keep_awake(False)
|
|
self.ctx.log(f'job {j.kind}: {j.result} {j.error}')
|
|
if j.result in ('ok', 'failed'):
|
|
self.sounds.play('select' if j.result == 'ok' else 'back')
|
|
if j.result == 'ok':
|
|
if j.after == 'restart':
|
|
if winsys.restart(self.layout, ['--continue-update', versions.display(self.layout.version)]):
|
|
self.root.after(200, self.root.destroy)
|
|
return
|
|
self.message = 'The update is ready: start NX2navi again to finish it.'
|
|
elif j.after == 'staged':
|
|
self.message = 'The update was downloaded; a checkout keeps running from the repository.'
|
|
if not self.settings.get('keep_cache') and j.kind in ('install', 'update', 'rebuild'):
|
|
self.drop_build_cache()
|
|
if j.kind == 'install':
|
|
self.message = 'The game is built. Select Play.'
|
|
elif j.kind in ('update', 'rebuild'):
|
|
self.message = 'The game is rebuilt and up to date.' if j.kind == 'rebuild' else \
|
|
f'NX2navi is now {versions.display(self.layout.version)}.'
|
|
self.release = None
|
|
self.reload_fonts_if_new()
|
|
self.show(self.job_return)
|
|
if j.kind == 'install' and self.settings.get('auto_check'):
|
|
self.check_now()
|
|
elif j.result == 'cancelled':
|
|
self.show(self.job_back)
|
|
else:
|
|
self.show('job')
|
|
|
|
def drop_build_cache(self):
|
|
"""Keep Build Cache off: delete the lifted C and the objects (about 5 GB) but keep state.json, which an
|
|
update needs to rebuild without the dump; the next build then lifts and compiles everything again."""
|
|
gd = self.settings.get('game_dir')
|
|
if not gd:
|
|
return
|
|
cache = Path(gd) / 'build-cache'
|
|
for name in ('lift', 'obj', 'gen'):
|
|
shutil.rmtree(cache / name, ignore_errors=True)
|
|
for name in ('link.rsp', 'link.key'):
|
|
(cache / name).unlink(missing_ok=True)
|
|
|
|
def reload_fonts_if_new(self):
|
|
if not self.game_fonts and gamefiles.cached_fonts(self.layout):
|
|
self.reload_fonts()
|
|
|
|
# ---------------------------------------------------------------- timers
|
|
|
|
def tick(self):
|
|
try:
|
|
if self.check and self.check.result is not None and not getattr(self.check, 'seen', False):
|
|
self.check.seen = True
|
|
self.check_done()
|
|
ev = self.bg.poll(self.release, busy=bool(self.job and self.job.result is None))
|
|
if ev:
|
|
self.background_event(ev)
|
|
if self.screen == 'job' and self.job:
|
|
if self.job.rev != self.job_rev:
|
|
self.job_rev = self.job.rev
|
|
if self.job.result is not None:
|
|
self.job_finished()
|
|
else:
|
|
self.rows = self.rows_job()
|
|
self.focus = self.job_focus()
|
|
self.render()
|
|
elif int(time.time()) != self.last_second and self.job.result is None:
|
|
self.render()
|
|
self.last_second = int(time.time())
|
|
except Exception as e: # a drawing error must not stop the timer
|
|
self.ctx.log(f'tick: {e!r}')
|
|
self.root.after(150, self.tick)
|
|
|
|
def minute(self):
|
|
"""Once a minute, also while the window is hidden during play: look for a new version an hour after the last
|
|
look (a new one then downloads in the background), and install a staged release that waited for a copy of
|
|
the game the launcher did not start to close."""
|
|
try:
|
|
if not self.args.selftest and self.screen in ('home', 'settings'):
|
|
last = max(self.settings.get('last_check', 0.0), self.check_started)
|
|
if self.settings.get('auto_check') and time.time() - last >= jobs.RECHECK:
|
|
self.check_now()
|
|
if self.bg.waiting and not (self.game_proc and self.game_proc.poll() is None):
|
|
self.install_staged('the game closed')
|
|
except Exception as e:
|
|
self.ctx.log(f'minute: {e!r}')
|
|
self.root.after(60000, self.minute)
|
|
|
|
def animate(self):
|
|
"""One step of everything that moves: the cursor on its way to the focused row and its breath, the screen
|
|
sliding into place, the sparks and Navi. Each moves by the time since the last step (the timer comes when
|
|
Tk gets to it, 16 to 50 ms apart), at most a tenth of a second's worth. With Animations off, and while the
|
|
window is hidden or minimised (the game is running), nothing moves and the timer runs slowly."""
|
|
moving = False
|
|
try:
|
|
now = time.perf_counter()
|
|
dt = min(0.1, max(0.0, now - self.anim_last))
|
|
self.anim_last = now
|
|
moving = self.animated() and self.root.state() == 'normal'
|
|
if moving:
|
|
self.anim_t += dt
|
|
self.step_slide(dt)
|
|
self.step_cursor(dt)
|
|
self.step_backdrop(dt)
|
|
except Exception as e: # a drawing error must not stop the timer
|
|
self.ctx.log(f'animate: {e!r}')
|
|
self.root.after(FRAME_MS if moving else 300, self.animate)
|
|
|
|
def step_slide(self, dt):
|
|
if not self.slide:
|
|
return
|
|
left = self.slide * math.exp(-dt * 16) # most of the way in a fifth of a second
|
|
if abs(left) < 1:
|
|
left = 0.0
|
|
self.canvas.move('dyn', int(left) - self.slide_applied, 0)
|
|
self.slide, self.slide_applied = left, int(left)
|
|
|
|
def step_cursor(self, dt):
|
|
c = self.canvas
|
|
if self.cursor_frames: # one breath in 1.6 s
|
|
k = int(self.anim_t / 1.6 * len(self.cursor_frames)) % len(self.cursor_frames)
|
|
if k != self.cursor_k:
|
|
self.cursor_k = k
|
|
c.itemconfigure('cursor', image=self.cursor_frames[k])
|
|
if self.cur_xy and self.cur_target and self.cur_xy != self.cur_target:
|
|
(x, y), (tx, ty) = self.cur_xy, self.cur_target
|
|
f = 1 - math.exp(-dt * 22) # there in about a tenth of a second
|
|
x, y = x + (tx - x) * f, y + (ty - y) * f
|
|
if abs(tx - x) < 0.6 and abs(ty - y) < 0.6:
|
|
x, y = tx, ty
|
|
self.cur_xy = (x, y)
|
|
c.coords('cursor', round(x) + self.slide_applied, round(y))
|
|
|
|
def step_backdrop(self, dt):
|
|
c, t = self.canvas, self.anim_t
|
|
for sp in self.sparks: # up, swaying, and back in at the bottom once under the title band
|
|
sp['y'] -= sp['vy'] * dt
|
|
if sp['y'] < BAND[2] - 8:
|
|
sp['y'] = H + 8
|
|
c.coords(sp['id'], self.X(sp['x'] + math.sin(t * sp['rate'] + sp['phase']) * sp['sway']), self.X(sp['y']))
|
|
k = int((math.sin(t * sp['rate'] * 2.3 + sp['phase'] * 3) + 1) * 1.49) # 0, 1 or 2: its twinkle
|
|
if k != sp['shown']:
|
|
sp['shown'] = k
|
|
c.itemconfigure(sp['id'], image=sp['pics'][k])
|
|
if self.navi_xy: # Navi rises and sinks two pixels every 2.4 s, her halo pulses with her
|
|
x, y = self.navi_xy
|
|
c.coords('navi', x, y + round(math.sin(t * 2.6) * self.X(2)))
|
|
if self.halo:
|
|
c.itemconfigure('halo', image=self.halo[int(t / 2.4 * len(self.halo)) % len(self.halo)])
|
|
|
|
# ---------------------------------------------------------------- drawing
|
|
|
|
def render(self):
|
|
c = self.canvas
|
|
c.delete('dyn')
|
|
if self.screen == 'job' and self.job:
|
|
if self.job.result is None or self.rows == []:
|
|
self.rows = self.rows_job()
|
|
if not (0 <= self.focus < len(self.rows)) or not self.rows[self.focus].focusable:
|
|
self.focus = self.job_focus()
|
|
x0, x1, y = ROWS
|
|
heights, self.row_gap = self.fit_rows(y, H - 46 if self.screen == 'job' else H - 12)
|
|
for r, h in zip(self.rows, heights):
|
|
self.draw_row(r, self.X(x0), self.X(y), self.X(x1), self.X(y + h))
|
|
y += h + self.row_gap
|
|
self.draw_status()
|
|
self.draw_cursor()
|
|
if self.screen == 'color' and self.pick:
|
|
self.draw_picker()
|
|
else:
|
|
self.draw_desc()
|
|
self.slide_applied = int(self.slide)
|
|
if self.slide_applied: # drawn in place, then put where the slide has got to (step_slide)
|
|
c.move('dyn', self.slide_applied, 0)
|
|
|
|
def fit_rows(self, top, bottom):
|
|
"""The height of each row and the gap between rows (logical pixels) that end the rows above bottom. A screen
|
|
with more rows than the window holds (Settings with every row, a job with every step) narrows its gaps
|
|
first, then its dividers, and last makes its rows a little lower."""
|
|
rows = self.rows
|
|
hs = [r.h for r in rows]
|
|
gaps = max(1, len(rows) - 1)
|
|
room = bottom - top
|
|
for stage in ('gaps', 'dividers', 'rows'):
|
|
if stage == 'dividers':
|
|
hs = [min(h, 14) if r.kind == 'divider' else h for r, h in zip(rows, hs)]
|
|
elif stage == 'rows':
|
|
over = sum(hs) + 6 * gaps - room
|
|
cut = -(-over // max(1, sum(1 for r in rows if r.kind != 'divider')))
|
|
hs = [h if r.kind == 'divider' else max(30, h - cut) for r, h in zip(rows, hs)]
|
|
gap = min(GAP, (room - sum(hs)) // gaps)
|
|
if gap >= 6:
|
|
return hs, gap
|
|
return hs, 6
|
|
|
|
def draw_status(self):
|
|
if self.screen != 'job' or not self.job:
|
|
return
|
|
j = self.job
|
|
steps = [s for s in j.steps]
|
|
cur = j.current()
|
|
n = steps.index(cur) + 1 if cur in steps else len(steps)
|
|
left = f'Stopped at step {n} of {len(steps)}' if j.result == 'failed' else f'Step {n} of {len(steps)}'
|
|
if self.message:
|
|
left = self.message
|
|
y = self.X(H - 24)
|
|
self.canvas.create_text(self.X(PANEL[0] + 30), y, text=left, anchor='sw', font=self.f['status'],
|
|
fill=P['status'], tags='dyn')
|
|
self.canvas.create_text(self.X(PANEL[2] - 30), y, text=f'Elapsed {jobs.mmss(j.elapsed())}', anchor='se',
|
|
font=self.f['status'], fill=P['status'], tags='dyn')
|
|
|
|
def text(self, x, y, text, font, fill, anchor='w', shadow=True, width=0):
|
|
c = self.canvas
|
|
if shadow:
|
|
c.create_text(x + 1, y + 1, text=text, font=font, fill=P['text_shadow'], anchor=anchor, tags='dyn',
|
|
width=width)
|
|
return c.create_text(x, y, text=text, font=font, fill=fill, anchor=anchor, tags='dyn', width=width)
|
|
|
|
def draw_row(self, r, x0, y0, x1, y1):
|
|
c, s, X = self.canvas, self.s, self.X
|
|
r.rect = (x0, y0, x1, y1)
|
|
if r.kind == 'divider':
|
|
img = self.sprite(('filigree', x1 - x0), lambda: art.filigree(x1 - x0, s)[0])
|
|
c.create_image(x0, (y0 + y1) // 2, anchor='w', image=img, tags='dyn')
|
|
return
|
|
if r.kind in ('action', 'pair'):
|
|
parts = [(r.label, r.on)] if r.kind == 'action' else r.actions
|
|
gap = X(GAP)
|
|
w = (x1 - x0 - gap * (len(parts) - 1)) // len(parts)
|
|
r.parts = []
|
|
for k, (label, cb) in enumerate(parts):
|
|
px0 = x0 + k * (w + gap)
|
|
px1 = px0 + w if k < len(parts) - 1 else x1
|
|
lit = r is self.focused() and (r.kind == 'action' or getattr(r, 'pick', 0) == k)
|
|
img = self.sprite(('label', px1 - px0, y1 - y0, lit),
|
|
lambda a=px1 - px0, b=y1 - y0: art.label_cell(a, b, s, lit))
|
|
ring = max(1, int(round(s)))
|
|
c.create_image(px0 - ring, y0 - ring, anchor='nw', image=img, tags='dyn')
|
|
font = self.fit_font(self.f['action_big'] if r.h >= 50 else self.f['action'], label,
|
|
px1 - px0 - X(16))
|
|
self.text((px0 + px1) // 2, (y0 + y1) // 2, label, font, P['text_bright'], anchor='center')
|
|
r.parts.append(((px0, y0, px1, y1), label, cb))
|
|
r.value_rect = None
|
|
return
|
|
lw = X(LABEL_W)
|
|
lx1 = x0 + lw
|
|
lit = r is self.focused()
|
|
img = self.sprite(('label', lw, y1 - y0, lit), lambda: art.label_cell(lw, y1 - y0, s, lit))
|
|
ring = max(1, int(round(s)))
|
|
c.create_image(x0 - ring, y0 - ring, anchor='nw', image=img, tags='dyn')
|
|
tx = x0 + X(16)
|
|
if r.icon:
|
|
size = X(32)
|
|
ic = self.sprite(('icon', r.icon, size), lambda: art.framed_icon(size, s, r.icon))
|
|
c.create_image(tx, (y0 + y1) // 2, anchor='w', image=ic, tags='dyn')
|
|
tx += size + X(14)
|
|
font = (self.f['big'] if r.icon else self.f['big_small']) if r.big else self.f['label']
|
|
font = self.fit_font(font, r.label, lx1 - tx - X(10) - (X(32) if r.badge else 0))
|
|
tid = self.text(tx, (y0 + y1) // 2, r.label, font, P['text_bright'] if r.big else P['text'])
|
|
if r.badge:
|
|
bx = self.canvas.bbox(tid)[2] + X(10)
|
|
size = X(22)
|
|
b = self.sprite(('badge', size), lambda: art.badge(size, s))
|
|
c.create_image(bx, (y0 + y1) // 2, anchor='w', image=b, tags='dyn')
|
|
c.create_text(bx + (size + 4) // 2, (y0 + y1) // 2, text='!', font=self.f['badge'],
|
|
fill=P['badge_text'], tags='dyn')
|
|
vx0 = lx1
|
|
r.value_rect = (vx0, y0, x1, y1)
|
|
c.create_rectangle(vx0, y0, x1 - 1, y1 - 1, fill=P['value'], outline=P['value_border'], width=1, tags='dyn')
|
|
pad = X(3) if not r.big else X(4)
|
|
ix0, iy0, ix1, iy1 = vx0 + pad, y0 + pad, x1 - pad, y1 - pad
|
|
cy = (y0 + y1) // 2
|
|
cx = (vx0 + x1) // 2
|
|
vfont = self.f['label'] if r.kind in ('toggle', 'enum', 'progress') or r.style == 'orange_arrow' \
|
|
else self.f['value']
|
|
if r.kind == 'toggle':
|
|
on = bool(r.get())
|
|
mid = (ix0 + ix1) // 2
|
|
for k, (label, sel) in enumerate((('On', on), ('Off', not on))):
|
|
sx0, sx1 = (ix0, mid) if k == 0 else (mid, ix1)
|
|
if sel:
|
|
self.orange(sx0, iy0, sx1, iy1)
|
|
self.canvas.create_text((sx0 + sx1) // 2, cy, text=label, font=vfont,
|
|
fill=P['on_text'] if sel else P['off'], tags='dyn')
|
|
elif r.kind == 'enum':
|
|
self.orange(ix0, iy0, ix1, iy1)
|
|
cw, ch = X(9), X(12)
|
|
for left in (True, False):
|
|
img = self.sprite(('chev', cw, ch, left, 'on'), lambda lf=left: art.chevron(cw, ch, P['on_text'], lf))
|
|
c.create_image(ix0 + X(9) if left else ix1 - X(9), cy, anchor='w' if left else 'e', image=img,
|
|
tags='dyn')
|
|
self.canvas.create_text(cx, cy, text=r.get(), font=vfont, fill=P['on_text'], tags='dyn')
|
|
elif r.kind == 'progress':
|
|
fw = int((ix1 - ix0) * max(0.0, min(1.0, r.fraction)))
|
|
if fw > 2:
|
|
self.orange(ix0, iy0, ix0 + fw, iy1)
|
|
self.canvas.create_text(cx + 1, cy + 1, text=r.value, font=vfont, fill=P['band_shadow'], tags='dyn')
|
|
self.canvas.create_text(cx, cy, text=r.value, font=vfont, fill=P['on_text'], tags='dyn')
|
|
elif r.style == 'orange_arrow':
|
|
self.orange(ix0, iy0, ix1, iy1)
|
|
cw, ch = X(9), X(12)
|
|
vfont = self.fit_font(vfont, r.value, (ix1 - ix0) - X(30) - cw, 0.8)
|
|
label_w = vfont.measure(r.value)
|
|
total = label_w + X(10) + cw
|
|
sx = cx - total // 2
|
|
self.canvas.create_text(sx, cy, text=r.value, font=vfont, fill=P['on_text'], anchor='w', tags='dyn')
|
|
img = self.sprite(('chev', cw, ch, False, 'on'), lambda: art.chevron(cw, ch, P['on_text']))
|
|
c.create_image(sx + label_w + X(10), cy, anchor='w', image=img, tags='dyn')
|
|
elif r.style == 'swatch': # a colour of the Custom scheme: a patch of it and its hex code
|
|
cw, ch = X(9), X(12)
|
|
img = self.sprite(('chev', cw, ch, False, 'dim'), lambda: art.chevron(cw, ch, P['chevron_dim']))
|
|
c.create_image(x1 - X(16), cy, anchor='e', image=img, tags='dyn')
|
|
tw = vfont.measure(r.value.upper())
|
|
sw = X(26)
|
|
sx = (vx0 + x1 - X(16) - cw - X(10)) // 2 - (sw + X(10) + tw) // 2
|
|
c.create_rectangle(sx, cy - X(9), sx + sw, cy + X(9), fill=r.value, outline=P['value_border'], tags='dyn')
|
|
c.create_text(sx + sw + X(10), cy, text=r.value.upper(), font=vfont, fill=P['value_text'], anchor='w',
|
|
tags='dyn')
|
|
elif r.style == 'arrow':
|
|
cw, ch = X(9), X(12)
|
|
img = self.sprite(('chev', cw, ch, False, 'dim'), lambda: art.chevron(cw, ch, P['chevron_dim']))
|
|
c.create_image(x1 - X(16), cy, anchor='e', image=img, tags='dyn')
|
|
if r.value: # a value, centred in the cell's room left of the arrow
|
|
right = x1 - X(16) - cw - X(10)
|
|
avail = right - vx0 - X(16)
|
|
vfont = self.fit_font(vfont, r.value, avail, 0.8)
|
|
c.create_text((vx0 + right) // 2, cy, text=fit(vfont, r.value, avail), font=vfont,
|
|
fill=P['value_text'], tags='dyn')
|
|
else:
|
|
color = {'normal': P['value_text'], 'dim': P['dim'], 'salmon': P['salmon']}.get(r.style, P['value_text'])
|
|
avail = (x1 - vx0) - X(20) - (X(21) if r.check else 0)
|
|
vfont = self.fit_font(vfont, r.value, avail, 0.8)
|
|
label = fit(vfont, r.value, avail)
|
|
tw = vfont.measure(label)
|
|
sx = cx - (tw + (X(21) if r.check else 0)) // 2
|
|
if r.check:
|
|
size = X(14)
|
|
ck = self.sprite(('check', size), lambda: art.check_mark(size, s))
|
|
c.create_image(sx, cy, anchor='w', image=ck, tags='dyn')
|
|
sx += X(21)
|
|
self.canvas.create_text(sx, cy, text=label, font=vfont, fill=color, anchor='w', tags='dyn')
|
|
|
|
def orange(self, x0, y0, x1, y1, tags=('dyn',)):
|
|
c = self.canvas
|
|
c.create_rectangle(x0, y0, x1 - 1, y1 - 1, fill=P['orange'], outline=P['orange_dark'], width=1, tags=tags)
|
|
if x1 - x0 > 4:
|
|
c.create_rectangle(x0 + 1, y0 + 1, x1 - 2, y1 - 2, outline=P['orange_light'], width=1, tags=tags)
|
|
|
|
def draw_cursor(self):
|
|
r = self.focused()
|
|
self.cursor_frames = []
|
|
if not r or not r.rect:
|
|
self.cur_xy = self.cur_target = None
|
|
return
|
|
if r.kind == 'pair' and r.parts:
|
|
box = r.parts[getattr(r, 'pick', 0)][0]
|
|
elif (r.kind in ('toggle', 'enum', 'progress') or r.focus_value) and r.value_rect:
|
|
box = r.value_rect
|
|
else:
|
|
box = r.rect
|
|
x0, y0, x1, y1 = box
|
|
frames, mg = self.glow(x1 - x0, y1 - y0)
|
|
self.cursor_frames = frames
|
|
self.cursor_k %= len(frames)
|
|
# The cursor is drawn where it has got to and animate takes it on to the row (step_cursor); on a new screen,
|
|
# while one slides in and with Animations off it stands on the row at once.
|
|
target = (float(x0 - mg), float(y0 - mg))
|
|
glide = self.animated() and self.cur_xy and self.cur_screen == self.screen and not self.slide
|
|
self.cur_xy = self.cur_xy if glide else target
|
|
self.cur_target, self.cur_screen = target, self.screen
|
|
self.canvas.create_image(round(self.cur_xy[0]), round(self.cur_xy[1]), anchor='nw',
|
|
image=frames[self.cursor_k], tags=('dyn', 'cursor'))
|
|
|
|
def glow(self, w, h):
|
|
key = ('glow', w, h)
|
|
if key not in self.sprites:
|
|
frames, mg = art.glow_frames(w, h, self.s)
|
|
self.sprites[key] = ([art.photo(self.root, f) for f in frames], mg)
|
|
return self.sprites[key]
|
|
|
|
def draw_desc(self):
|
|
r = self.focused()
|
|
d = r.desc if r else None
|
|
if callable(d):
|
|
d = d()
|
|
if not d:
|
|
if self.message and self.screen != 'job':
|
|
d = (self.message,)
|
|
else:
|
|
return
|
|
if isinstance(d, tuple):
|
|
d = {'text': [t for t in d if t]}
|
|
elif self.message and self.screen in ('first_run', 'settings', 'uninstall'):
|
|
d = dict(d)
|
|
d['text'] = [self.message] + list(d.get('text', []))
|
|
c, X = self.canvas, self.X
|
|
dx, dw = DESC
|
|
x0 = X(dx)
|
|
w = X(dw)
|
|
padx, pady = X(26), X(22)
|
|
inner = w - 2 * padx
|
|
items = []
|
|
y = 0
|
|
|
|
def add(kind, *args):
|
|
items.append((kind, args))
|
|
|
|
if d.get('title'):
|
|
add('text', d['title'], self.f['heading'], P['text_bright'], X(14))
|
|
for t in d.get('text', []):
|
|
if t:
|
|
add('text', t, self.f['desc'], P['desc_text'], X(12))
|
|
if d.get('progress') is not None:
|
|
add('progress', d['progress'], d.get('progress_label', ('', '')))
|
|
if d.get('divider'):
|
|
add('filigree')
|
|
if d.get('subtitle'):
|
|
add('text', d['subtitle'], self.f['sub'], P['gold'], X(8))
|
|
for b in d.get('bullets', []):
|
|
add('bullet', b)
|
|
if d.get('log'):
|
|
add('log', d['log'])
|
|
if d.get('note'):
|
|
add('text', d['note'], self.f['small'], P['muted'], X(4))
|
|
# measure: draw at a provisional top, then move everything to the final top
|
|
top0 = X(112)
|
|
y = top0 + pady
|
|
made = []
|
|
for kind, args in items:
|
|
if kind == 'text':
|
|
text, font, fill, after = args
|
|
tid = c.create_text(x0 + padx, y, text=text, font=font, fill=fill, anchor='nw', width=inner,
|
|
tags=('dyn', 'desc'))
|
|
bb = c.bbox(tid)
|
|
y = bb[3] + after
|
|
made.append(tid)
|
|
elif kind == 'progress':
|
|
frac, (left, right) = args
|
|
c.create_text(x0 + padx, y, text=left, font=self.f['small'], fill=P['muted'], anchor='nw',
|
|
tags=('dyn', 'desc'))
|
|
c.create_text(x0 + w - padx, y, text=right, font=self.f['small'], fill=P['muted'], anchor='ne',
|
|
tags=('dyn', 'desc'))
|
|
y += self.f['small'].metrics('linespace') + X(6)
|
|
h = X(22)
|
|
c.create_rectangle(x0 + padx, y, x0 + w - padx - 1, y + h - 1, fill=P['value'],
|
|
outline=P['value_border'], tags=('dyn', 'desc'))
|
|
fw = int((inner - X(6)) * frac)
|
|
if fw > 2:
|
|
self.orange(x0 + padx + X(3), y + X(3), x0 + padx + X(3) + fw, y + h - X(3), ('dyn', 'desc'))
|
|
y += h + X(14)
|
|
elif kind == 'filigree':
|
|
img = self.sprite(('filigree', inner), lambda: art.filigree(inner, self.s)[0])
|
|
c.create_image(x0 + padx, y + X(9), anchor='w', image=img, tags=('dyn', 'desc'))
|
|
y += X(22)
|
|
elif kind == 'bullet':
|
|
(text,) = args
|
|
c.create_rectangle(x0 + padx + X(1), y + X(8), x0 + padx + X(6), y + X(13), fill=P['bullet'],
|
|
outline='', tags=('dyn', 'desc'))
|
|
tid = c.create_text(x0 + padx + X(16), y, text=text, font=self.f['value'], fill=P['desc_text'],
|
|
anchor='nw', width=inner - X(16), tags=('dyn', 'desc'))
|
|
y = c.bbox(tid)[3] + X(8)
|
|
elif kind == 'log':
|
|
(lines,) = args
|
|
lh = self.f['mono'].metrics('linespace')
|
|
shown = [fit(self.f['mono'], ln, inner - X(24)) for ln in lines[-6:]]
|
|
h = lh * max(len(shown), 1) + X(20)
|
|
c.create_rectangle(x0 + padx, y, x0 + w - padx - 1, y + h - 1, fill=P['value'],
|
|
outline=P['value_border'], tags=('dyn', 'desc'))
|
|
ty = y + X(10)
|
|
for ln in shown:
|
|
color = P['salmon'] if ln.lower().startswith(('error', 'stopped')) else P['log_text']
|
|
c.create_text(x0 + padx + X(12), ty, text=ln, font=self.f['mono'], fill=color, anchor='nw',
|
|
tags=('dyn', 'desc'))
|
|
ty += lh
|
|
y += h + X(14)
|
|
height = y - top0 + pady - X(4)
|
|
top = X(236) if height < X(200) else X(112)
|
|
top = min(top, X(H - 30) - height) if top + height > X(H - 30) else top
|
|
dy = top - top0
|
|
if dy:
|
|
c.move('desc', 0, dy)
|
|
hb = max(X(60), height)
|
|
key = ('desc', w, hb // 4)
|
|
if key not in self.sprites:
|
|
img, pad = art.desc_box(w, hb, self.s, self.see_through())
|
|
self.sprites[key] = (art.photo(self.root, img), pad)
|
|
pic, pad = self.sprites[key]
|
|
bid = c.create_image(x0 - pad, top - pad, anchor='nw', image=pic, tags='dyn')
|
|
c.tag_lower(bid, 'desc')
|
|
|
|
|
|
def parse_args(argv):
|
|
ap = argparse.ArgumentParser(prog='NX2navi')
|
|
ap.add_argument('--from', dest='source', help='a folder to look in for the dump first (the setup\'s folder)')
|
|
ap.add_argument('--continue-update', metavar='OLD', help='finish an update after the restart (from OLD)')
|
|
ap.add_argument('--rolled-back', metavar='VERSION', help='the stub went back from VERSION')
|
|
ap.add_argument('--uninstall', action='store_true')
|
|
ap.add_argument('--root', help='the launcher folder (tests)')
|
|
ap.add_argument('--selftest', action='store_true', help='draw every screen and close')
|
|
ap.add_argument('--shots', help='with --selftest: save a PNG of each screen into this folder')
|
|
ap.add_argument('--play', action='store_true',
|
|
help='start the game without the window and wait for it (the Steam shortcut, launcher/steam.py)')
|
|
a, _unknown = ap.parse_known_args(argv)
|
|
return a
|
|
|
|
|
|
def single_instance(layout, wait):
|
|
"""An exclusive lock on data/launcher.lock for this process's life, or None when another launcher holds it after
|
|
wait seconds (the one an update restart replaces may still be closing)."""
|
|
path = layout.data / 'launcher.lock'
|
|
deadline = time.time() + wait
|
|
while True:
|
|
try:
|
|
f = open(path, 'a+')
|
|
try:
|
|
if os.name == 'nt':
|
|
import msvcrt
|
|
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
import fcntl
|
|
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
return f
|
|
except OSError:
|
|
f.close()
|
|
raise
|
|
except OSError:
|
|
if time.time() >= deadline:
|
|
return None
|
|
time.sleep(0.25)
|
|
|
|
|
|
def play_only(ctx):
|
|
"""--play, from the Steam shortcut (launcher/steam.py): the game starts from its folder with its console hidden, as
|
|
Play starts it, and this process waits until it and the copies its own Restart starts have ended, so Steam shows
|
|
the game as running all that time. None when there is no built game: the launcher's window opens instead."""
|
|
gd = ctx.settings.get('game_dir')
|
|
exe = Path(gd) / 'ocarina.exe' if gd else None
|
|
if not exe or not exe.is_file():
|
|
return None
|
|
if saves.game_running(gd):
|
|
ctx.log('play from Steam: the game is already running')
|
|
return 0
|
|
try:
|
|
proc = winsys.start_game(exe)
|
|
except OSError as e:
|
|
ctx.log(f'play from Steam: {exe}: {e}')
|
|
return None
|
|
ctx.log(f'play from Steam: {exe}')
|
|
time.sleep(2)
|
|
while proc.poll() is None or saves.game_running(gd):
|
|
time.sleep(1)
|
|
ctx.log(f'play from Steam: the game ended (code {proc.returncode})')
|
|
return 0
|
|
|
|
|
|
def main(argv=None):
|
|
a = parse_args(sys.argv[1:] if argv is None else argv)
|
|
winsys.dpi_aware()
|
|
winsys.app_id()
|
|
from ..paths import Layout
|
|
ctx = Context(Layout(root=a.root) if a.root else None)
|
|
if a.play and not a.selftest:
|
|
code = play_only(ctx)
|
|
if code is not None:
|
|
return code
|
|
lock = None
|
|
if not a.selftest:
|
|
lock = single_instance(ctx.layout, 20 if (a.continue_update or a.rolled_back) else 2)
|
|
if lock is None:
|
|
ctx.log('another launcher is running')
|
|
return 0
|
|
root = tk.Tk(className='NX2navi')
|
|
app = App(root, ctx, a)
|
|
if a.selftest:
|
|
from .selftest import run
|
|
root.after(300, lambda: run(app, a.shots))
|
|
root.mainloop()
|
|
return 0
|