#!/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 GAME_ALTTP = "A Link to the Past" class AdjusterWorld(object): def __init__(self, sprite_pool): import random self.sprite_pool = {1: sprite_pool} self.per_slot_randoms = {1: random} class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): def _get_help_string(self, action): return textwrap.dedent(action.help) def get_argparser() -> argparse.ArgumentParser: 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('--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('--oof', help='''\ Path to a sound effect to replace Link's "oof" sound. Needs to be in a .brr format and have a length of no more than 2673 bytes, created from a 16-bit signed PCM .wav at 12khz. https://github.com/boldowa/snesbrr ''') parser.add_argument('--names', default='', type=str) parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.') return parser def main(): parser = get_argparser() 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) if args.oof is not None and not os.path.isfile(args.oof): input('Could not find oof sound effect at given location. \nPress Enter to exit.') sys.exit(1) if args.oof is not None and os.path.getsize(args.oof) > 2673: input('"oof" sound effect cannot exceed 2673 bytes. \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() == '.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, args.oof, 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 Utils 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", ".aplttp")), ("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) guiargs.oof = rom_vars.oof 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 guiargs.oof = rom_vars.oof 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.is_set(): 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() class AttachTooltip(object): def __init__(self, parent, text): self._parent = parent self._text = text self._window = None parent.bind('', lambda event : self.show()) parent.bind('', lambda event : self.hide()) def show(self): if self._window or not self._text: return self._window = Toplevel(self._parent) #remove window bar controls self._window.wm_overrideredirect(1) #adjust positioning x, y, *_ = self._parent.bbox("insert") x = x + self._parent.winfo_rootx() + 20 y = y + self._parent.winfo_rooty() + 20 self._window.wm_geometry("+{0}+{1}".format(x,y)) #show text label = Label(self._window, text=self._text, justify=LEFT) label.pack(ipadx=1) def hide(self): if self._window: self._window.destroy() self._window = None 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, "oof": 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) oofDialogFrame = Frame(romOptionsFrame) oofDialogFrame.grid(row=1, column=1) baseOofLabel = Label(oofDialogFrame, text='"OOF" Sound:') vars.oofNameVar = StringVar() vars.oof = adjuster_settings.oof def set_oof(oof_param): nonlocal vars if isinstance(oof_param, str) and os.path.isfile(oof_param) and os.path.getsize(oof_param) <= 2673: vars.oof = oof_param vars.oofNameVar.set(oof_param.rsplit('/',1)[-1]) else: vars.oof = None vars.oofNameVar.set('(unchanged)') set_oof(adjuster_settings.oof) oofEntry = Label(oofDialogFrame, textvariable=vars.oofNameVar) def OofSelect(): nonlocal vars oof_file = filedialog.askopenfilename( filetypes=[("BRR files", ".brr"), ("All Files", "*")]) try: set_oof(oof_file) except Exception: set_oof(None) oofSelectButton = Button(oofDialogFrame, text='...', command=OofSelect) AttachTooltip(oofSelectButton, text="Select a .brr file no more than 2673 bytes.\n" + \ "This can be created from a <=0.394s 16-bit signed PCM .wav file at 12khz using snesbrr.") baseOofLabel.pack(side=LEFT) oofEntry.pack(side=LEFT) oofSelectButton.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=6, 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 .aplttp 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.invalid_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("", 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) if self.invalid_sprites: invalid = sorted(self.invalid_sprites) logging.warning(f"The following sprites are invalid: {', '.join(invalid)}") msg = f"{invalid[0]} " msg += f"and {len(invalid)-1} more are invalid" if len(invalid) > 1 else "is invalid" messagebox.showerror("Invalid sprites detected", msg, parent=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("", 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("", 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): if file == '.gitignore': continue sprite = Sprite(os.path.join(path, file)) if sprite.valid: sprites.append((file, sprite)) else: self.invalid_sprites.append(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("", 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, "", cls.enter) widget.bind_class(cls.tag, "", cls.leave) widget.bind_class(cls.tag, "", cls.motion) widget.bind_class(cls.tag, "", 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()