diff options
-rw-r--r-- | srb.png | bin | 0 -> 583 bytes | |||
-rwxr-xr-x | srb_tk.py | 534 | ||||
-rwxr-xr-x | stackrpms_tk_lib.py | 442 |
3 files changed, 976 insertions, 0 deletions
Binary files differ diff --git a/srb_tk.py b/srb_tk.py new file mode 100755 index 0000000..75d4f60 --- /dev/null +++ b/srb_tk.py @@ -0,0 +1,534 @@ +#!/usr/bin/env python3 +# File: srb_tk.py +# Location: https://bgstack15.ddns.net/cgit/srb_lib +# Author: bgstack15 +# SPDX-License-Identifier: GPL-3.0-only +# Startdate: 2024-03-28-5 13:43 +# Title: Graphical frontend for savegame editor for Snoopy vs. the Red Baron +# Project: srb_lib +# Purpose: Savegame editor graphical frontend for +# History: +# Usage: +# ./srb_tk.py +# References: +# logout-manager-tcl, lmlib.py +# https://likegeeks.com/python-gui-examples-tkinter-tutorial/#Add_a_Menu_bar +# icon from https://www.iconarchive.com/show/peanuts-icons-by-ed-mundy/Snoopy-icon.html +# https://www.tcl.tk/man/tcl8.6/TkCmd/bind.htm from https://www.tcl.tk/man/tcl8.6/TkCmd/contents.htm +# https://www.reddit.com/r/learnpython/comments/mavinn/tkinter_remove_optionmenu_icon/ +# https://stackoverflow.com/questions/69806038/navigate-to-tkinter-optionmenu-with-tab-key/69806039#69806039 +# icon courtesy of https://icons8.com/icon/9336/aircraft +# possible way to have a widget store its variable: https://stackoverflow.com/questions/63871376/tkinter-widget-cgetvariable +# Improve: +# Add levelset, level info. +# Enable the checkbox for profile-in-use +# Dependencies: +# dep-devuan: python3-tkinter, python3-pil.imagetk +# rec-devuan: python3-cairosvg +import tkinter as tk +from pathlib import Path +import tkinter.filedialog, srb_lib, os, time +import stackrpms_tk_lib as stk + +ABOUT_TEXT = """ +srb_tk \"Savegame Editor for Snoopy vs. the Red Baron\" +(C) 2024 bgstack15 +SPDX-License-Identifier: GPL-3.0-only +Icon courtesy of https://icons8.com/icon/9336/aircraft +""" + +class App(tk.Frame): + def __init__(self, master): + super().__init__(master) + self.master.title("Savegame editor for SRB") + self.bdata = None + menu = tk.Menu(self.master) + self.master.config(menu=menu) + menu_file = tk.Menu(menu,tearoff=0) + menu_file.add_command(label="Open...", command=self.func_open, underline=0) + menu_file.add_command(label="Reload current file", command=self.func_reload, underline=0) + menu_file.add_command(label="Save", command=self.func_save, underline=0) + menu_file.add_command(label="Save as...", command=self.func_save_as, underline=5) + menu_file.add_separator() + menu_file.add_command(label="Exit", command=self.func_exit, underline=1) + menu.add_cascade(label="File",menu=menu_file, underline=0) + menu_help = tk.Menu(menu,tearoff=0) + menu_help.add_command(label="About", command=self.func_about, underline=0) + menu.add_cascade(label="Help",menu=menu_help,underline=0) + self.grid() # use this geometry manager instead of pack() + # app icon + #imgicon = stk.get_scaled_icon("hexedit",24,"Numix-Circle", "","apps") + #imgicon = stk.get_scaled_icon(os.path.join(os.getenv("HOME"),"Downloads/snoopy.png"),24,"Numix-Circle", "","apps") + imgicon = stk.get_scaled_icon("./srb.png",24,"default", "","apps") + self.master.tk.call("wm","iconphoto",self.master._w,imgicon) + + # app variables + self.silent = tk.BooleanVar() + self.silent.set(True) + + # data variables + self.current_file = tk.StringVar(value = os.path.join(os.getenv("HOME"),"Documents","Snoopy vs. the Red Baron","Profile 1","Profile 1.sav")) + #print(f"beginning: {self.current_file.get()}") + self.checksum = tk.IntVar() + self.selected_profile = tk.IntVar(value=1) + self.prof1_used = tk.BooleanVar() + # Do not use traces for most things though, because it will catch itself in a loop. Instead, use the bind <Key-Enter> on the form fields. + self.prof1name = tk.StringVar() + self.prof1tut = tk.BooleanVar() + self.prof2_used = tk.BooleanVar() + self.prof2name = tk.StringVar() + self.prof2tut = tk.BooleanVar() + self.prof3_used = tk.BooleanVar() + self.prof3name = tk.StringVar() + self.prof3tut = tk.BooleanVar() + # current-profile form: + self.money = tk.IntVar() + self.equipped_weapon = tk.StringVar() + self.health = tk.IntVar() + self.stunt = tk.IntVar() + self.gun = tk.IntVar() + self.pur_weap_potato = tk.BooleanVar() + self.pur_weap_stinger = tk.BooleanVar() + self.pur_weap_watball = tk.BooleanVar() + self.pur_weap_snow = tk.BooleanVar() + self.pur_weap_fire = tk.BooleanVar() + self.pur_weap_lightn = tk.BooleanVar() + self.pur_weap_romanc = tk.BooleanVar() + self.pur_weap_pumpkin = tk.BooleanVar() + # these strings will hold the uppercase-when-collected letters, and be used by the checkboxes for the purchasable planes. + self.pur_plane_str_marcie = tk.StringVar(value="marcie") + self.pur_plane_bool_marcie = tk.BooleanVar() + self.pur_plane_str_sally = tk.StringVar(value="sally") + self.pur_plane_bool_sally = tk.BooleanVar() + self.pur_plane_str_rerun = tk.StringVar(value="rerun") + self.pur_plane_bool_rerun = tk.BooleanVar() + self.pur_plane_str_pigpen = tk.StringVar(value="pigpen") + self.pur_plane_bool_pigpen = tk.BooleanVar() + self.pur_plane_str_woodstock = tk.StringVar(value="woodstock") + self.pur_plane_bool_woodstock = tk.BooleanVar() + self.pur_plane_str_baron = tk.StringVar(value="baron") + self.pur_plane_bool_baron = tk.BooleanVar() + # statusbar + self.statustext = tk.StringVar() + + # app label, for vanity + # WORKHERE, vanity tag does not actually fill two columns. + self.lbl_app = tk.Label(self.master,text="Savegame editor for Snoopy vs. the Red Baron") + self.lbl_app.grid(row=0,column=0,columnspan=2) + stk.Tooltip(self.lbl_app,text="Also checkout the cli with ./srb.py") + self.lbl_checksum = tk.Label(self.master,text="Checksum",underline=0) + self.lbl_checksum.grid(row=1,column=0) + self.ent_checksum = tk.Entry(state="readonly",textvariable=self.checksum) + #self.ent_checksum.bind("<Key-Return>",self.load_form_into_data) + self.ent_checksum.grid(row=2,column=0) + self.chk_silent = tk.Checkbutton(self.master,text="silent",variable=self.silent) + self.chk_silent.grid(row=2,column=1) + self.frm_profiles = tk.Frame(self.master) #,borderwidth=1,background="green") + self.frm_profiles.grid(row=3,column=0,columnspan=2) + # investigate radio button key-bind, up-down, to increment/decrement choice? + self.rad_prof1 = stk.Radiobutton(self.frm_profiles,value=1,text="Profile 1",variable=self.selected_profile,underline=8) + self.rad_prof1.grid(row=0,column=0) + self.chk_prof1_used = tk.Checkbutton(self.frm_profiles,text="used",state="disabled",variable=self.prof1_used) + self.chk_prof1_used.grid(row=0,column=2) + self.chk_prof1_tut = stk.Checkbutton(self.frm_profiles,text="tutorial",variable=self.prof1tut,func=self.load_form_into_data) + #self.hook_checkbox_enter_keys(self.chk_prof1_tut,self.load_form_into_data) + self.chk_prof1_tut.grid(row=0,column=3) + self.ent_prof1name = stk.Entry(self.frm_profiles,textvariable=self.prof1name,func=self.load_form_into_data) + self.ent_prof1name.bind("<Key-Return>",self.load_form_into_data) + self.ent_prof1name.bind("<Key-KP_Enter>",self.load_form_into_data) + self.ent_prof1name.grid(row=0,column=4) + self.rad_prof2 = stk.Radiobutton(self.frm_profiles,value=2,text="Profile 2",variable=self.selected_profile,underline=8) + self.rad_prof2.grid(row=1,column=0) + self.prof2_used.set(False) + self.chk_prof2_used = tk.Checkbutton(self.frm_profiles,text="used",state="disabled",variable=self.prof2_used) + self.chk_prof2_used.grid(row=1,column=2) + self.chk_prof2_tut = stk.Checkbutton(self.frm_profiles,text="tutorial",variable=self.prof2tut,func=self.load_form_into_data) + #self.hook_checkbox_enter_keys(self.chk_prof2_tut,self.load_form_into_data) + self.chk_prof2_tut.grid(row=1,column=3) + self.ent_prof2name = stk.Entry(self.frm_profiles,textvariable=self.prof2name,func=self.load_form_into_data) + self.ent_prof2name.bind("<Key-Return>",self.load_form_into_data) + self.ent_prof2name.grid(row=1,column=4) + self.rad_prof3 = stk.Radiobutton(self.frm_profiles,value=3,text="Profile 3",variable=self.selected_profile,underline=8) + self.rad_prof3.grid(row=2,column=0) + self.prof3_used.set(False) + self.chk_prof3_used = tk.Checkbutton(self.frm_profiles,text="used",state="disabled",variable=self.prof3_used) + self.chk_prof3_used.grid(row=2,column=2) + self.chk_prof3_tut = stk.Checkbutton(self.frm_profiles,text="tutorial",variable=self.prof3tut,func=self.load_form_into_data) + #self.hook_checkbox_enter_keys(self.chk_prof3_tut,self.load_form_into_data) + self.chk_prof3_tut.grid(row=2,column=3) + self.ent_prof3name = stk.Entry(self.frm_profiles,textvariable=self.prof3name,func=self.load_form_into_data) + self.ent_prof3name.bind("<Key-Return>",self.load_form_into_data) + self.ent_prof3name.grid(row=2,column=4) + #self.selected_profile.set(1) # purposefully do not set this value. + + # one profile, so we will have to use the value of self.selected_profile + self.frm_curprof = tk.Frame(self.master) + self.frm_curprof.grid(row=4,column=0,rowspan=5) + # most form fields are inside the current profile + self.lbl_money = tk.Label(self.frm_curprof,text="Money") + self.lbl_money.grid(row=0,column=0) + self.ent_money = stk.Entry(self.frm_curprof,textvariable=self.money,func=self.load_form_into_data) + #self.ent_money.bind("<Key-Return>",self.load_form_into_data) + self.ent_money.grid(row=0,column=1) + self.lbl_equ_weapon = tk.Label(self.frm_curprof,text="Equipped weapon") + self.lbl_equ_weapon.grid(row=1,column=0) + # list of names + equippable_weapons = [i["name"] for i in srb_lib.WEAPONS if i["e"]] + self.opt_equ_weapon = tk.OptionMenu(self.frm_curprof,self.equipped_weapon,*equippable_weapons) + # the indicatoron,padx,pady and grid sticky are incredibly useful and hard to find in the docs + self.opt_equ_weapon.config(indicatoron=False,padx=1,pady=1,takefocus=1) + self.opt_equ_weapon.grid(row=1,column=1,sticky="ew") + self.lbl_health = tk.Label(self.frm_curprof,text="Health") + self.lbl_health.grid(row=2,column=0) + self.spn_health = stk.Spinbox(self.frm_curprof,from_=1,to=4,textvariable=self.health) + self.spn_health.grid(row=2,column=1) + self.lbl_stunt = tk.Label(self.frm_curprof,text="Stunt") + self.lbl_stunt.grid(row=3,column=0) + self.spn_stunt = stk.Spinbox(self.frm_curprof,from_=1,to=4,textvariable=self.stunt) + self.spn_stunt.grid(row=3,column=1) + self.lbl_gun = tk.Label(self.frm_curprof,text="Gun") + self.lbl_gun.grid(row=4,column=0) + self.spn_gun = stk.Spinbox(self.frm_curprof,from_=1,to=5,textvariable=self.gun) + self.spn_gun.grid(row=4,column=1) + self.lbl_pur = tk.Label(self.frm_curprof,text="Purchased: Weapons") + self.lbl_pur.grid(row=5,column=0) + #self.lbl_pur_weap = tk.Label(self.frm_curprof,text="Weapons") + #self.lbl_pur_weap.grid(row=5,column=0) + self.lbl_pur_weap = tk.Label(self.frm_curprof,text="Planes") + self.lbl_pur_weap.grid(row=5,column=1) + self.chk_pur_weap_potato = stk.Checkbutton(self.frm_curprof,text="Potato Gun",variable=self.pur_weap_potato,func=self.load_form_into_data) + self.chk_pur_weap_potato.grid(row=6,column=0,sticky="W") + self.chk_pur_weap_stinger = stk.Checkbutton(self.frm_curprof,text="Stinger",variable=self.pur_weap_stinger,func=self.load_form_into_data) + self.chk_pur_weap_stinger.grid(row=7,column=0,sticky="W") + self.chk_pur_weap_watball = stk.Checkbutton(self.frm_curprof,text="Water Balloon Cannon",variable=self.pur_weap_watball,func=self.load_form_into_data) + self.chk_pur_weap_watball.grid(row=8,column=0,sticky="W") + self.chk_pur_weap_snow = stk.Checkbutton(self.frm_curprof,text="Snow Blower",variable=self.pur_weap_snow,func=self.load_form_into_data) + self.chk_pur_weap_snow.grid(row=9,column=0,sticky="W") + self.chk_pur_weap_fire = stk.Checkbutton(self.frm_curprof,text="Fire Boomerang",variable=self.pur_weap_fire,func=self.load_form_into_data) + self.chk_pur_weap_fire.grid(row=10,column=0,sticky="W") + self.chk_pur_weap_lightn = stk.Checkbutton(self.frm_curprof,text="Lightning Rod",variable=self.pur_weap_lightn,func=self.load_form_into_data) + self.chk_pur_weap_lightn.grid(row=11,column=0,sticky="W") + self.chk_pur_weap_romanc = stk.Checkbutton(self.frm_curprof,text="Roman Candles",variable=self.pur_weap_romanc,func=self.load_form_into_data) + self.chk_pur_weap_romanc.grid(row=12,column=0,sticky="W") + self.chk_pur_weap_pumpkin = stk.Checkbutton(self.frm_curprof,text="10 Gauge Pumpkin",variable=self.pur_weap_pumpkin,func=self.load_form_into_data) + self.chk_pur_weap_pumpkin.grid(row=13,column=0,sticky="W") + self.chk_pur_plane_marcie = stk.Checkbutton(self.frm_curprof,textvariable=self.pur_plane_str_marcie,variable=self.pur_plane_bool_marcie,func=self.load_form_into_data) + self.chk_pur_plane_marcie.grid(row=6,column=1,sticky="W") + self.chk_pur_plane_sally = stk.Checkbutton(self.frm_curprof,textvariable=self.pur_plane_str_sally,variable=self.pur_plane_bool_sally,func=self.load_form_into_data) + self.chk_pur_plane_sally.grid(row=7,column=1,sticky="W") + self.chk_pur_plane_rerun = stk.Checkbutton(self.frm_curprof,textvariable=self.pur_plane_str_rerun,variable=self.pur_plane_bool_rerun,func=self.load_form_into_data) + self.chk_pur_plane_rerun.grid(row=8,column=1,sticky="W") + self.chk_pur_plane_pigpen = stk.Checkbutton(self.frm_curprof,textvariable=self.pur_plane_str_pigpen,variable=self.pur_plane_bool_pigpen,func=self.load_form_into_data) + self.chk_pur_plane_pigpen.grid(row=9,column=1,sticky="W") + self.chk_pur_plane_woodstock = stk.Checkbutton(self.frm_curprof,textvariable=self.pur_plane_str_woodstock,variable=self.pur_plane_bool_woodstock,func=self.load_form_into_data) + self.chk_pur_plane_woodstock.grid(row=10,column=1,sticky="W") + self.chk_pur_plane_baron = stk.Checkbutton(self.frm_curprof,textvariable=self.pur_plane_str_baron,variable=self.pur_plane_bool_baron,func=self.load_form_into_data) + self.chk_pur_plane_baron.grid(row=11,column=1,sticky="W") + + # status bar + self.status = stk.StatusBar(self.master,var=self.statustext) + #self.grid(expand=True,fill=tk.BOTH) + # and now open the first file + self.func_open(self.current_file.get()) + + def load_form_into_data(self, arg1 = None, arg2 = None, arg3 = None): + """ + Update self.bdata with the values currently displayed/edited on the form. This also updates the checksum. + """ + #print(f"debug (load_form_into_data): {arg1}, {arg2}, {arg3}") + if self.bdata: + bdata = self.bdata + else: + raise Exception("No file loaded.") + try: + bdata, message = srb_lib.set_name(bdata,1,self.prof1name.get()) + if message != "": + self.flash_entry(self.ent_prof1name) + raise Exception("Profile 1 name") + bdata, message = srb_lib.set_name(bdata,2,self.prof2name.get()) + if message != "": + self.flash_entry(self.ent_prof2name) + raise Exception("Profile 2 name") + bdata, message = srb_lib.set_name(bdata,3,self.prof3name.get()) + if message != "": + self.flash_entry(self.ent_prof3name) + raise Exception("Profile 3 name") + #print(f"debug: loading data with value prof1 tutorial: {self.prof1tut.get()}") + bdata, _ = srb_lib.set_tutorial_completed(bdata,1,self.prof1tut.get()) + bdata, _ = srb_lib.set_tutorial_completed(bdata,2,self.prof2tut.get()) + bdata, _ = srb_lib.set_tutorial_completed(bdata,3,self.prof3tut.get()) + + # and now the current profile stuff + profile_id = self.selected_profile.get() + if profile_id >= 1 and profile_id <= 3: + bdata = srb_lib.set_money(bdata,profile_id,self.money.get(),silent=self.silent.get()) + bdata, _ = srb_lib.set_plane_stat(bdata,profile_id,"health",self.health.get()) + bdata, _ = srb_lib.set_plane_stat(bdata,profile_id,"stunt",self.stunt.get()) + bdata, _ = srb_lib.set_plane_stat(bdata,profile_id,"gun",self.gun.get()) + bdata2 = srb_lib.set_weapon(bdata,profile_id,self.equipped_weapon.get(),silent=self.silent.get()) + if bdata2 == -1: + raise Exception("Failed when setting equipped weapon.") + else: + bdata = bdata2 + # purchased weapons + pur_weap_list = [] + if self.pur_weap_potato.get(): + pur_weap_list.append("Potato Gun") + if self.pur_weap_stinger.get(): + pur_weap_list.append("Stinger") + if self.pur_weap_watball.get(): + pur_weap_list.append("Water Balloon Cannon") + if self.pur_weap_snow.get(): + pur_weap_list.append("Snow Blower") + if self.pur_weap_fire.get(): + pur_weap_list.append("Fire Boomerang") + if self.pur_weap_lightn.get(): + pur_weap_list.append("Lightning Rod") + if self.pur_weap_romanc.get(): + pur_weap_list.append("Roman Candles") + if self.pur_weap_pumpkin.get(): + pur_weap_list.append("10 Gauge Pumpkin") + bdata, message = srb_lib.set_purchased_weapons(bdata, profile_id, "remove",["all"], silent=self.silent.get()) + if bdata == -1 or message != "": + raise Exception("Failed when clearing purchased weapons.") + #print(f"debug: will set weapon list {pur_weap_list}") + bdata, message = srb_lib.set_purchased_weapons(bdata, profile_id, "add",pur_weap_list, silent=self.silent.get()) + if bdata == -1 or message != "": + raise Exception("Failed when setting purchased weapons.") + # purchased planes + pur_planes_list = [] + if self.pur_plane_bool_marcie.get(): + pur_planes_list.append("marcie") + if self.pur_plane_bool_sally.get(): + pur_planes_list.append("sally") + if self.pur_plane_bool_rerun.get(): + pur_planes_list.append("rerun") + if self.pur_plane_bool_pigpen.get(): + pur_planes_list.append("pigpen") + if self.pur_plane_bool_woodstock.get(): + pur_planes_list.append("woodstock") + if self.pur_plane_bool_baron.get(): + pur_planes_list.append("baron") + bdata, message = srb_lib.set_purchased_planes(bdata, profile_id, "remove",["all"], silent=self.silent.get()) + if bdata == -1 or message != "": + raise Exception("Failed when clearing purchased planes.") + #print(f"debug: will set plane list {pur_planes_list}") + bdata, message = srb_lib.set_purchased_planes(bdata, profile_id, "add", pur_planes_list, silent=self.silent.get()) + if bdata == -1 or message != "": + raise Exception(f"Failed when setting purchased planes, message {message}.") + except Exception as e: + bdata = -1 + print(f"ERROR! Invalid {e}") + # If everything saved correctly, reload the checksum and then reload the form with the new data. This means that if you type in lower-case letters in a profile name, it will reload the valid upper-case name because the game only stores uppercase letters. + if bdata != -1: + self.bdata = bdata + csum = 0 + try: + csum = srb_lib.calculate_checksum(self.bdata) + except: + pass + if csum != 0: + #print(f"need to do something with new checksum 0x{csum:08x}") + self.bchecksum = csum + self.load_data_into_form() + + def flash_entry(self, this_entry): + """ Flash the offending entry box red for one second. """ + # Improve: move this to stackrpms_tk_lib? + current_color = this_entry.cget("background") + #print(f"DEBUG (flash_entry): this_entry={this_entry}") + #print(f"DEBUG: current_color {current_color}") + if current_color in ["#ffffff","white"]: # name is supposed to work but it does not + next_color = "red" #if current_color == "#ffffff" else "white" + this_entry.config(background=next_color) + self.master.after(1000, lambda te=this_entry: self.flash_entry(te)) + else: + next_color = "white" + this_entry.config(background=next_color) + + # functions + def func_about(self): + """ Display about dialog. """ + tkinter.messagebox.Message(title="About",message=ABOUT_TEXT,icon="info").show() + + def func_exit(self): + # in case we need to manually do stuff + # otherwise command=self.client_exit would have sufficed. + self.master.quit() + + def func_open(self, filen = None): + if filen: + filename = filen + else: + filename = tkinter.filedialog.askopenfilename() + # filename might be a None or something if the dialog was cancelled + if filename: + self.current_file.set(filename) + self.statustext.set(f"File: {self.current_file.get()}") + self.bchecksum, self.bdata = srb_lib.read_file(filename) + if not self.silent.get(): + print(f"File {filename} has checksum 0x{self.bchecksum:08x}, len(data)={len(self.bdata)}") + self.load_data_into_form() + + def func_reload(self): + """ + Convenience function to just run func_open against the current file. The command lambda func_open failed to work because it seems to hardcode to just the first-defined current_file. + """ + self.func_open(self.current_file.get()) + + def mark_traces(self,action): + """ Enable or disable traces for variables. This protects the form from always calling load-form-into-data while we are populating the form initially! """ + if action == "enable": + for j in [ + self.health, + self.stunt, + self.gun, + self.equipped_weapon, + #self.prof1name, # do not trace the profile names, because it prevents spaces ending on the variables + #self.prof2name, + #self.prof3name, + ]: + j.trace("w",self.load_form_into_data) + # different one + self.selected_profile.trace("w",self.load_data_into_form_for_current_profile) + else: # disable + for j in [ + self.health, + self.selected_profile, + self.stunt, + self.gun, + self.equipped_weapon, + #self.prof1name, + #self.prof2name, + #self.prof3name, + ]: + for i in j.trace_info(): + if i[0][0] == "write": + j.trace_remove(i[0][0],i[1]) + + def load_data_into_form(self, arg1 = None, arg2 = None, arg3 = None): + """ Update all fields in dialog with contents of bdata. """ + #print(f"debug (load_data_into_form): {arg1}, {arg2}, {arg3}") + self.mark_traces("disable") + # set all variables that are tied to entry boxes + self.checksum.set(hex(self.bchecksum)[2:]) + self.prof1_used.set(srb_lib.get_profile_in_use(self.bdata,1)) + self.prof1tut.set(srb_lib.get_tutorial_completed(self.bdata,1)) + prof1name = srb_lib.get_name(self.bdata,1).rstrip() + self.prof1name.set(prof1name) + self.prof2_used.set(srb_lib.get_profile_in_use(self.bdata,2)) + self.prof2tut.set(srb_lib.get_tutorial_completed(self.bdata,2)) + prof2name = srb_lib.get_name(self.bdata,2).rstrip() + self.prof2name.set(prof2name) + self.prof3_used.set(srb_lib.get_profile_in_use(self.bdata,3)) + self.prof3tut.set(srb_lib.get_tutorial_completed(self.bdata,3)) + prof3name = srb_lib.get_name(self.bdata,3).rstrip() + self.prof3name.set(prof3name) + self.load_data_into_form_for_current_profile() + # not necessary because for_current_profile runs the enable. + #self.mark_traces("enable") + + def load_data_into_form_for_current_profile(self, arg1 = None, arg2 = None, arg3 = None): + """ Determine the currently-selected radio button for profile and load those values into the form. """ + if not self.bdata: + return "No file loaded." + profile_id = self.selected_profile.get() + #print(f"debug: Using profile_id {profile_id}") + #print(f"debug: arg1 {arg1}") + #print(f"debug: arg2 {arg2}") + #print(f"debug: arg3 {arg3}") + if profile_id >= 1 and profile_id <= 3: + self.mark_traces("disable") + # money + self.money.set(srb_lib.get_money(self.bdata,profile_id)) + # equipped weapon + self.equipped_weapon.set(srb_lib.get_weapon(self.bdata,profile_id)) + # plane stats + stat, _ = srb_lib.get_plane_stat(self.bdata,profile_id,"health") + self.health.set(stat) + stat, _ = srb_lib.get_plane_stat(self.bdata,profile_id,"stunt") + self.stunt.set(stat) + stat, _ = srb_lib.get_plane_stat(self.bdata,profile_id,"gun") + self.gun.set(stat) + # purchased weapons + weapons_list, weapons_mask = srb_lib.get_purchased_weapons(self.bdata,profile_id,silent=self.silent.get()) + if weapons_list == "all": + weapons_list = [i["name"] for i in srb_lib.WEAPONS if i["e"]] + elif weapons_list == "none": + weapons_list = [] + # and now set each weapon + self.pur_weap_potato.set(True if "Potato Gun" in weapons_list else False) + self.pur_weap_stinger.set(True if "Stinger" in weapons_list else False) + self.pur_weap_watball.set(True if "Water Balloon Cannon" in weapons_list else False) + self.pur_weap_snow.set(True if "Snow Blower" in weapons_list else False) + self.pur_weap_fire.set(True if "Fire Boomerang" in weapons_list else False) + self.pur_weap_lightn.set(True if "Lightning Rod" in weapons_list else False) + self.pur_weap_romanc.set(True if "Roman Candles" in weapons_list else False) + self.pur_weap_pumpkin.set(True if "10 Gauge Pumpkin" in weapons_list else False) + # purchased planes + planes_list, planes_mask = srb_lib.get_purchased_planes(self.bdata,profile_id,silent=self.silent.get()) + if planes_list == "all": + planes_list = [i["name"] for i in srb_lib.PLANES] + elif planes_list == "none": + planes_list = [] + self.pur_plane_bool_marcie.set(True if "marcie" in planes_list else False) + self.pur_plane_bool_sally.set(True if "sally" in planes_list else False) + self.pur_plane_bool_rerun.set(True if "rerun" in planes_list else False) + self.pur_plane_bool_pigpen.set(True if "pigpen" in planes_list else False) + self.pur_plane_bool_woodstock.set(True if "woodstock" in planes_list else False) + self.pur_plane_bool_baron.set(True if "baron" in planes_list else False) + x = 0 + for i in srb_lib.LEVELSETS: + _, letters = srb_lib.get_levelset_status(self.bdata,profile_id,x,silent=self.silent.get()) + if 0 == x: + self.pur_plane_str_marcie.set(letters) + elif 1 == x: + self.pur_plane_str_sally.set(letters) + elif 2 == x: + self.pur_plane_str_rerun.set(letters) + elif 3 == x: + self.pur_plane_str_pigpen.set(letters) + elif 4 == x: + self.pur_plane_str_woodstock.set(letters) + elif 5 == x: + self.pur_plane_str_baron.set(letters) + x += 1 + self.mark_traces("enable") + # end if-profile_id is 1,2 or 3. + + else: + print(f"Error! Cannot load that profile_id into the form.") + + def refresh_to_data(self): + """ Update bdata with values from dialog? """ + #srb_lib. + + def func_save(self): + filename = None + try: + filename = self.current_file.get() + except: + msg = "Cannot save because we have not opened anything yet." + print(f"WARNING: {msg}") + tk.messagebox.showerror(title="Error",message=msg) + if filename: + checksum = self.bchecksum + #print(f"how about save to file {filename}, checksum {checksum}") + #print(f"checksum hex 0x{checksum:08x}, int {checksum}") + srb_lib.write_file(filename,checksum, self.bdata) + + def func_save_as(self): + filename = None + try: + filename = tkinter.filedialog.asksaveasfilename(initialfile=self.current_file.get()) + except: + msg = "Cannot save as because did not get a valid file from the dialog." + tk.messagebox.showerror(title="Error",message=msg) + if filename: + checksum = self.bchecksum + srb_lib.write_file(filename,checksum,self.bdata) + +# main +root = tk.Tk() +srb_tk = App(root) +srb_tk.mainloop() diff --git a/stackrpms_tk_lib.py b/stackrpms_tk_lib.py new file mode 100755 index 0000000..a086344 --- /dev/null +++ b/stackrpms_tk_lib.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +# Startdate: 2024-03-28-5 14:49 +# Project: srb_lib but belongs on its own +# Dependencies: +# dep-devuan: python3-tkinter, python3-pil.imagetk +# rec-devuan: python3-cairosvg +# References: +# logout-manager-tcl, lmlib.py +# file:///usr/include/X11/keysymdef.h +# http://stackoverflow.com/questions/3221956/what-is-the-simplest-way-to-make-tooltips-in-tkinter/36221216#36221216 +# https://stackoverflow.com/questions/63871376/tkinter-widget-cgetvariable/63871784#63871784 +# https://coderslegacy.com/python/create-a-status-bar-in-tkinter/ +# https://stackoverflow.com/questions/38329996/enable-mouse-wheel-in-spinbox-tk-python +# https://insolor.github.io/effbot-tkinterbook-archive/tkinter-events-and-bindings.htm +# Improve: +from tkinter import * +from tkinter import Spinbox as tkSpinbox, Checkbutton as tkCheckbutton, Entry as tkEntry, Radiobutton as tkRadiobutton +from pathlib import Path +import glob, re, os, configparser, sys +# has to happen after tkinter +from PIL import Image, ImageTk + +USE_SVG = False +MAX_RECURSION = 10 # safety valve for any recursion +try: + from cairosvg import svg2png + USE_SVG = True +except: + print(f"WARNING: unable to import cairosvg. Try package python3-cairosvg. No svg image support is available.",file=sys.stderr) + +######################## +# copied from logout-manager-tcl +# these should be put into a stackrpms-python3-tcl library + +class Tooltip: + + ''' + It creates a tooltip for a given widget as the mouse goes on it. + see: + http://stackoverflow.com/questions/3221956/ + what-is-the-simplest-way-to-make-tooltips- + in-tkinter/36221216#36221216 + http://www.daniweb.com/programming/software-development/ + code/484591/a-tooltip-class-for-tkinter + - Originally written by vegaseat on 2014.09.09. + - Modified to include a delay time by Victor Zaccardo on 2016.03.25. + - Modified + - to correct extreme right and extreme bottom behavior, + - to stay inside the screen whenever the tooltip might go out on + the top but still the screen is higher than the tooltip, + - to use the more flexible mouse positioning, + - to add customizable background color, padding, waittime and + wraplength on creation + by Alberto Vassena on 2016.11.05. + Tested on Ubuntu 16.04/16.10, running Python 3.5.2 + TODO: themes styles support + ''' + + def __init__(self, widget, + *, + bg='#FFFFEA', + pad=(5, 3, 5, 3), + text='widget info', + waittime=400, + wraplength=250): + self.waittime = waittime # in miliseconds, originally 500 + self.wraplength = wraplength # in pixels, originally 180 + self.widget = widget + self.text = text + self.widget.bind("<Enter>", self.onEnter) + self.widget.bind("<Leave>", self.onLeave) + self.widget.bind("<ButtonPress>", self.onLeave) + self.bg = bg + self.pad = pad + self.id = None + self.tw = None + + def onEnter(self, event=None): + self.schedule() + + def onLeave(self, event=None): + self.unschedule() + self.hide() + + def schedule(self): + self.unschedule() + self.id = self.widget.after(self.waittime, self.show) + + def unschedule(self): + id_ = self.id + self.id = None + if id_: + self.widget.after_cancel(id_) + + def show(self): + def tip_pos_calculator(widget, label, + *, + tip_delta=(10, 5), pad=(5, 3, 5, 3)): + w = widget + s_width, s_height = w.winfo_screenwidth(), w.winfo_screenheight() + width, height = (pad[0] + label.winfo_reqwidth() + pad[2], + pad[1] + label.winfo_reqheight() + pad[3]) + mouse_x, mouse_y = w.winfo_pointerxy() + x1, y1 = mouse_x + tip_delta[0], mouse_y + tip_delta[1] + x2, y2 = x1 + width, y1 + height + x_delta = x2 - s_width + if x_delta < 0: + x_delta = 0 + y_delta = y2 - s_height + if y_delta < 0: + y_delta = 0 + offscreen = (x_delta, y_delta) != (0, 0) + if offscreen: + if x_delta: + x1 = mouse_x - tip_delta[0] - width + if y_delta: + y1 = mouse_y - tip_delta[1] - height + offscreen_again = y1 < 0 # out on the top + if offscreen_again: + # No further checks will be done. + # TIP: + # A further mod might automagically augment the + # wraplength when the tooltip is too high to be + # kept inside the screen. + y1 = 0 + return x1, y1 + bg = self.bg + pad = self.pad + widget = self.widget + # creates a toplevel window + self.tw = Toplevel(widget) + # Leaves only the label and removes the app window + self.tw.wm_overrideredirect(True) + win = Frame(self.tw, + background=bg, + borderwidth=0) + label = Label(win, + text=self.text, + justify=LEFT, + background=bg, + relief=SOLID, + borderwidth=0, + wraplength=self.wraplength) + label.grid(padx=(pad[0], pad[2]), + pady=(pad[1], pad[3]), + sticky=NSEW) + win.grid() + x, y = tip_pos_calculator(widget, label) + self.tw.wm_geometry("+%d+%d" % (x, y)) + + def hide(self): + tw = self.tw + if tw: + tw.destroy() + self.tw = None + +def mynum(x, type = "all"): + # return the complicated numerical value for the weird size options + f = re.split("[^0-9]+",x) + try: + f0 = int(f[0]) + except: + f0 = 0 + try: + f1 = int(f[1]) + except: + f1 = 0 + if type == "all": + return f0 * 100 + f1 if len(f) >= 2 else 0 + else: + return f0 + +def image_from_svg(filename = "",size = "48"): + # open svg + if USE_SVG == 1: + svg2png(url=filename,write_to="/tmp/lm_temp_image.png",parent_width = size,parent_height = size) + photo = Image.open("/tmp/lm_temp_image.png") + else: + photo = Image.new("RGBA",[size,size]) + return photo + +def sort_sizes(x): + # Original reference so#46228101 + value = x.split("/")[5] + return mynum(value, "all") + +def find_best_size_match(size, thelist): + # return item from sorted thelist whose split("/")[5] is the first to meet or exceed the requested size + try: + default = thelist[-1] + except: + default = None + return next(( i for i in thelist if mynum(i.split("/")[5],"real") >= size ), default) + +def empty_photoimage(size=24): + photo = Image.new("RGBA",[size,size]) + return ImageTk.PhotoImage(image=photo) + +def get_scaled_icon(icon_name, size = 24, icon_theme = "default", fallback_icon_name = "", category = "actions"): + iconfilename = None + # if name is a specific filename, just use it. + if Path(icon_name).is_file(): + #print("This is a file:",icon_name) + iconfilename = icon_name + else: + if icon_theme == "default": + # this should not happen, because the Initialize_config should have checked gtk3 default value. + icon_theme = "hicolor" + # so now that icon_theme is defined, let us go find the icon that matches the requested name and size, in the actions category + #print("Using icon theme",icon_theme) + # category is the fd.o icon category. logout-manager-tcl hardcoded "action" + iconfilename = get_filename_of_icon(name=icon_name, theme=icon_theme, size=size, category=category, allow_svg = USE_SVG) + # So now we think we have derived the correct filename + try: + #print("Trying icon file",iconfilename) + # try an svg + if re.compile(".*\.svg").match(iconfilename): + #print("Trying svg...") + photo = image_from_svg(filename=iconfilename, size=size) + else: + photo = Image.open(iconfilename) + except Exception as f: + print(f"Error with icon file: {f}.",file=sys.stderr) + return empty_photoimage() + photo.thumbnail(size=[size, size]) + try: + photo = ImageTk.PhotoImage(photo) + except Exception as e: + print("Error was ",e,file=sys.stderr) + # If I ever add HiDPI support, multiple size here by the factor. So, size * 1.25 + return photo + +def get_filename_of_icon(name, theme = "hicolor", size = 48, category = "actions", allow_svg = False): + # poor man's attempt at walking through fd.o icon theme + filename = None + # example: Adwaita system-log-out + if theme == "default" or theme is None: + try: + theme = get_gtk3_default_icon_theme() + except: + theme = "hicolor" + # first, find all files underneath /usr/share/icons/$THEME/$SIZE + #print("Finding filename of icon, theme=",theme,"category=",category,"name=",name) + # to exclude the scalable/ contents, replace dir 5 asterisk with [0-9]* + results = [] + base_dir = "/usr/share/icons/" + file_filters = ".*" + if not allow_svg: + file_filters = ".{png,PNG}" + # I have no idea if this is xdg icon theme compliant, but it is a valiant attempt. + # 1. try (requested) req-theme, req-category, req-name first + results = glob.glob(base_dir+theme+"/*/"+category+"/"+name+file_filters) + # 2. try req-theme, (generic) gen-category, req-name + if len(results) == 0: + # no results with that category, so try all categories + results = glob.glob(base_dir+theme+"/*/*/"+name+file_filters) + # 3. try "gnome", req-category, req-name + if len(results) == 0: + results = glob.glob(base_dir+"gnome"+"/*/"+category+"/"+name+file_filters) + # 4. try "gnome", gen-category, req-name + if len(results) == 0: + results = glob.glob(base_dir+"gnome"+"/*/*/"+name+file_filters) + # 5. try "hicolor", req-category, req-name + if len(results) == 0: + results = glob.glob(base_dir+"hicolor"+"/*/"+category+"/"+name+file_filters) + # 6. try "hicolor", gen-category, req-name + if len(results) == 0: + results = glob.glob(base_dir+"hicolor"+"/*/*/"+name+file_filters) + # the sort arranges it so a Numix/24 dir comes before a Numix/24@2x dir + results = sorted(results, key=sort_sizes) + #print(results) + # now find the first one that matches + filename = find_best_size_match(size,results) + return filename + +def get_gtk3_default_icon_theme(): + # abstracted so it does not clutter get_scaled_icon + name = "hicolor" + gtk3_config_path = os.path.join(os.path.expanduser("~"),".config","gtk-3.0","settings.ini") + gtk3_config = configparser.ConfigParser() + gtk3_config.read(gtk3_config_path) + try: + if 'Settings' in gtk3_config: + name = gtk3_config['Settings']['gtk-icon-theme-name'] + elif 'settings' in gtk3_config: + name = gtk3_config['settings']['gtk-icon-theme-name'] + except: + # supposed failsafe: keep name = hicolor + pass + #print("Found gtk3 default theme:",name) + return name + +################################### +# new for srb +class StatusBar(Frame): + def __init__(self, master, var = None): + Frame.__init__(self, master, borderwidth = 1, background = "darkgray") + if var: + self.label = Label(self,textvariable=var) + else: + self.label = Label(self) + self.label.pack(side=LEFT) + #self.pack(side=BOTTOM, fill=X) + self.grid(row=100,column=0,columnspan=100) + def set(self, newText): + self.label.config(text=newText) + def clear(self): + self.label.config(text="") + +class Spinbox(tkSpinbox): + """ + This enables mouse scrolling without having to add it to each instance. + """ + def __init__(self, *args, **kwargs): + tkSpinbox.__init__(self, *args, **kwargs) + self.bind('<MouseWheel>', self.mouseWheel) + self.bind('<Button-4>', self.mouseWheel) + self.bind('<Button-5>', self.mouseWheel) + + def mouseWheel(self, event): + if event.num == 5 or event.delta == -120: + self.invoke('buttondown') + elif event.num == 4 or event.delta == 120: + self.invoke('buttonup') + +class Checkbutton(tkCheckbutton): + """ + Add a specific function to these key-up/button-up sequences, without having to add it to each instance individually. + """ + def __init__(self, *args, **kwargs): + func = None + if "func" in kwargs.keys(): + func = kwargs.pop("func") + tkCheckbutton.__init__(self, *args, **kwargs) + if func: + self.bind("<ButtonRelease-1>",func) + self.bind("<KeyRelease-space>",func) + self.bind("<KeyRelease-Return>",func) + +class Entry(tkEntry): + def __init__(self, *args, **kwargs): + func = None + if "func" in kwargs.keys(): + func = kwargs.pop("func") + tkEntry.__init__(self, *args, **kwargs) + if func: + self.bind("<Key-Return>",func) + self.bind("<Key-KP_Enter>",func) + # Do not need specific shift-tab stuff because FocusOut is possible! + #self.bind("<Key-Tab>",func) + #self.bind("<Shift-KeyPress-Tab>",func) + self.bind("<FocusOut>",func) + +class Radiobutton(tkRadiobutton): + """ + Extends tkRadioButtons: + * Store self.__var attribute that connects to the tk.IntVar() used + * Moves between all associated radio buttons with up/down keys! + """ + @property + def var(self): + return self.__var + + @property + def value(self) -> int: + return self.__var.get() + + @value.setter + def value(self, value): + self.__var.set(int(value)) + + def __init__(self, *args, **kwargs): + self.__var = tk.IntVar() if not "variable" in kwargs else kwargs["variable"] + tkRadiobutton.__init__(self, *args, **{**kwargs, "variable":self.__var}) + self.bind("<Key-Down>",self.scroll_with_keys) + self.bind("<Key-Up>",self.scroll_with_keys) + self.bind("<Key-Home>",self.scroll_with_keys) + self.bind("<Key-End>",self.scroll_with_keys) + + def add_item_if_var_matches(self, otherwidget, samevar, thelist): + j1 = None + try: + j1 = otherwidget.cget("variable") + except: + pass + if j1 == samevar: + #print(f"found {otherwidget} that uses var {j1}") + thelist.append(otherwidget) + return thelist + + def list_all_child_items_that_use_var(self, thistoplevel, thisvar, thislist, depth): + # safety valve + if depth >= MAX_RECURSION: + raise Exception("You dug too deep with recursion in list_all_child_items") + for i in thistoplevel.winfo_children(): + thislist = self.add_item_if_var_matches(i,thisvar,thislist) + thislist = self.list_all_child_items_that_use_var(i,thisvar,thislist,depth+1) # recurse + return thislist + + def widget_key(self,a): + """ Used to sort radio options by 'value'. """ + aval = None + try: + aval = a.cget("value") + except: + pass + return aval + + def scroll_with_keys(self, arg1 = None, arg2 = None, arg3 = None): + """ This depends on you setting the object.var property to match the variable. """ + # prepare all items that also use this variable + # nametowidget is a tk trick to get the top-level parent object of the current widget. That is, the app or top window. + items = self.list_all_child_items_that_use_var(self.nametowidget("."),self.cget("variable"),[],depth=0) + # sort them by value, in case they are not in order + items.sort(key=self.widget_key) + # we are using 1-indexing here. + items.insert(0, None) + #print(f"debug: now have items {items}") + # prepare the operation + direction = None + if arg1.keysym == "Up": + direction = +1 + elif arg1.keysym == "Down": + direction = +0 + #print(f"Must go direction {direction} in list {items}, using {self.__var.get()}") + # Because of the 1-indexing, by always adding one, we do scroll through the values the way you would expect. + if direction is not None: + self.__var.set((self.__var.get()+direction) % (len(items)-1) + 1) + elif arg1.keysym in ["Home"]: + self.__var.set(items[1].cget("value")) + elif arg1.keysym in ["End"]: + self.__var.set(items[-1].cget("value")) + # and now focus on the object whose value matches + for i in items: + i1 = None + try: + i1 = i.cget("value") + except: + pass + if i1 == self.__var.get(): + i.focus() + break |