aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--srb.pngbin0 -> 583 bytes
-rwxr-xr-xsrb_tk.py534
-rwxr-xr-xstackrpms_tk_lib.py442
3 files changed, 976 insertions, 0 deletions
diff --git a/srb.png b/srb.png
new file mode 100644
index 0000000..04d745f
--- /dev/null
+++ b/srb.png
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
bgstack15