Module steamback.gui
Expand source code
#!python3
from tkinter import *
from tkinter import ttk
import sv_ttk
import datetime
import timeago
import os
from async_tkinter_loop import async_handler
import asyncio
from . import Engine, util
logger = None
def add_scrollbar(view: ttk.Treeview) -> ttk.Scrollbar:
root = view.master
b = ttk.Scrollbar(root,
orient="vertical",
command=view.yview)
# Configuring treeview
view.configure(yscrollcommand=b.set)
return b
async def main_loop(root: Tk) -> None:
"""
An asynchronous implementation of tkinter mainloop
:param root: tkinter root object
:return: nothing
"""
try:
while True:
try:
root.winfo_exists() # Will throw TclError if the main window is destroyed
root.update()
except TclError:
break
# Don't poll events quickly if not visible and not the focus of the user
# no longer checking root.state() == 'normal'
interval = 0.05 if root.focus_displayof() else 0.5
await asyncio.sleep(interval)
except TclError as e:
if not "application has been destroyed" in str(e):
print(f'Exiting due to { e }')
def saveinfo_ago_str(si: dict) -> str:
# our timestamps are in msecs
now = datetime.datetime.now()
ts = datetime.datetime.fromtimestamp(si["timestamp"] / 1000.0)
ts_str = timeago.format(ts, now)
return ts_str
status_watching_str = "Status: Watching Steam for game exit..."
class GUI:
def __init__(self, root: Tk, e: Engine):
self.root = root
self.engine = e
self.watcher = util.SteamWatcher(e)
# A dictionary mapping from saveinfo filename to saveinfo dictionary object
self.saves = None
# The saveinfo for the undo file
self.undo = None
self.set_app_icon()
root.title("Steamback")
root.resizable(width=800, height=400)
"""Save window position on exit"""
def on_close():
# Here I write the X Y position of the window to a file "myapp.conf"
with open(os.path.join(self.engine.config.app_data_dir, "window.conf"), "w") as conf:
conf.write(root.geometry())
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_close)
# Here I read the X and Y positon of the window from when I last closed it.
try:
with open(os.path.join(self.engine.config.app_data_dir, "window.conf"), "r") as conf:
root.geometry(conf.read())
except Exception:
pass # Ignore any errors (file might be missing etc)
""" self.label_index = 0
self.label_text = StringVar()
self.label_text.set(self.LABEL_TEXT[self.label_index])
self.label = Label(root, textvariable=self.label_text)
self.label.bind("<Button-1>", self.cycle_label_text)
self.label.pack() """
# self.greet_button = ttk.Button(root, text="Greet", command=self.greet)
# self.greet_button.pack()
# self.close_button = ttk.Button(root, text="Close", command=root.quit)
# self.close_button.pack()
self.undo_button = ttk.Button(
root, text="Undo changes to XXX", command=async_handler(self.on_undo_click))
self.revert_button = ttk.Button(
root, text="Revert XXX to save from 5 minutes ago", command=async_handler(self.on_revert_click))
# Define a label for the list.
self.status = ttk.Label(
root, text=status_watching_str)
# List supported games
treev = ttk.Treeview(root,
selectmode='browse'
# bg="grey",
# activestyle='dotbox'
)
self.supported_games = treev
s_sb = add_scrollbar(treev)
# Defining number of columns
treev["columns"] = ("name",)
# Defining heading
treev['show'] = 'headings'
treev.heading("name", text="Supported Games")
# List supported games
treev = ttk.Treeview(root,
selectmode='none'
# bg="grey",
# activestyle='dotbox'
)
self.supported_games = treev
games_sb = add_scrollbar(treev)
# Defining number of columns
treev["columns"] = ("name",)
# Defining heading
treev['show'] = 'headings'
treev.heading("name", text="Supported Games")
char_width = 8
treev.column("name", minwidth=char_width * 10,
width=char_width * 30, stretch=YES)
# List save games
treev = ttk.Treeview(root,
selectmode='browse'
# bg="grey",
# activestyle='dotbox'
)
self.save_games = treev
saves_sb = add_scrollbar(treev)
# Defining number of columns
treev["columns"] = ("name", "time")
# Defining heading
treev['show'] = 'headings'
treev.heading("name", text="Save games")
treev.heading("time", text="Time")
treev.column("name", minwidth=char_width * 10,
width=char_width * 30, stretch=YES)
treev.column("time", minwidth=char_width * 6,
width=char_width * 17, stretch=NO)
treev.bind("<<TreeviewSelect>>", self.on_savegame_selected)
# Do the layout per this great documentation: https://tkdocs.com/tutorial/grid.html
self.supported_games.grid(
row=0, column=0, sticky=(N, S, W, E), rowspan=3)
games_sb.grid(row=0, column=1, sticky=(N, S), rowspan=3)
self.save_games.grid(row=0, column=3, sticky=(N, S, E, W), rowspan=1)
saves_sb.grid(row=0, column=4, sticky=(N, S), rowspan=1)
# Position the various undo/revert buttons but hide them for now
self.undo_button.grid(row=1, column=3, padx=8, pady=8)
self.revert_button.grid(row=2, column=3, padx=8, pady=8)
self.undo_button.grid_remove()
self.revert_button.grid_remove()
self.status.grid(row=10, column=0, sticky=(
W, E), padx=8, pady=8, columnspan=4)
root.rowconfigure(0, weight=1)
# grow the currently empty middle space
root.columnconfigure(0, weight=1)
root.columnconfigure(2, weight=1)
root.columnconfigure(3, weight=1)
"""A savegame was selected in the treeview"""
def on_savegame_selected(self, event):
for filename in self.save_games.selection():
si = self.saves[filename]
# set revert button text
self.revert_button.config(
text=f'Revert { si["game_info"]["game_name"]} to save from { saveinfo_ago_str(si)}')
self.revert_button.grid() # show revert button
"""user clicked to restore from a savegame"""
async def on_revert_click(self):
for filename in self.save_games.selection():
# do the restore
si = self.saves[filename]
# self.engine.dry_run = True # FIXME - testing
await self.engine.do_restore(si)
# Set status msg
new_text = f'Reverted to { si["game_info"]["game_name"] } snapshot'
self.set_status(new_text)
# deselect the item the user just reverted
self.save_games.selection_remove(filename)
self.revert_button.grid_remove()
# We just generated an undo save, so update the list of savegames
await self.find_savegames()
"""User wants to undo our last revert"""
async def on_undo_click(self):
# do the restore
si = self.undo
assert si
# self.engine.dry_run = True # FIXME - testing
await self.engine.do_restore(si)
# Set status msg
new_text = f'Undid changes to { si["game_info"]["game_name"] }'
self.set_status(new_text)
async def find_supported(self):
all_games = self.engine.find_all_game_info()
supported = await self.engine.find_supported(all_games)
tree = self.supported_games
# put all children into the args of this function call
tree.delete(*tree.get_children())
for g in supported:
tree.insert(
"", END, values=(g["game_name"], ))
async def find_savegames(self):
all_saves = await self.engine.get_saveinfos()
# Don't list any undos in the tree view
saveinfos = list(filter(lambda i: not i["is_undo"], all_saves))
undos = list(filter(lambda i: i["is_undo"], all_saves))
self.saves = {si["filename"]: si for si in saveinfos}
# show undo button as needed
if len(undos) > 0:
g = undos[0]
self.undo = g
self.undo_button.config(
text=f'Undo changes to { g["game_info"]["game_name"]}')
self.undo_button.grid()
# fill the treeview
# put all children into the args of this function call
tree = self.save_games
tree.delete(*tree.get_children())
for g in saveinfos:
# print(f' {g}')
tree.insert(
"", END, iid=g["filename"], values=(g["game_info"]["game_name"], saveinfo_ago_str(g)))
"""Look for steam changes, and then queue up looking again"""
async def watch_steam(self):
# self.engine.ignore_unchanged = False # for testing
result = await self.watcher.check_once()
backups = result.backed_up
if result.game_started:
self.set_status(status_watching_str)
if (len(backups) > 0):
si = backups[0] # only print for first one (the common case)
new_text = f'Save-game snapshot taken for { si["game_info"]["game_name"] }...'
await self.find_savegames()
self.set_status(new_text)
# Our run is exiting, but queue one for the future
quitting = False
if not quitting:
await asyncio.sleep(5)
asyncio.create_task(self.watch_steam())
def set_status(self, new_text):
self.status.config(text=new_text)
async def async_main_loop(self):
# do this in the background - update gui when done
asyncio.create_task(self.find_supported())
asyncio.create_task(self.find_savegames())
asyncio.create_task(self.watch_steam())
await main_loop(self.root)
"""Set the icon for our app in GUI"""
def set_app_icon(self):
# might be missing on some systems so use a try catch and do the imports here
try:
from PIL import Image, ImageTk
with Image.open(os.path.join(os.path.dirname(
__file__), 'data', 'icons8-refresh-96.png')) as ico:
photo = ImageTk.PhotoImage(ico)
self.root.wm_iconphoto(True, photo)
except Exception as e:
logger.warning(
f'Can\'t set application icon due to missing library: {e}')
"""
def cycle_label_text(self, event):
self.label_index += 1
self.label_index %= len(self.LABEL_TEXT) # wrap around
self.label_text.set(self.LABEL_TEXT[self.label_index])
"""
def run(e: Engine):
global logger
logger = e.config.logger
root = Tk()
# to make tk less ugly https://www.reddit.com/r/Python/comments/lps11c/how_to_make_tkinter_look_modern_how_to_use_themes/
# style = Style(root)
# Set the theme with the theme_use method
# style.theme_use('clam') # put the theme name here, that you want to use
sv_ttk.set_theme("dark")
g = GUI(root, e)
# async_mainloop(root)
asyncio.get_event_loop_policy().get_event_loop(
).run_until_complete(g.async_main_loop())
Functions
def add_scrollbar(view: tkinter.ttk.Treeview) ‑> tkinter.ttk.Scrollbar
-
Expand source code
def add_scrollbar(view: ttk.Treeview) -> ttk.Scrollbar: root = view.master b = ttk.Scrollbar(root, orient="vertical", command=view.yview) # Configuring treeview view.configure(yscrollcommand=b.set) return b
async def main_loop(root: tkinter.Tk) ‑> None
-
An asynchronous implementation of tkinter mainloop :param root: tkinter root object :return: nothing
Expand source code
async def main_loop(root: Tk) -> None: """ An asynchronous implementation of tkinter mainloop :param root: tkinter root object :return: nothing """ try: while True: try: root.winfo_exists() # Will throw TclError if the main window is destroyed root.update() except TclError: break # Don't poll events quickly if not visible and not the focus of the user # no longer checking root.state() == 'normal' interval = 0.05 if root.focus_displayof() else 0.5 await asyncio.sleep(interval) except TclError as e: if not "application has been destroyed" in str(e): print(f'Exiting due to { e }')
def run(e: Engine)
-
Expand source code
def run(e: Engine): global logger logger = e.config.logger root = Tk() # to make tk less ugly https://www.reddit.com/r/Python/comments/lps11c/how_to_make_tkinter_look_modern_how_to_use_themes/ # style = Style(root) # Set the theme with the theme_use method # style.theme_use('clam') # put the theme name here, that you want to use sv_ttk.set_theme("dark") g = GUI(root, e) # async_mainloop(root) asyncio.get_event_loop_policy().get_event_loop( ).run_until_complete(g.async_main_loop())
def saveinfo_ago_str(si: dict) ‑> str
-
Expand source code
def saveinfo_ago_str(si: dict) -> str: # our timestamps are in msecs now = datetime.datetime.now() ts = datetime.datetime.fromtimestamp(si["timestamp"] / 1000.0) ts_str = timeago.format(ts, now) return ts_str
Classes
class GUI (root: tkinter.Tk, e: Engine)
-
Expand source code
class GUI: def __init__(self, root: Tk, e: Engine): self.root = root self.engine = e self.watcher = util.SteamWatcher(e) # A dictionary mapping from saveinfo filename to saveinfo dictionary object self.saves = None # The saveinfo for the undo file self.undo = None self.set_app_icon() root.title("Steamback") root.resizable(width=800, height=400) """Save window position on exit""" def on_close(): # Here I write the X Y position of the window to a file "myapp.conf" with open(os.path.join(self.engine.config.app_data_dir, "window.conf"), "w") as conf: conf.write(root.geometry()) root.destroy() root.protocol("WM_DELETE_WINDOW", on_close) # Here I read the X and Y positon of the window from when I last closed it. try: with open(os.path.join(self.engine.config.app_data_dir, "window.conf"), "r") as conf: root.geometry(conf.read()) except Exception: pass # Ignore any errors (file might be missing etc) """ self.label_index = 0 self.label_text = StringVar() self.label_text.set(self.LABEL_TEXT[self.label_index]) self.label = Label(root, textvariable=self.label_text) self.label.bind("<Button-1>", self.cycle_label_text) self.label.pack() """ # self.greet_button = ttk.Button(root, text="Greet", command=self.greet) # self.greet_button.pack() # self.close_button = ttk.Button(root, text="Close", command=root.quit) # self.close_button.pack() self.undo_button = ttk.Button( root, text="Undo changes to XXX", command=async_handler(self.on_undo_click)) self.revert_button = ttk.Button( root, text="Revert XXX to save from 5 minutes ago", command=async_handler(self.on_revert_click)) # Define a label for the list. self.status = ttk.Label( root, text=status_watching_str) # List supported games treev = ttk.Treeview(root, selectmode='browse' # bg="grey", # activestyle='dotbox' ) self.supported_games = treev s_sb = add_scrollbar(treev) # Defining number of columns treev["columns"] = ("name",) # Defining heading treev['show'] = 'headings' treev.heading("name", text="Supported Games") # List supported games treev = ttk.Treeview(root, selectmode='none' # bg="grey", # activestyle='dotbox' ) self.supported_games = treev games_sb = add_scrollbar(treev) # Defining number of columns treev["columns"] = ("name",) # Defining heading treev['show'] = 'headings' treev.heading("name", text="Supported Games") char_width = 8 treev.column("name", minwidth=char_width * 10, width=char_width * 30, stretch=YES) # List save games treev = ttk.Treeview(root, selectmode='browse' # bg="grey", # activestyle='dotbox' ) self.save_games = treev saves_sb = add_scrollbar(treev) # Defining number of columns treev["columns"] = ("name", "time") # Defining heading treev['show'] = 'headings' treev.heading("name", text="Save games") treev.heading("time", text="Time") treev.column("name", minwidth=char_width * 10, width=char_width * 30, stretch=YES) treev.column("time", minwidth=char_width * 6, width=char_width * 17, stretch=NO) treev.bind("<<TreeviewSelect>>", self.on_savegame_selected) # Do the layout per this great documentation: https://tkdocs.com/tutorial/grid.html self.supported_games.grid( row=0, column=0, sticky=(N, S, W, E), rowspan=3) games_sb.grid(row=0, column=1, sticky=(N, S), rowspan=3) self.save_games.grid(row=0, column=3, sticky=(N, S, E, W), rowspan=1) saves_sb.grid(row=0, column=4, sticky=(N, S), rowspan=1) # Position the various undo/revert buttons but hide them for now self.undo_button.grid(row=1, column=3, padx=8, pady=8) self.revert_button.grid(row=2, column=3, padx=8, pady=8) self.undo_button.grid_remove() self.revert_button.grid_remove() self.status.grid(row=10, column=0, sticky=( W, E), padx=8, pady=8, columnspan=4) root.rowconfigure(0, weight=1) # grow the currently empty middle space root.columnconfigure(0, weight=1) root.columnconfigure(2, weight=1) root.columnconfigure(3, weight=1) """A savegame was selected in the treeview""" def on_savegame_selected(self, event): for filename in self.save_games.selection(): si = self.saves[filename] # set revert button text self.revert_button.config( text=f'Revert { si["game_info"]["game_name"]} to save from { saveinfo_ago_str(si)}') self.revert_button.grid() # show revert button """user clicked to restore from a savegame""" async def on_revert_click(self): for filename in self.save_games.selection(): # do the restore si = self.saves[filename] # self.engine.dry_run = True # FIXME - testing await self.engine.do_restore(si) # Set status msg new_text = f'Reverted to { si["game_info"]["game_name"] } snapshot' self.set_status(new_text) # deselect the item the user just reverted self.save_games.selection_remove(filename) self.revert_button.grid_remove() # We just generated an undo save, so update the list of savegames await self.find_savegames() """User wants to undo our last revert""" async def on_undo_click(self): # do the restore si = self.undo assert si # self.engine.dry_run = True # FIXME - testing await self.engine.do_restore(si) # Set status msg new_text = f'Undid changes to { si["game_info"]["game_name"] }' self.set_status(new_text) async def find_supported(self): all_games = self.engine.find_all_game_info() supported = await self.engine.find_supported(all_games) tree = self.supported_games # put all children into the args of this function call tree.delete(*tree.get_children()) for g in supported: tree.insert( "", END, values=(g["game_name"], )) async def find_savegames(self): all_saves = await self.engine.get_saveinfos() # Don't list any undos in the tree view saveinfos = list(filter(lambda i: not i["is_undo"], all_saves)) undos = list(filter(lambda i: i["is_undo"], all_saves)) self.saves = {si["filename"]: si for si in saveinfos} # show undo button as needed if len(undos) > 0: g = undos[0] self.undo = g self.undo_button.config( text=f'Undo changes to { g["game_info"]["game_name"]}') self.undo_button.grid() # fill the treeview # put all children into the args of this function call tree = self.save_games tree.delete(*tree.get_children()) for g in saveinfos: # print(f' {g}') tree.insert( "", END, iid=g["filename"], values=(g["game_info"]["game_name"], saveinfo_ago_str(g))) """Look for steam changes, and then queue up looking again""" async def watch_steam(self): # self.engine.ignore_unchanged = False # for testing result = await self.watcher.check_once() backups = result.backed_up if result.game_started: self.set_status(status_watching_str) if (len(backups) > 0): si = backups[0] # only print for first one (the common case) new_text = f'Save-game snapshot taken for { si["game_info"]["game_name"] }...' await self.find_savegames() self.set_status(new_text) # Our run is exiting, but queue one for the future quitting = False if not quitting: await asyncio.sleep(5) asyncio.create_task(self.watch_steam()) def set_status(self, new_text): self.status.config(text=new_text) async def async_main_loop(self): # do this in the background - update gui when done asyncio.create_task(self.find_supported()) asyncio.create_task(self.find_savegames()) asyncio.create_task(self.watch_steam()) await main_loop(self.root) """Set the icon for our app in GUI""" def set_app_icon(self): # might be missing on some systems so use a try catch and do the imports here try: from PIL import Image, ImageTk with Image.open(os.path.join(os.path.dirname( __file__), 'data', 'icons8-refresh-96.png')) as ico: photo = ImageTk.PhotoImage(ico) self.root.wm_iconphoto(True, photo) except Exception as e: logger.warning( f'Can\'t set application icon due to missing library: {e}')
Methods
async def async_main_loop(self)
-
Expand source code
async def async_main_loop(self): # do this in the background - update gui when done asyncio.create_task(self.find_supported()) asyncio.create_task(self.find_savegames()) asyncio.create_task(self.watch_steam()) await main_loop(self.root)
async def find_savegames(self)
-
Expand source code
async def find_savegames(self): all_saves = await self.engine.get_saveinfos() # Don't list any undos in the tree view saveinfos = list(filter(lambda i: not i["is_undo"], all_saves)) undos = list(filter(lambda i: i["is_undo"], all_saves)) self.saves = {si["filename"]: si for si in saveinfos} # show undo button as needed if len(undos) > 0: g = undos[0] self.undo = g self.undo_button.config( text=f'Undo changes to { g["game_info"]["game_name"]}') self.undo_button.grid() # fill the treeview # put all children into the args of this function call tree = self.save_games tree.delete(*tree.get_children()) for g in saveinfos: # print(f' {g}') tree.insert( "", END, iid=g["filename"], values=(g["game_info"]["game_name"], saveinfo_ago_str(g)))
async def find_supported(self)
-
Expand source code
async def find_supported(self): all_games = self.engine.find_all_game_info() supported = await self.engine.find_supported(all_games) tree = self.supported_games # put all children into the args of this function call tree.delete(*tree.get_children()) for g in supported: tree.insert( "", END, values=(g["game_name"], ))
async def on_revert_click(self)
-
Expand source code
async def on_revert_click(self): for filename in self.save_games.selection(): # do the restore si = self.saves[filename] # self.engine.dry_run = True # FIXME - testing await self.engine.do_restore(si) # Set status msg new_text = f'Reverted to { si["game_info"]["game_name"] } snapshot' self.set_status(new_text) # deselect the item the user just reverted self.save_games.selection_remove(filename) self.revert_button.grid_remove() # We just generated an undo save, so update the list of savegames await self.find_savegames()
def on_savegame_selected(self, event)
-
Expand source code
def on_savegame_selected(self, event): for filename in self.save_games.selection(): si = self.saves[filename] # set revert button text self.revert_button.config( text=f'Revert { si["game_info"]["game_name"]} to save from { saveinfo_ago_str(si)}') self.revert_button.grid() # show revert button
async def on_undo_click(self)
-
Expand source code
async def on_undo_click(self): # do the restore si = self.undo assert si # self.engine.dry_run = True # FIXME - testing await self.engine.do_restore(si) # Set status msg new_text = f'Undid changes to { si["game_info"]["game_name"] }' self.set_status(new_text)
def set_app_icon(self)
-
Expand source code
def set_app_icon(self): # might be missing on some systems so use a try catch and do the imports here try: from PIL import Image, ImageTk with Image.open(os.path.join(os.path.dirname( __file__), 'data', 'icons8-refresh-96.png')) as ico: photo = ImageTk.PhotoImage(ico) self.root.wm_iconphoto(True, photo) except Exception as e: logger.warning( f'Can\'t set application icon due to missing library: {e}')
def set_status(self, new_text)
-
Expand source code
def set_status(self, new_text): self.status.config(text=new_text)
async def watch_steam(self)
-
Expand source code
async def watch_steam(self): # self.engine.ignore_unchanged = False # for testing result = await self.watcher.check_once() backups = result.backed_up if result.game_started: self.set_status(status_watching_str) if (len(backups) > 0): si = backups[0] # only print for first one (the common case) new_text = f'Save-game snapshot taken for { si["game_info"]["game_name"] }...' await self.find_savegames() self.set_status(new_text) # Our run is exiting, but queue one for the future quitting = False if not quitting: await asyncio.sleep(5) asyncio.create_task(self.watch_steam())