Archipelago/LttPAdjuster.py

1267 lines
51 KiB
Python

#!/usr/bin/env python3
import argparse
import json
import os
import logging
import queue
import random
import shutil
import textwrap
import sys
import threading
import time
import tkinter as tk
from argparse import Namespace
from concurrent.futures import as_completed, ThreadPoolExecutor
from glob import glob
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
from tkinter.constants import DISABLED, NORMAL
from urllib.parse import urlparse
from urllib.request import urlopen
import ModuleUpdate
ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
get_adjuster_settings, tkinter_center_window, init_logging
from Patch import GAME_ALTTP
class AdjusterWorld(object):
def __init__(self, sprite_pool):
import random
self.sprite_pool = {1: sprite_pool}
self.slot_seeds = {1: random}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def _get_help_string(self, action):
return textwrap.dedent(action.help)
def main():
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
parser.add_argument('--baserom', default='Zelda no Densetsu - Kamigami no Triforce (Japan).sfc',
help='Path to an ALttP Japan(1.0) rom to use as a base.')
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
help='''\
Select the rate at which the menu opens and closes.
(default: %(default)s)
''')
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
help='''\
Hide the triforce hud in certain circumstances.
hide_goal will hide the hud until finding a triforce piece, hide_required will hide the total amount needed to win
(Both can be revealed when speaking to Murahalda)
(default: %(default)s)
''')
parser.add_argument('--enableflashing',
help='Reenable flashing animations (unfriendly to epilepsy, always disabled in race roms)',
action='store_false', dest="reduceflashing")
parser.add_argument('--heartbeep', default='normal', const='normal', nargs='?',
choices=['double', 'normal', 'half', 'quarter', 'off'],
help='''\
Select the rate at which the heart beep sound is played at
low health. (default: %(default)s)
''')
parser.add_argument('--heartcolor', default='red', const='red', nargs='?',
choices=['red', 'blue', 'green', 'yellow', 'random'],
help='Select the color of Link\'s heart meter. (default: %(default)s)')
parser.add_argument('--ow_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
parser.add_argument('--link_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
parser.add_argument('--shield_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
parser.add_argument('--sword_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
parser.add_argument('--hud_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
parser.add_argument('--uw_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
parser.add_argument('--sprite', help='''\
Path to a sprite sheet to use for Link. Needs to be in
binary format and have a length of 0x7000 (28672) bytes,
or 0x7078 (28792) bytes including palette data.
Alternatively, can be a ALttP Rom patched with a Link
sprite that will be extracted.
''')
parser.add_argument('--names', default='', type=str)
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
args = parser.parse_args()
args.music = not args.disablemusic
# set up logger
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
args.loglevel]
logging.basicConfig(format='%(message)s', level=loglevel)
if args.update_sprites:
run_sprite_update()
sys.exit()
if not os.path.isfile(args.rom):
adjustGUI()
else:
if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite):
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
sys.exit(1)
args, path = adjust(args=args)
if isinstance(args.sprite, Sprite):
args.sprite = args.sprite.name
persistent_store("adjuster", GAME_ALTTP, args)
def adjust(args):
start = time.perf_counter()
init_logging("LttP Adjuster")
logger = logging.getLogger('Adjuster')
logger.info('Patching ROM.')
vanillaRom = args.baserom
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
vanillaRom = local_path(vanillaRom)
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
import Patch
meta, args.rom = Patch.create_rom_file(args.rom)
if os.stat(args.rom).st_size in (0x200000, 0x400000) and os.path.splitext(args.rom)[-1].lower() == '.sfc':
rom = LocalRom(args.rom, patch=False, vanillaRom=vanillaRom)
else:
raise RuntimeError(
'Provided Rom is not a valid Link to the Past Randomizer Rom. Please provide one for adjusting.')
palettes_options = {}
palettes_options['dungeon'] = args.uw_palettes
palettes_options['overworld'] = args.ow_palettes
palettes_options['hud'] = args.hud_palettes
palettes_options['sword'] = args.sword_palettes
palettes_options['shield'] = args.shield_palettes
# palettes_options['link']=args.link_palettesvera
racerom = rom.read_byte(0x180213) > 0
world = None
if hasattr(args, "world"):
world = getattr(args, "world")
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
deathlink=args.deathlink, allowcollect=args.allowcollect)
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
rom.write_to_file(path)
logger.info('Done. Enjoy.')
logger.debug('Total Time: %s', time.perf_counter() - start)
return args, path
def adjustGUI():
from tkinter import Tk, LEFT, BOTTOM, TOP, \
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
from argparse import Namespace
from Main import __version__ as MWVersion
adjustWindow = Tk()
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
set_icon(adjustWindow)
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
bottomFrame2 = Frame(adjustWindow)
romFrame, romVar = get_rom_frame(adjustWindow)
romDialogFrame = Frame(adjustWindow)
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
romVar2 = StringVar()
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
def RomSelect2():
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
romDialogFrame.pack(side=TOP, expand=True, fill=X)
baseRomLabel2.pack(side=LEFT)
romEntry2.pack(side=LEFT, expand=True, fill=X)
romSelectButton2.pack(side=LEFT)
def adjustRom():
guiargs = Namespace()
guiargs.auto_apply = rom_vars.auto_apply.get()
guiargs.heartbeep = rom_vars.heartbeepVar.get()
guiargs.heartcolor = rom_vars.heartcolorVar.get()
guiargs.menuspeed = rom_vars.menuspeedVar.get()
guiargs.ow_palettes = rom_vars.owPalettesVar.get()
guiargs.uw_palettes = rom_vars.uwPalettesVar.get()
guiargs.hud_palettes = rom_vars.hudPalettesVar.get()
guiargs.sword_palettes = rom_vars.swordPalettesVar.get()
guiargs.shield_palettes = rom_vars.shieldPalettesVar.get()
guiargs.quickswap = bool(rom_vars.quickSwapVar.get())
guiargs.music = bool(rom_vars.MusicVar.get())
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
guiargs.rom = romVar2.get()
guiargs.baserom = romVar.get()
guiargs.sprite = rom_vars.sprite
if rom_vars.sprite_pool:
guiargs.world = AdjusterWorld(rom_vars.sprite_pool)
try:
guiargs, path = adjust(args=guiargs)
if rom_vars.sprite_pool:
guiargs.sprite_pool = rom_vars.sprite_pool
delattr(guiargs, "world")
except Exception as e:
logging.exception(e)
messagebox.showerror(title="Error while adjusting Rom", message=str(e))
else:
messagebox.showinfo(title="Success", message=f"Rom patched successfully to {path}")
if isinstance(guiargs.sprite, Sprite):
guiargs.sprite = guiargs.sprite.name
delattr(guiargs, "rom")
persistent_store("adjuster", GAME_ALTTP, guiargs)
def saveGUISettings():
guiargs = Namespace()
guiargs.auto_apply = rom_vars.auto_apply.get()
guiargs.heartbeep = rom_vars.heartbeepVar.get()
guiargs.heartcolor = rom_vars.heartcolorVar.get()
guiargs.menuspeed = rom_vars.menuspeedVar.get()
guiargs.ow_palettes = rom_vars.owPalettesVar.get()
guiargs.uw_palettes = rom_vars.uwPalettesVar.get()
guiargs.hud_palettes = rom_vars.hudPalettesVar.get()
guiargs.sword_palettes = rom_vars.swordPalettesVar.get()
guiargs.shield_palettes = rom_vars.shieldPalettesVar.get()
guiargs.quickswap = bool(rom_vars.quickSwapVar.get())
guiargs.music = bool(rom_vars.MusicVar.get())
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
guiargs.baserom = romVar.get()
if isinstance(rom_vars.sprite, Sprite):
guiargs.sprite = rom_vars.sprite.name
else:
guiargs.sprite = rom_vars.sprite
guiargs.sprite_pool = rom_vars.sprite_pool
persistent_store("adjuster", GAME_ALTTP, guiargs)
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
rom_options_frame.pack(side=TOP)
adjustButton.pack(side=LEFT, padx=(5,5))
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
saveButton.pack(side=LEFT, padx=(5,5))
bottomFrame2.pack(side=TOP, pady=(5,5))
tkinter_center_window(adjustWindow)
adjustWindow.mainloop()
def run_sprite_update():
import threading
done = threading.Event()
try:
top = Tk()
except:
task = BackgroundTaskProgressNullWindow(update_sprites, lambda successful, resultmessage: done.set())
else:
top.withdraw()
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
while not done.isSet():
task.do_events()
logging.info("Done updating sprites")
def update_sprites(task, on_finish=None):
resultmessage = ""
successful = True
sprite_dir = user_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context()
def finished():
task.close_window()
if on_finish:
on_finish(successful, resultmessage)
try:
task.update_status("Downloading alttpr sprites list")
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
sprites_arr = json.loads(response.read().decode("utf-8"))
except Exception as e:
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False
task.queue_event(finished)
return
try:
task.update_status("Determining needed sprites")
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
filename not in current_sprites]
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
type(e).__name__, e)
successful = False
task.queue_event(finished)
return
def dl(sprite_url, filename):
target = os.path.join(sprite_dir, filename)
with urlopen(sprite_url, context=ctx) as response, open(target, 'wb') as out:
shutil.copyfileobj(response, out)
def rem(sprite):
os.remove(os.path.join(sprite_dir, sprite))
with ThreadPoolExecutor() as pool:
dl_tasks = []
rem_tasks = []
for (sprite_url, filename) in needed_sprites:
dl_tasks.append(pool.submit(dl, sprite_url, filename))
for sprite in obsolete_sprites:
rem_tasks.append(pool.submit(rem, sprite))
deleted = 0
updated = 0
for dl_task in as_completed(dl_tasks):
updated += 1
task.update_status("Downloading needed sprite %g/%g" % (updated, len(needed_sprites)))
try:
dl_task.result()
except Exception as e:
logging.exception(e)
resultmessage = "Error downloading sprite. Not all sprites updated.\n\n%s: %s" % (
type(e).__name__, e)
successful = False
for rem_task in as_completed(rem_tasks):
deleted += 1
task.update_status("Removing obsolete sprite %g/%g" % (deleted, len(obsolete_sprites)))
try:
rem_task.result()
except Exception as e:
logging.exception(e)
resultmessage = "Error removing obsolete sprite. Not all sprites updated.\n\n%s: %s" % (
type(e).__name__, e)
successful = False
if successful:
resultmessage = "alttpr sprites updated successfully"
task.queue_event(finished)
def set_icon(window):
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
window.tk.call('wm', 'iconphoto', window._w, logo)
class BackgroundTask(object):
def __init__(self, window, code_to_run, *args):
self.window = window
self.queue = queue.Queue()
self.running = True
self.process_queue()
self.task = threading.Thread(target=code_to_run, args=(self, *args))
self.task.start()
def stop(self):
self.running = False
# safe to call from worker
def queue_event(self, event):
self.queue.put(event)
def process_queue(self):
try:
while True:
if not self.running:
return
event = self.queue.get_nowait()
event()
if self.running:
# if self is no longer running self.window may no longer be valid
self.window.update_idletasks()
except queue.Empty:
pass
if self.running:
self.window.after(100, self.process_queue)
class BackgroundTaskProgress(BackgroundTask):
def __init__(self, parent, code_to_run, title, *args):
self.parent = parent
self.window = tk.Toplevel(parent)
self.window['padx'] = 5
self.window['pady'] = 5
try:
self.window.attributes("-toolwindow", 1)
except tk.TclError:
pass
self.window.wm_title(title)
self.label_var = tk.StringVar()
self.label_var.set("")
self.label = tk.Label(self.window, textvariable=self.label_var, width=50)
self.label.pack()
self.window.resizable(width=False, height=False)
set_icon(self.window)
self.window.focus()
super().__init__(self.window, code_to_run, *args)
# safe to call from worker thread
def update_status(self, text):
self.queue_event(lambda: self.label_var.set(text))
def do_events(self):
self.parent.update()
# only call this in an event callback
def close_window(self):
self.stop()
self.window.destroy()
class BackgroundTaskProgressNullWindow(BackgroundTask):
def __init__(self, code_to_run, *args):
super().__init__(None, code_to_run, *args)
def process_queue(self):
try:
while True:
if not self.running:
return
event = self.queue.get_nowait()
event()
except queue.Empty:
pass
def do_events(self):
self.process_queue()
def update_status(self, text):
self.queue_event(lambda: logging.info(text))
def close_window(self):
self.stop()
def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
if not adjuster_settings:
adjuster_settings = Namespace()
adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
romFrame = Frame(parent)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
romVar = StringVar(value=adjuster_settings.baserom)
romEntry = Entry(romFrame, textvariable=romVar)
def RomSelect():
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc")), ("All Files", "*")])
try:
get_base_rom_bytes(rom) # throws error on checksum fail
except Exception as e:
logging.exception(e)
messagebox.showerror(title="Error while reading ROM", message=str(e))
else:
romVar.set(rom)
romSelectButton['state'] = "disabled"
romSelectButton["text"] = "ROM verified"
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
baseRomLabel.pack(side=LEFT)
romEntry.pack(side=LEFT, expand=True, fill=X)
romSelectButton.pack(side=LEFT)
romFrame.pack(side=TOP, expand=True, fill=X)
return romFrame, romVar
def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
defaults = {
"auto_apply": 'ask',
"music": True,
"reduceflashing": True,
"deathlink": False,
"sprite": None,
"quickswap": True,
"menuspeed": 'normal',
"heartcolor": 'red',
"heartbeep": 'normal',
"ow_palettes": 'default',
"uw_palettes": 'default',
"hud_palettes": 'default',
"sword_palettes": 'default',
"shield_palettes": 'default',
"sprite_pool": [],
"allowcollect": False,
}
if not adjuster_settings:
adjuster_settings = Namespace()
for key, defaultvalue in defaults.items():
if not hasattr(adjuster_settings, key):
setattr(adjuster_settings, key, defaultvalue)
romOptionsFrame = LabelFrame(parent, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1)
romOptionsFrame.columnconfigure(1, weight=1)
for i in range(5):
romOptionsFrame.rowconfigure(i, weight=1)
vars = Namespace()
vars.MusicVar = IntVar()
vars.MusicVar.set(adjuster_settings.music)
MusicCheckbutton = Checkbutton(romOptionsFrame, text="Music", variable=vars.MusicVar)
MusicCheckbutton.grid(row=0, column=0, sticky=E)
vars.disableFlashingVar = IntVar(value=adjuster_settings.reduceflashing)
disableFlashingCheckbutton = Checkbutton(romOptionsFrame, text="Disable flashing (anti-epilepsy)",
variable=vars.disableFlashingVar)
disableFlashingCheckbutton.grid(row=6, column=0, sticky=W)
vars.DeathLinkVar = IntVar(value=adjuster_settings.deathlink)
DeathLinkCheckbutton = Checkbutton(romOptionsFrame, text="DeathLink (Team Deaths)", variable=vars.DeathLinkVar)
DeathLinkCheckbutton.grid(row=7, column=0, sticky=W)
vars.AllowCollectVar = IntVar(value=adjuster_settings.allowcollect)
AllowCollectCheckbutton = Checkbutton(romOptionsFrame, text="Allow Collect", variable=vars.AllowCollectVar)
AllowCollectCheckbutton.grid(row=8, column=0, sticky=W)
spriteDialogFrame = Frame(romOptionsFrame)
spriteDialogFrame.grid(row=0, column=1)
baseSpriteLabel = Label(spriteDialogFrame, text='Sprite:')
vars.spriteNameVar = StringVar()
vars.sprite = adjuster_settings.sprite
def set_sprite(sprite_param):
nonlocal vars
if isinstance(sprite_param, str):
vars.sprite = sprite_param
vars.spriteNameVar.set(sprite_param)
elif sprite_param is None or not sprite_param.valid:
vars.sprite = None
vars.spriteNameVar.set('(unchanged)')
else:
vars.sprite = sprite_param
vars.spriteNameVar.set(vars.sprite.name)
set_sprite(adjuster_settings.sprite)
#vars.spriteNameVar.set(adjuster_settings.sprite)
spriteEntry = Label(spriteDialogFrame, textvariable=vars.spriteNameVar)
def SpriteSelect():
nonlocal vars
SpriteSelector(parent, set_sprite, spritePool=vars.sprite_pool)
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
baseSpriteLabel.pack(side=LEFT)
spriteEntry.pack(side=LEFT)
spriteSelectButton.pack(side=LEFT)
vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap)
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
menuspeedFrame = Frame(romOptionsFrame)
menuspeedFrame.grid(row=1, column=1, sticky=E)
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
menuspeedLabel.pack(side=LEFT)
vars.menuspeedVar = StringVar()
vars.menuspeedVar.set(adjuster_settings.menuspeed)
menuspeedOptionMenu = OptionMenu(menuspeedFrame, vars.menuspeedVar, 'normal', 'instant', 'double', 'triple',
'quadruple', 'half')
menuspeedOptionMenu.pack(side=LEFT)
heartcolorFrame = Frame(romOptionsFrame)
heartcolorFrame.grid(row=2, column=0, sticky=E)
heartcolorLabel = Label(heartcolorFrame, text='Heart color')
heartcolorLabel.pack(side=LEFT)
vars.heartcolorVar = StringVar()
vars.heartcolorVar.set(adjuster_settings.heartcolor)
heartcolorOptionMenu = OptionMenu(heartcolorFrame, vars.heartcolorVar, 'red', 'blue', 'green', 'yellow', 'random')
heartcolorOptionMenu.pack(side=LEFT)
heartbeepFrame = Frame(romOptionsFrame)
heartbeepFrame.grid(row=2, column=1, sticky=E)
heartbeepLabel = Label(heartbeepFrame, text='Heartbeep')
heartbeepLabel.pack(side=LEFT)
vars.heartbeepVar = StringVar()
vars.heartbeepVar.set(adjuster_settings.heartbeep)
heartbeepOptionMenu = OptionMenu(heartbeepFrame, vars.heartbeepVar, 'double', 'normal', 'half', 'quarter', 'off')
heartbeepOptionMenu.pack(side=LEFT)
owPalettesFrame = Frame(romOptionsFrame)
owPalettesFrame.grid(row=3, column=0, sticky=E)
owPalettesLabel = Label(owPalettesFrame, text='Overworld palettes')
owPalettesLabel.pack(side=LEFT)
vars.owPalettesVar = StringVar()
vars.owPalettesVar.set(adjuster_settings.ow_palettes)
owPalettesOptionMenu = OptionMenu(owPalettesFrame, vars.owPalettesVar, 'default', 'good', 'blackout', 'grayscale',
'negative', 'classic', 'dizzy', 'sick', 'puke')
owPalettesOptionMenu.pack(side=LEFT)
uwPalettesFrame = Frame(romOptionsFrame)
uwPalettesFrame.grid(row=3, column=1, sticky=E)
uwPalettesLabel = Label(uwPalettesFrame, text='Dungeon palettes')
uwPalettesLabel.pack(side=LEFT)
vars.uwPalettesVar = StringVar()
vars.uwPalettesVar.set(adjuster_settings.uw_palettes)
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, vars.uwPalettesVar, 'default', 'good', 'blackout', 'grayscale',
'negative', 'classic', 'dizzy', 'sick', 'puke')
uwPalettesOptionMenu.pack(side=LEFT)
hudPalettesFrame = Frame(romOptionsFrame)
hudPalettesFrame.grid(row=4, column=0, sticky=E)
hudPalettesLabel = Label(hudPalettesFrame, text='HUD palettes')
hudPalettesLabel.pack(side=LEFT)
vars.hudPalettesVar = StringVar()
vars.hudPalettesVar.set(adjuster_settings.hud_palettes)
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, vars.hudPalettesVar, 'default', 'good', 'blackout',
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
hudPalettesOptionMenu.pack(side=LEFT)
swordPalettesFrame = Frame(romOptionsFrame)
swordPalettesFrame.grid(row=4, column=1, sticky=E)
swordPalettesLabel = Label(swordPalettesFrame, text='Sword palettes')
swordPalettesLabel.pack(side=LEFT)
vars.swordPalettesVar = StringVar()
vars.swordPalettesVar.set(adjuster_settings.sword_palettes)
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, vars.swordPalettesVar, 'default', 'good', 'blackout',
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
swordPalettesOptionMenu.pack(side=LEFT)
shieldPalettesFrame = Frame(romOptionsFrame)
shieldPalettesFrame.grid(row=5, column=0, sticky=E)
shieldPalettesLabel = Label(shieldPalettesFrame, text='Shield palettes')
shieldPalettesLabel.pack(side=LEFT)
vars.shieldPalettesVar = StringVar()
vars.shieldPalettesVar.set(adjuster_settings.shield_palettes)
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, vars.shieldPalettesVar, 'default', 'good', 'blackout',
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
shieldPalettesOptionMenu.pack(side=LEFT)
spritePoolFrame = Frame(romOptionsFrame)
spritePoolFrame.grid(row=5, column=1)
baseSpritePoolLabel = Label(spritePoolFrame, text='Sprite Pool:')
vars.spritePoolCountVar = StringVar()
vars.sprite_pool = adjuster_settings.sprite_pool
def set_sprite_pool(sprite_param):
nonlocal vars
operation = "add"
if isinstance(sprite_param, tuple):
operation, sprite_param = sprite_param
if isinstance(sprite_param, Sprite) and sprite_param.valid:
sprite_param = sprite_param.name
if isinstance(sprite_param, str):
if operation == "add":
vars.sprite_pool.append(sprite_param)
elif operation == "remove":
vars.sprite_pool.remove(sprite_param)
elif operation == "clear":
vars.sprite_pool.clear()
vars.spritePoolCountVar.set(str(len(vars.sprite_pool)))
set_sprite_pool(None)
vars.spritePoolCountVar.set(len(adjuster_settings.sprite_pool))
spritePoolEntry = Label(spritePoolFrame, textvariable=vars.spritePoolCountVar)
def SpritePoolSelect():
nonlocal vars
SpriteSelector(parent, set_sprite_pool, randomOnEvent=False, spritePool=vars.sprite_pool)
def SpritePoolClear():
nonlocal vars
vars.sprite_pool.clear()
vars.spritePoolCountVar.set('0')
spritePoolSelectButton = Button(spritePoolFrame, text='...', command=SpritePoolSelect)
spritePoolClearButton = Button(spritePoolFrame, text='Clear', command=SpritePoolClear)
baseSpritePoolLabel.pack(side=LEFT)
spritePoolEntry.pack(side=LEFT)
spritePoolSelectButton.pack(side=LEFT)
spritePoolClearButton.pack(side=LEFT)
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
autoApplyFrame = Frame(romOptionsFrame)
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
filler.pack(side=TOP, expand=True, fill=X)
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
askRadio.pack(side=LEFT, padx=5, pady=5)
alwaysRadio = Radiobutton(autoApplyFrame, text='Always', variable=vars.auto_apply, value='always')
alwaysRadio.pack(side=LEFT, padx=5, pady=5)
neverRadio = Radiobutton(autoApplyFrame, text='Never', variable=vars.auto_apply, value='never')
neverRadio.pack(side=LEFT, padx=5, pady=5)
return romOptionsFrame, vars, set_sprite
class SpriteSelector():
def __init__(self, parent, callback, adjuster=False, randomOnEvent=True, spritePool=None):
self.deploy_icons()
self.parent = parent
self.window = Toplevel(parent)
self.callback = callback
self.adjuster = adjuster
self.randomOnEvent = randomOnEvent
self.spritePoolButtons = None
self.window.wm_title("TAKE ANY ONE YOU WANT")
self.window['padx'] = 5
self.window['pady'] = 5
self.spritesPerRow = 32
self.all_sprites = []
self.sprite_pool = spritePool
def open_custom_sprite_dir(_evt):
open_file(self.custom_sprite_dir)
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
custom_frametitle = Frame(self.window)
title_text = Label(custom_frametitle, text="Custom Sprites")
title_link = Label(custom_frametitle, text="(open)", fg="blue", cursor="hand2")
title_text.pack(side=LEFT)
title_link.pack(side=LEFT)
title_link.bind("<Button-1>", open_custom_sprite_dir)
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir,
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
if not randomOnEvent:
self.sprite_pool_section(spritePool)
frame = Frame(self.window)
frame.pack(side=BOTTOM, fill=X, pady=5)
if self.randomOnEvent:
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
button.pack(side=RIGHT, padx=(5, 0))
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
button.pack(side=RIGHT, padx=(5, 0))
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
button.pack(side=LEFT,padx=(0,5))
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
button.pack(side=LEFT, padx=(0, 5))
self.randomButtonText = StringVar()
button = Button(frame, textvariable=self.randomButtonText, command=self.use_random_sprite)
button.pack(side=LEFT, padx=(0, 5))
self.randomButtonText.set("Random")
self.randomOnEventText = StringVar()
self.randomOnHitVar = IntVar()
self.randomOnEnterVar = IntVar()
self.randomOnExitVar = IntVar()
self.randomOnSlashVar = IntVar()
self.randomOnItemVar = IntVar()
self.randomOnBonkVar = IntVar()
self.randomOnRandomVar = IntVar()
self.randomOnAllVar = IntVar()
if self.randomOnEvent:
self.buttonHit = Checkbutton(frame, text="Hit", command=self.update_random_button, variable=self.randomOnHitVar)
self.buttonHit.pack(side=LEFT, padx=(0, 5))
self.buttonEnter = Checkbutton(frame, text="Enter", command=self.update_random_button, variable=self.randomOnEnterVar)
self.buttonEnter.pack(side=LEFT, padx=(0, 5))
self.buttonExit = Checkbutton(frame, text="Exit", command=self.update_random_button, variable=self.randomOnExitVar)
self.buttonExit.pack(side=LEFT, padx=(0, 5))
self.buttonSlash = Checkbutton(frame, text="Slash", command=self.update_random_button, variable=self.randomOnSlashVar)
self.buttonSlash.pack(side=LEFT, padx=(0, 5))
self.buttonItem = Checkbutton(frame, text="Item", command=self.update_random_button, variable=self.randomOnItemVar)
self.buttonItem.pack(side=LEFT, padx=(0, 5))
self.buttonBonk = Checkbutton(frame, text="Bonk", command=self.update_random_button, variable=self.randomOnBonkVar)
self.buttonBonk.pack(side=LEFT, padx=(0, 5))
self.buttonRandom = Checkbutton(frame, text="Random", command=self.update_random_button, variable=self.randomOnRandomVar)
self.buttonRandom.pack(side=LEFT, padx=(0, 5))
self.buttonAll = Checkbutton(frame, text="All", command=self.update_random_button, variable=self.randomOnAllVar)
self.buttonAll.pack(side=LEFT, padx=(0, 5))
set_icon(self.window)
self.window.focus()
tkinter_center_window(self.window)
def remove_from_sprite_pool(self, button, spritename):
self.callback(("remove", spritename))
self.spritePoolButtons.buttons.remove(button)
button.destroy()
def add_to_sprite_pool(self, spritename):
if isinstance(spritename, str):
if spritename == "random":
button = Button(self.spritePoolButtons, text="?")
button['font'] = font.Font(size=19)
button.configure(command=lambda spr="random": self.remove_from_sprite_pool(button, spr))
ToolTips.register(button, "Random")
self.spritePoolButtons.buttons.append(button)
else:
spritename = Sprite.get_sprite_from_name(spritename)
if isinstance(spritename, Sprite) and spritename.valid:
image = get_image_for_sprite(spritename)
if image is None:
return
button = Button(self.spritePoolButtons, image=image)
button.configure(command=lambda spr=spritename: self.remove_from_sprite_pool(button, spr.name))
ToolTips.register(button, spritename.name +
f"\nBy: {spritename.author_name if spritename.author_name else ''}")
button.image = image
self.spritePoolButtons.buttons.append(button)
self.grid_fill_sprites(self.spritePoolButtons)
def sprite_pool_section(self, spritePool):
def clear_sprite_pool(_evt):
self.callback(("clear", "Clear"))
for button in self.spritePoolButtons.buttons:
button.destroy()
self.spritePoolButtons.buttons.clear()
frametitle = Frame(self.window)
title_text = Label(frametitle, text="Sprite Pool")
title_link = Label(frametitle, text="(clear)", fg="blue", cursor="hand2")
title_text.pack(side=LEFT)
title_link.pack(side=LEFT)
title_link.bind("<Button-1>", clear_sprite_pool)
self.spritePoolButtons = LabelFrame(self.window, labelwidget=frametitle, padx=5, pady=5)
self.spritePoolButtons.pack(side=TOP, fill=X)
self.spritePoolButtons.buttons = []
def update_sprites(event):
self.spritesPerRow = (event.width - 10) // 38
self.grid_fill_sprites(self.spritePoolButtons)
self.grid_fill_sprites(self.spritePoolButtons)
self.spritePoolButtons.bind("<Configure>", update_sprites)
if spritePool:
for sprite in spritePool:
self.add_to_sprite_pool(sprite)
def icon_section(self, frame_label, path, no_results_label):
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
frame.pack(side=TOP, fill=X)
sprites = []
for file in os.listdir(path):
sprites.append((file, Sprite(os.path.join(path, file))))
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
frame.buttons = []
for file, sprite in sprites:
image = get_image_for_sprite(sprite)
if image is None:
continue
self.all_sprites.append(sprite)
button = Button(frame, image=image, command=lambda spr=sprite: self.select_sprite(spr))
ToolTips.register(button, sprite.name +
("\nBy: %s" % sprite.author_name if sprite.author_name else "") +
f"\nFrom: {file}")
button.image = image
frame.buttons.append(button)
if not frame.buttons:
label = Label(frame, text=no_results_label)
label.pack()
def update_sprites(event):
self.spritesPerRow = (event.width - 10) // 38
self.grid_fill_sprites(frame)
self.grid_fill_sprites(frame)
frame.bind("<Configure>", update_sprites)
def grid_fill_sprites(self, frame):
for i, button in enumerate(frame.buttons):
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
def update_alttpr_sprites(self):
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
self.window.destroy()
self.parent.update()
def on_finish(successful, resultmessage):
if successful:
messagebox.showinfo("Sprite Updater", resultmessage)
else:
logging.error(resultmessage)
messagebox.showerror("Sprite Updater", resultmessage)
SpriteSelector(self.parent, self.callback, self.adjuster)
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
def browse_for_sprite(self):
sprite = filedialog.askopenfilename(
filetypes=[("All Sprite Sources", (".zspr", ".spr", ".sfc", ".smc")),
("ZSprite files", ".zspr"),
("Sprite files", ".spr"),
("Rom Files", (".sfc", ".smc")),
("All Files", "*")])
try:
self.callback(Sprite(sprite))
except Exception:
self.callback(None)
self.window.destroy()
def use_default_sprite(self):
self.callback(None)
self.window.destroy()
def use_default_link_sprite(self):
if self.randomOnEvent:
self.callback(Sprite.default_link_sprite())
self.window.destroy()
else:
self.callback("link")
self.add_to_sprite_pool("link")
def update_random_button(self):
if self.randomOnAllVar.get():
randomon = "all"
self.buttonHit.config(state=DISABLED)
self.buttonEnter.config(state=DISABLED)
self.buttonExit.config(state=DISABLED)
self.buttonSlash.config(state=DISABLED)
self.buttonItem.config(state=DISABLED)
self.buttonBonk.config(state=DISABLED)
self.buttonRandom.config(state=DISABLED)
elif self.randomOnRandomVar.get():
randomon = "random"
self.buttonHit.config(state=DISABLED)
self.buttonEnter.config(state=DISABLED)
self.buttonExit.config(state=DISABLED)
self.buttonSlash.config(state=DISABLED)
self.buttonItem.config(state=DISABLED)
self.buttonBonk.config(state=DISABLED)
else:
self.buttonHit.config(state=NORMAL)
self.buttonEnter.config(state=NORMAL)
self.buttonExit.config(state=NORMAL)
self.buttonSlash.config(state=NORMAL)
self.buttonItem.config(state=NORMAL)
self.buttonBonk.config(state=NORMAL)
self.buttonRandom.config(state=NORMAL)
randomon = "-hit" if self.randomOnHitVar.get() else ""
randomon += "-enter" if self.randomOnEnterVar.get() else ""
randomon += "-exit" if self.randomOnExitVar.get() else ""
randomon += "-slash" if self.randomOnSlashVar.get() else ""
randomon += "-item" if self.randomOnItemVar.get() else ""
randomon += "-bonk" if self.randomOnBonkVar.get() else ""
self.randomOnEventText.set(f"randomon{randomon}" if randomon else None)
self.randomButtonText.set("Random On Event" if randomon else "Random")
def use_random_sprite(self):
if not self.randomOnEvent:
self.callback("random")
self.add_to_sprite_pool("random")
return
elif self.randomOnEventText.get():
self.callback(self.randomOnEventText.get())
elif self.sprite_pool:
self.callback(random.choice(self.sprite_pool))
elif self.all_sprites:
self.callback(random.choice(self.all_sprites))
else:
self.callback(None)
self.window.destroy()
def select_sprite(self, spritename):
self.callback(spritename)
if self.randomOnEvent:
self.window.destroy()
else:
self.add_to_sprite_pool(spritename)
def deploy_icons(self):
if not os.path.exists(self.custom_sprite_dir):
os.makedirs(self.custom_sprite_dir)
@property
def alttpr_sprite_dir(self):
return user_path("data", "sprites", "alttpr")
@property
def custom_sprite_dir(self):
return user_path("data", "sprites", "custom")
def get_image_for_sprite(sprite, gif_only: bool = False):
if not sprite.valid:
return None
height = 24
width = 16
def draw_sprite_into_gif(add_palette_color, set_pixel_color_index):
def drawsprite(spr, pal_as_colors, offset):
for y, row in enumerate(spr):
for x, pal_index in enumerate(row):
if pal_index:
color = pal_as_colors[pal_index - 1]
set_pixel_color_index(x + offset[0], y + offset[1], color)
add_palette_color(16, (40, 40, 40))
shadow = [
[0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0],
]
drawsprite(shadow, [16], (2, 17))
palettes = sprite.decode_palette()
for i in range(15):
add_palette_color(i + 1, palettes[0][i])
body = sprite.decode16(0x4C0)
drawsprite(body, list(range(1, 16)), (0, 8))
head = sprite.decode16(0x40)
drawsprite(head, list(range(1, 16)), (0, 0))
def make_gif(callback):
gif_header = b'GIF89a'
gif_lsd = bytearray(7)
gif_lsd[0] = width
gif_lsd[2] = height
gif_lsd[
4] = 0xF4 # 32 color palette follows. transparant + 15 for sprite + 1 for shadow=17 which rounds up to 32 as nearest power of 2
gif_lsd[5] = 0 # background color is zero
gif_lsd[6] = 0 # aspect raio not specified
gif_gct = bytearray(3 * 32)
gif_gce = bytearray(8)
gif_gce[0] = 0x21 # start of extention blocked
gif_gce[1] = 0xF9 # identifies this as the Graphics Control extension
gif_gce[2] = 4 # we are suppling only the 4 four bytes
gif_gce[3] = 0x01 # this gif includes transparency
gif_gce[4] = gif_gce[5] = 0 # animation frrame delay (unused)
gif_gce[6] = 0 # transparent color is index 0
gif_gce[7] = 0 # end of gif_gce
gif_id = bytearray(10)
gif_id[0] = 0x2c
# byte 1,2 are image left. 3,4 are image top both are left as zerosuitsamus
gif_id[5] = width
gif_id[7] = height
gif_id[9] = 0 # no local color table
gif_img_minimum_code_size = bytes(
[7]) # we choose 7 bits, so that each pixel is represented by a byte, for conviennce.
clear = 0x80
stop = 0x81
unchunked_image_data = bytearray(height * (width + 1) + 1)
# we technically need a Clear code once every 125 bytes, but we do it at the start of every row for simplicity
for row in range(height):
unchunked_image_data[row * (width + 1)] = clear
unchunked_image_data[-1] = stop
def add_palette_color(index, color):
gif_gct[3 * index] = color[0]
gif_gct[3 * index + 1] = color[1]
gif_gct[3 * index + 2] = color[2]
def set_pixel_color_index(x, y, color):
unchunked_image_data[y * (width + 1) + x + 1] = color
callback(add_palette_color, set_pixel_color_index)
def chunk_image(img):
for i in range(0, len(img), 255):
chunk = img[i:i + 255]
yield bytes([len(chunk)])
yield chunk
gif_img = b''.join([gif_img_minimum_code_size] + list(chunk_image(unchunked_image_data)) + [b'\x00'])
gif = b''.join([gif_header, gif_lsd, gif_gct, gif_gce, gif_id, gif_img, b'\x3b'])
return gif
gif_data = make_gif(draw_sprite_into_gif)
if gif_only:
return gif_data
image = PhotoImage(data=gif_data)
return image.zoom(2)
class ToolTips(object):
# This class derived from wckToolTips which is available under the following license:
# Copyright (c) 1998-2007 by Secret Labs AB
# Copyright (c) 1998-2007 by Fredrik Lundh
#
# By obtaining, using, and/or copying this software and/or its
# associated documentation, you agree that you have read, understood,
# and will comply with the following terms and conditions:
#
# Permission to use, copy, modify, and distribute this software and its
# associated documentation for any purpose and without fee is hereby
# granted, provided that the above copyright notice appears in all
# copies, and that both that copyright notice and this permission notice
# appear in supporting documentation, and that the name of Secret Labs
# AB or the author not be used in advertising or publicity pertaining to
# distribution of the software without specific, written prior
# permission.
#
# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO
# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
# FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
label = None
window = None
active = 0
tag = None
after_id = None
@classmethod
def getcontroller(cls, widget):
if cls.tag is None:
cls.tag = "ui_tooltip_%d" % id(cls)
widget.bind_class(cls.tag, "<Enter>", cls.enter)
widget.bind_class(cls.tag, "<Leave>", cls.leave)
widget.bind_class(cls.tag, "<Motion>", cls.motion)
widget.bind_class(cls.tag, "<Destroy>", cls.leave)
# pick suitable colors for tooltips
try:
cls.bg = "systeminfobackground"
cls.fg = "systeminfotext"
widget.winfo_rgb(cls.fg) # make sure system colors exist
widget.winfo_rgb(cls.bg)
except Exception:
cls.bg = "#ffffe0"
cls.fg = "black"
return cls.tag
@classmethod
def register(cls, widget, text):
widget.ui_tooltip_text = text
tags = list(widget.bindtags())
tags.append(cls.getcontroller(widget))
widget.bindtags(tuple(tags))
@classmethod
def unregister(cls, widget):
tags = list(widget.bindtags())
tags.remove(cls.getcontroller(widget))
widget.bindtags(tuple(tags))
# event handlers
@classmethod
def enter(cls, event):
widget = event.widget
if not cls.label:
# create and hide balloon help window
cls.popup = tk.Toplevel(bg=cls.fg, bd=1)
cls.popup.overrideredirect(1)
cls.popup.withdraw()
cls.label = tk.Label(
cls.popup, fg=cls.fg, bg=cls.bg, bd=0, padx=2, justify=tk.LEFT
)
cls.label.pack()
cls.active = 0
cls.xy = event.x_root + 16, event.y_root + 10
cls.event_xy = event.x, event.y
cls.after_id = widget.after(200, cls.display, widget)
@classmethod
def motion(cls, event):
cls.xy = event.x_root + 16, event.y_root + 10
cls.event_xy = event.x, event.y
@classmethod
def display(cls, widget):
if not cls.active:
# display balloon help window
text = widget.ui_tooltip_text
if callable(text):
text = text(widget, cls.event_xy)
cls.label.config(text=text)
cls.popup.deiconify()
cls.popup.lift()
cls.popup.geometry("+%d+%d" % cls.xy)
cls.active = 1
cls.after_id = None
@classmethod
def leave(cls, event):
widget = event.widget
if cls.active:
cls.popup.withdraw()
cls.active = 0
if cls.after_id:
widget.after_cancel(cls.after_id)
cls.after_id = None
if __name__ == '__main__':
main()