Implement settings and saving them to the settings file

This commit is contained in:
DevilXD committed 2022-03-16 19:26:39 +01:00
1 parent 82b999995f
commit 5a3a540b45
7 files changed
+741 -179

No files matched your search

+2 -1
View File
@@ -11,4 +11,5 @@ __pycache__
/*.tmp /*.tmp
/Twitch Drops Miner /Twitch Drops Miner
# Dev files # Dev files
cookies* cookies.jar
settings.json
+490 -101
View File
@@ -1,12 +1,15 @@
from __future__ import annotations from __future__ import annotations
import sys
import asyncio import asyncio
import logging import logging
import tkinter as tk import tkinter as tk
from pathlib import Path
from math import log10, ceil from math import log10, ceil
from functools import partial
from tkinter.font import Font from tkinter.font import Font
from tkinter import Tk, ttk, StringVar, DoubleVar
from collections import abc, namedtuple, OrderedDict from collections import abc, namedtuple, OrderedDict
from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar
from typing import Any, TypedDict, NoReturn, TYPE_CHECKING from typing import Any, TypedDict, NoReturn, TYPE_CHECKING
try: try:
@@ -15,6 +18,7 @@ except ModuleNotFoundError as exc:
raise ImportError("You have to run 'pip install pystray' first") from exc raise ImportError("You have to run 'pip install pystray' first") from exc
from utils import resource_path from utils import resource_path
from registry import RegistryKey, ValueType
from constants import FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State from constants import FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -25,6 +29,7 @@ if TYPE_CHECKING:
digits = ceil(log10(WS_TOPICS_LIMIT)) digits = ceil(log10(WS_TOPICS_LIMIT))
WS_FONT = ("Courier New", 10) WS_FONT = ("Courier New", 10)
LARGE_FONT = (..., 12)
class _ICOImage: class _ICOImage:
@@ -49,10 +54,10 @@ class PlaceholderEntry(ttk.Entry):
def __init__( def __init__(
self, self,
master: ttk.Widget, master: ttk.Widget,
*args, *args: Any,
placeholder: str, placeholder: str,
placeholdercolor: str = "grey60", placeholdercolor: str = "grey60",
**kwargs, **kwargs: Any,
): ):
super().__init__(master, *args, **kwargs) super().__init__(master, *args, **kwargs)
self._show: str = kwargs.get("show", '') self._show: str = kwargs.get("show", '')
@@ -61,19 +66,24 @@ class PlaceholderEntry(ttk.Entry):
self._ph_text: str = placeholder self._ph_text: str = placeholder
self.bind("<FocusIn>", self._focus_in) self.bind("<FocusIn>", self._focus_in)
self.bind("<FocusOut>", self._focus_out) self.bind("<FocusOut>", self._focus_out)
self._ph: bool = False if isinstance(self, ttk.Combobox):
self._focus_out(None) # only bind this for comboboxes
self.bind("<<ComboboxSelected>>", self._combobox_select)
# start with a placeholder
self._ph: bool = True
super().config(foreground=self._ph_color, show='')
super().insert(0, self._ph_text)
def _focus_in(self, event): def _focus_in(self, event: tk.Event[PlaceholderEntry]) -> None:
""" """
On focus in, if we've had a placeholder, clear the box and set normal text colour and show. On focus in, if we've had a placeholder, clear the box and set normal text colour and show.
""" """
if self._ph: if self._ph:
self._ph = False self._ph = False
self.delete(0, "end") self.delete(0, "end")
self.config(foreground=self._text_color, show=self._show) super().config(foreground=self._text_color, show=self._show)
def _focus_out(self, event): def _focus_out(self, event: tk.Event[PlaceholderEntry]) -> None:
""" """
On focus out, if we're empty, insert a placeholder, On focus out, if we're empty, insert a placeholder,
set placeholder text color and make sure it's shown. set placeholder text color and make sure it's shown.
@@ -81,40 +91,142 @@ class PlaceholderEntry(ttk.Entry):
""" """
if not super().get(): if not super().get():
self._ph = True self._ph = True
self.config(foreground=self._ph_color, show='') super().config(foreground=self._ph_color, show='')
self.insert(0, self._ph_text) super().insert(0, self._ph_text)
def _store_option(self, options: dict[str, Any], name: str, attr: str): def _combobox_select(self, event: tk.Event[PlaceholderEntry]):
# combobox clears and inserts the selected value internally, bypassing the insert method
# disable the placeholder flag and set the color here, so _focus_in doesn't clear the entry
self._ph = False
super().config(foreground=self._text_color, show=self._show)
def _store_option(
self, options: dict[str, object], name: str, attr: str, *, remove: bool = False
) -> None:
if name in options: if name in options:
setattr(self, attr, options[name]) if remove:
value = options.pop(name)
else:
value = options[name]
setattr(self, attr, value)
def configure(self, *args, **kwargs): def configure(self, *args: Any, **kwargs: Any) -> Any:
if args: options = {}
options = args[0] if args and args[0] is not None:
options.update(args[0])
if kwargs: if kwargs:
options = kwargs options.update(kwargs)
self._store_option(options, "show", "_show") self._store_option(options, "show", "_show")
self._store_option(options, "placeholder", "_ph_text") self._store_option(options, "placeholder", "_ph_text", remove=True)
self._store_option(options, "foreground", "_text_color") self._store_option(options, "foreground", "_text_color")
self._store_option(options, "placeholdercolor", "_ph_color") self._store_option(options, "placeholdercolor", "_ph_color", remove=True)
super().configure(*args, *kwargs) return super().configure(*args, **kwargs)
def get(self): def config(self, *args: Any, **kwargs: Any) -> Any:
# because 'config = configure' makes mypy complain
self.configure(*args, **kwargs)
def get(self) -> str:
if self._ph: if self._ph:
return '' return ''
return super().get() return super().get()
def clear(self): def insert(self, index: tk._EntryIndex, content: str) -> None:
# when inserting into the entry externally, disable the placeholder flag
if self._ph:
self._ph = False
self.delete(0, "end")
super().config(foreground=self._text_color, show=self._show)
return super().insert(index, content)
def clear(self) -> None:
self.delete(0, "end") self.delete(0, "end")
self._ph = True self._ph = True
self.config(foreground=self._ph_color, show='') super().config(foreground=self._ph_color, show='')
self.insert(0, self._ph_text) super().insert(0, self._ph_text)
def enable(self):
super().configure(state="normal")
def disable(self): class PlaceholderCombobox(PlaceholderEntry, ttk.Combobox):
super().configure(state="disabled") pass
class PaddedListbox(tk.Listbox):
def __init__(self, master: ttk.Widget, *args, padding: tk._Padding = (0, 0, 0, 0), **kwargs):
# we place the listbox inside a frame with the same background
# this means we need to forward the 'grid' method to the frame, not the listbox
self._frame = tk.Frame(master)
self._frame.rowconfigure(0, weight=1)
self._frame.columnconfigure(0, weight=1)
super().__init__(self._frame)
# mimic default listbox style with sunken relief and borderwidth of 1
if "relief" not in kwargs:
kwargs["relief"] = "sunken"
if "borderwidth" not in kwargs:
kwargs["borderwidth"] = 1
self.configure(*args, padding=padding, **kwargs)
def grid(self, *args, **kwargs):
return self._frame.grid(*args, **kwargs)
def grid_remove(self) -> None:
return self._frame.grid_remove()
def grid_info(self) -> tk._GridInfo:
return self._frame.grid_info()
def grid_forget(self) -> None:
return self._frame.grid_forget()
def configure(self, *args: Any, **kwargs: Any) -> Any:
options = {}
if args and args[0] is not None:
options.update(args[0])
if kwargs:
options.update(kwargs)
# NOTE on processed options:
# • relief is applied to the frame only
# • background is copied, so that both listbox and frame change color
# • borderwidth is applied to the frame only
# bg is folded into background for easier processing
if "bg" in options:
options["background"] = options.pop("bg")
frame_options = {}
if "relief" in options:
frame_options["relief"] = options.pop("relief")
if "background" in options:
frame_options["background"] = options["background"] # copy
if "borderwidth" in options:
frame_options["borderwidth"] = options.pop("borderwidth")
self._frame.configure(frame_options)
# update padding
if "padding" in options:
padding: tk._Padding = options.pop("padding")
padx1: tk._ScreenUnits
padx2: tk._ScreenUnits
pady1: tk._ScreenUnits
pady2: tk._ScreenUnits
if not isinstance(padding, tuple) or len(padding) == 1:
if isinstance(padding, tuple):
padding = padding[0]
padx1 = padx2 = pady1 = pady2 = padding
elif len(padding) == 2:
padx1 = padx2 = padding[0]
pady1 = pady2 = padding[1] # type: ignore
elif len(padding) == 3:
padx1, padx2 = padding[0:2] # type: ignore
pady1 = pady2 = padding[2] # type: ignore
else:
padx1, padx2, pady1, pady2 = padding # type: ignore
super().grid(column=0, row=0, padx=(padx1, padx2), pady=(pady1, pady2), sticky="nsew")
else:
super().grid(column=0, row=0, sticky="nsew")
# listbox uses flat relief to blend in with the inside of the frame
options["relief"] = "flat"
return super().configure(options)
def config(self, *args: Any, **kwargs: Any) -> Any:
# because 'config = configure' makes mypy complain
self.configure(*args, **kwargs)
class _WSEntry(TypedDict): class _WSEntry(TypedDict):
@@ -124,8 +236,8 @@ class _WSEntry(TypedDict):
class WebsocketStatus: class WebsocketStatus:
def __init__(self, manager: GUIManager, master: ttk.Widget): def __init__(self, manager: GUIManager, master: ttk.Widget):
self._status_var = StringVar() self._status_var = StringVar(master)
self._topics_var = StringVar() self._topics_var = StringVar(master)
frame = ttk.LabelFrame(master, text="Websocket Status", padding=(4, 0, 4, 4)) frame = ttk.LabelFrame(master, text="Websocket Status", padding=(4, 0, 4, 4))
frame.grid(column=0, row=0, sticky="nsew", padx=2) frame.grid(column=0, row=0, sticky="nsew", padx=2)
ttk.Label( ttk.Label(
@@ -188,7 +300,7 @@ LoginData = namedtuple("LoginData", ["username", "password", "token"])
class LoginForm: class LoginForm:
def __init__(self, manager: GUIManager, master: ttk.Widget): def __init__(self, manager: GUIManager, master: ttk.Widget):
self._manager = manager self._manager = manager
self._var = StringVar() self._var = StringVar(master)
frame = ttk.LabelFrame(master, text="Login Form", padding=(4, 0, 4, 4)) frame = ttk.LabelFrame(master, text="Login Form", padding=(4, 0, 4, 4))
frame.grid(column=1, row=0, sticky="nsew", padx=2) frame.grid(column=1, row=0, sticky="nsew", padx=2)
frame.columnconfigure(0, weight=2) frame.columnconfigure(0, weight=2)
@@ -237,40 +349,39 @@ class LoginForm:
class GameSelector: class GameSelector:
def __init__(self, manager: GUIManager, master: ttk.Widget): def __init__(self, manager: GUIManager, master: ttk.Widget):
self._manager = manager self._manager = manager
self._var = StringVar() self._settings = manager._twitch.settings
self._var = StringVar(master)
frame = ttk.LabelFrame(master, text="Game Selector", padding=(4, 0, 4, 4)) frame = ttk.LabelFrame(master, text="Game Selector", padding=(4, 0, 4, 4))
frame.grid(column=1, row=1, sticky="nsew", padx=2) frame.grid(column=1, row=1, sticky="nsew", padx=2)
frame.rowconfigure(0, weight=1)
frame.columnconfigure(0, weight=1) frame.columnconfigure(0, weight=1)
self._list = tk.Listbox( self._list = PaddedListbox(
frame, frame,
height=5, height=5,
selectmode="single", padding=(1, 0),
activestyle="none", activestyle="none",
exportselection=False, selectmode="single",
highlightthickness=0, highlightthickness=0,
exportselection=False,
) )
self._list.pack(fill="both", expand=True) self._list.grid(column=0, row=0, sticky="nsew")
self._selection: str | None = self._manager._twitch.options.game self._selection: str | None = None
self._games: OrderedDict[str, Game] = OrderedDict() self._games: OrderedDict[str, tuple[int, Game]] = OrderedDict()
self._excluded: set[int] = set() self._excluded_indices: set[int] = set()
self._list.bind("<<ListboxSelect>>", self._on_select) self._list.bind("<<ListboxSelect>>", self._on_select)
def set_games(self, games: abc.Iterable[Game]): def set_games(self, games: abc.Iterable[Game]):
self._games.clear() self._games.clear()
self._games.update((str(g), g) for g in games) self._games.update((str(game), (i, game)) for i, game in enumerate(games))
self._list.delete(0, "end") self._list.delete(0, "end")
self._list.insert("end", *self._games.keys()) self._list.insert("end", *self._games.keys())
self._list.config(width=0) # autoadjust listbox width self._list.config(width=0) # autoadjust listbox width
# process excluded games and relink the selection # gray-out excluded games and relink the selection
self._excluded.clear() self._excluded_indices.clear()
self._exclude_sync()
selected_index: int | None = None selected_index: int | None = None
exclude = self._manager._twitch.options.exclude if self._selection is not None and self._selection in self._games:
for i, game_name in enumerate(self._list.get(0, "end")): selected_index = self._games[self._selection][0]
if game_name in exclude:
self._excluded.add(i)
self._list.itemconfig(i, foreground="gray60")
elif game_name == self._selection:
selected_index = i
self._list.selection_clear(0, "end") self._list.selection_clear(0, "end")
if selected_index is not None: if selected_index is not None:
# reselect the currently selected item # reselect the currently selected item
@@ -279,13 +390,38 @@ class GameSelector:
# the game we've had selected isn't there anymore - clear selection # the game we've had selected isn't there anymore - clear selection
self._selection = None self._selection = None
def _on_select(self, event): def _exclude_update(self, game_name: str, *, index: int | None = None) -> None:
# called from the settings tab or _update below
if index is None:
try:
index = self._games[game_name][0]
except KeyError:
# user added an excluded game that isn't on the list - just return
return
if (
game_name in self._settings.exclude
or self._settings.priority_only
and game_name not in self._settings.priority
):
if index not in self._excluded_indices:
self._excluded_indices.add(index)
self._list.itemconfig(index, foreground="gray60")
elif index in self._excluded_indices:
self._excluded_indices.discard(index)
self._list.itemconfig(index, foreground='')
def _exclude_sync(self) -> None:
# called when priority_only state changes
for game_name, (i, _) in self._games.items():
self._exclude_update(game_name, index=i)
def _on_select(self, event: tk.Event[PaddedListbox]) -> None:
current: tuple[int, ...] = self._list.curselection() current: tuple[int, ...] = self._list.curselection()
if not current: if not current:
# can happen when the user clicks on an empty list # can happen when the user clicks on an empty list
return return
idx: int = current[0] idx: int = current[0]
if idx in self._excluded: if idx in self._excluded_indices:
# user clicked on an excluded game - reselect the previous one if possible # user clicked on an excluded game - reselect the previous one if possible
self._list.selection_clear(0, "end") self._list.selection_clear(0, "end")
if self._selection is not None: if self._selection is not None:
@@ -301,19 +437,27 @@ class GameSelector:
self._selection = new_selection self._selection = new_selection
self._manager._twitch.change_state(State.GAME_SELECT) self._manager._twitch.change_state(State.GAME_SELECT)
def get_selection(self) -> Game | None: def get_next(self) -> Game | None:
if self._selection is None: if self._selection is not None:
return None return self._games[self._selection][1]
return self._games[self._selection] # select and return the first prioritized game from the list
def set_first(self) -> Game | None:
# select and return the first non-excluded game from the list
self._list.selection_clear(0, "end") self._list.selection_clear(0, "end")
for game_name in self._settings.priority:
if game_name in self._games:
# it's a prioritized game - easy choice
idx, game = self._games[game_name]
self._selection = game_name
self._list.selection_set(idx)
return game
# if priority_only is enabled, we end here
if self._settings.priority_only:
return None
# with priorities out of the way, select and return the first non-excluded game instead
for i, game_name in enumerate(self._list.get(0, "end")): for i, game_name in enumerate(self._list.get(0, "end")):
if i not in self._excluded: if i not in self._excluded_indices:
self._selection = game_name self._selection = game_name
self._list.selection_set(i) self._list.selection_set(i)
return self._games[game_name] return self._games[game_name][1]
return None return None
@@ -343,16 +487,16 @@ class CampaignProgress:
self._manager = manager self._manager = manager
self._vars: _ProgressVars = { self._vars: _ProgressVars = {
"campaign": { "campaign": {
"name": StringVar(), # campaign name "name": StringVar(master), # campaign name
"progress": DoubleVar(), # controls the progress bar "progress": DoubleVar(master), # controls the progress bar
"percentage": StringVar(), # percentage display string "percentage": StringVar(master), # percentage display string
"remaining": StringVar(), # time remaining string "remaining": StringVar(master), # time remaining string
}, },
"drop": { "drop": {
"rewards": StringVar(), # drop rewards "rewards": StringVar(master), # drop rewards
"progress": DoubleVar(), # as above "progress": DoubleVar(master), # as above
"percentage": StringVar(), # as above "percentage": StringVar(master), # as above
"remaining": StringVar(), # as above "remaining": StringVar(master), # as above
}, },
} }
self._frame = frame = ttk.LabelFrame( self._frame = frame = ttk.LabelFrame(
@@ -459,8 +603,7 @@ class CampaignProgress:
f"{campaign.progress:6.1%} ({campaign.claimed_drops}/{campaign.total_drops})" f"{campaign.progress:6.1%} ({campaign.claimed_drops}/{campaign.total_drops})"
) )
# tray # tray
tray = self._manager.tray self._manager.tray.display_progress(drop)
tray.display_progress(drop)
self.stop_timer() self.stop_timer()
if countdown: if countdown:
# restart our seconds update timer # restart our seconds update timer
@@ -748,7 +891,7 @@ class TrayIcon:
self.icon: pystray.Icon | None = None self.icon: pystray.Icon | None = None
self._button = ttk.Button(master, command=self.minimize, text="Minimize to Tray") self._button = ttk.Button(master, command=self.minimize, text="Minimize to Tray")
self._button.grid(column=0, row=0, sticky="ne") self._button.grid(column=0, row=0, sticky="ne")
if manager._twitch.options.tray: if manager._twitch.settings.tray:
# start hidden in tray # start hidden in tray
self._manager._root.after_idle(self.minimize) self._manager._root.after_idle(self.minimize)
@@ -823,24 +966,10 @@ class TrayIcon:
class Notebook: class Notebook:
def __init__(self, manager: GUIManager, master: ttk.Widget): def __init__(self, manager: GUIManager, master: ttk.Widget):
self._style = manager._style
# removes "Notebook.focus" from the Tab layout tree to avoid ugly dotted line on selection
# we target "Notebook.padding" since "focus" follows it, then fold the layout children
original = self._style.layout("TNotebook.Tab")
layout_list = original
while True:
element, layout = layout_list[0]
layout_list = layout["children"]
if element == "Notebook.padding":
layout["children"] = layout_list[0][1]["children"]
break
self._style.layout("TNotebook.Tab", original)
# Add padding to the tab names
self._style.theme_settings(
self._style.theme_use(), {"TNotebook.Tab": {"configure": {"padding": [8, 4]}}}
)
self._nb = ttk.Notebook(master) self._nb = ttk.Notebook(master)
self._nb.grid(column=0, row=0, sticky="nsew") self._nb.grid(column=0, row=0, sticky="nsew")
# prevent entries from being selected after switching tabs
self._nb.bind("<<NotebookTabChanged>>", lambda event: manager._root.focus_set())
master.rowconfigure(0, weight=1) master.rowconfigure(0, weight=1)
master.columnconfigure(0, weight=1) master.columnconfigure(0, weight=1)
@@ -851,6 +980,240 @@ class Notebook:
self._nb.add(widget, text=name, **kwargs) self._nb.add(widget, text=name, **kwargs)
class _SettingsVars(TypedDict):
tray: IntVar
autostart: IntVar
priority_only: IntVar
class SettingsPanel:
AUTOSTART_NAME: str = "TwitchDropsMiner"
def __init__(self, manager: GUIManager, master: ttk.Widget):
self._settings = manager._twitch.settings
self._game_selector = manager.games
self._vars: _SettingsVars = {
"tray": IntVar(master, self._settings.autostart_tray),
"autostart": IntVar(master, self._settings.autostart),
"priority_only": IntVar(master, self._settings.priority_only),
}
master.rowconfigure(0, weight=1)
master.columnconfigure(0, weight=1)
# use a frame to center the content within the tab
center_frame = ttk.Frame(master)
center_frame.grid(column=0, row=0)
# General section
general_frame = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="General")
general_frame.grid(column=0, row=0, sticky="nsew")
# use another frame to center the options within the section
# NOTE: this can be adjusted or removed later on if more options were to be added
general_frame.rowconfigure(0, weight=1)
general_frame.columnconfigure(0, weight=1)
center_frame2 = ttk.Frame(general_frame)
center_frame2.grid(column=0, row=0)
ttk.Label(center_frame2, text="Autostart: ").grid(column=0, row=0, sticky="e")
ttk.Checkbutton(
center_frame2, variable=self._vars["autostart"], command=self.update_autostart
).grid(column=1, row=0)
ttk.Label(center_frame2, text="Autostart into tray: ").grid(column=0, row=1, sticky="e")
ttk.Checkbutton(
center_frame2, variable=self._vars["tray"], command=self.update_autostart
).grid(column=1, row=1)
ttk.Label(center_frame2, text="Priority only: ").grid(column=0, row=2, sticky="e")
ttk.Checkbutton(
center_frame2, variable=self._vars["priority_only"], command=self.priority_only
).grid(column=1, row=2)
# Priority section
priority_frame = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="Priority")
priority_frame.grid(column=1, row=0, sticky="nsew")
self._priority_entry = PlaceholderCombobox(
priority_frame, placeholder="Game name", width=30
)
self._priority_entry.grid(column=0, row=0, sticky="ew")
priority_frame.columnconfigure(0, weight=1)
ttk.Button(
priority_frame, text="+", command=self.priority_add, width=2, style="Large.TButton"
).grid(column=1, row=0)
self._priority_list = PaddedListbox(
priority_frame,
height=10,
padding=(1, 0),
activestyle="none",
selectmode="single",
highlightthickness=0,
exportselection=False,
)
self._priority_list.grid(column=0, row=1, rowspan=3, sticky="nsew")
self._priority_list.insert("end", *self._settings.priority)
ttk.Button(
priority_frame,
width=2,
text="▲",
style="Large.TButton",
command=partial(self.priority_move, True),
).grid(column=1, row=1, sticky="ns")
priority_frame.rowconfigure(1, weight=1)
ttk.Button(
priority_frame,
width=2,
text="▼",
style="Large.TButton",
command=partial(self.priority_move, False),
).grid(column=1, row=2, sticky="ns")
priority_frame.rowconfigure(2, weight=1)
ttk.Button(
priority_frame, text="❌", command=self.priority_delete, width=2, style="Large.TButton"
).grid(column=1, row=3, sticky="ns")
priority_frame.rowconfigure(3, weight=1)
# Exclude section
exclude_frame = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="Exclude")
exclude_frame.grid(column=2, row=0, sticky="nsew")
self._exclude_entry = PlaceholderCombobox(exclude_frame, placeholder="Game name", width=26)
self._exclude_entry.grid(column=0, row=0, sticky="ew")
ttk.Button(
exclude_frame, text="+", command=self.exclude_add, width=2, style="Large.TButton"
).grid(column=1, row=0)
self._exclude_list = PaddedListbox(
exclude_frame,
height=10,
padding=(1, 0),
activestyle="none",
selectmode="single",
highlightthickness=0,
exportselection=False,
)
self._exclude_list.grid(column=0, row=1, columnspan=2, sticky="nsew")
exclude_frame.rowconfigure(1, weight=1)
# insert them alphabetically
self._exclude_list.insert("end", *sorted(self._settings.exclude))
ttk.Button(
exclude_frame, text="❌", command=self.exclude_delete, width=2, style="Large.TButton"
).grid(column=0, row=2, columnspan=2, sticky="ew")
self.priority_only() # update exclude section state
def clear_selection(self) -> None:
self._priority_list.selection_clear(0, "end")
self._exclude_list.selection_clear(0, "end")
def update_autostart(self) -> None:
enabled = bool(self._vars["autostart"].get())
tray = bool(self._vars["tray"].get())
self._settings.autostart = enabled
self._settings.autostart_tray = tray
if enabled:
self_path = str(Path(sys.argv[0]).resolve())
if tray:
self_path += " --tray"
with RegistryKey("HKCU/Software/Microsoft/Windows/CurrentVersion/Run") as key:
key.set(self.AUTOSTART_NAME, ValueType.REG_SZ, self_path)
else:
with RegistryKey("HKCU/Software/Microsoft/Windows/CurrentVersion/Run") as key:
key.delete(self.AUTOSTART_NAME, silent=True)
def set_games(self, games: abc.Iterable[Game]) -> None:
games_list = list(map(str, games))
self._exclude_entry.config(values=games_list)
self._priority_entry.config(values=games_list)
def priority_add(self) -> None:
game_name: str = self._priority_entry.get()
if not game_name:
# prevent adding empty strings
return
self._priority_entry.clear()
# add it preventing duplicates
try:
existing_idx: int = self._settings.priority.index(game_name)
except ValueError:
# not there, add it
self._priority_list.insert("end", game_name)
self._priority_list.see("end")
self._settings.priority.append(game_name)
self._game_selector._exclude_update(game_name)
else:
# already there, set the selection on it
self._priority_list.selection_set(existing_idx)
self._priority_list.see(existing_idx)
def _priority_idx(self) -> int | None:
selection: tuple[int, ...] = self._priority_list.curselection()
if not selection:
return None
return selection[0]
def priority_move(self, up: bool) -> None:
idx: int | None = self._priority_idx()
if idx is None:
return
if up and idx == 0 or not up and idx == self._priority_list.size() - 1:
return
swap_idx: int = idx - 1 if up else idx + 1
item: str = self._priority_list.get(idx)
self._priority_list.delete(idx)
self._priority_list.insert(swap_idx, item)
# reselect the item and scroll the list if needed
self._priority_list.selection_set(swap_idx)
self._priority_list.see(swap_idx)
p = self._settings.priority
p[idx], p[swap_idx] = p[swap_idx], p[idx]
def priority_delete(self) -> None:
idx: int | None = self._priority_idx()
if idx is None:
return
# NOTE: get the item before it's removed from the list
item: str = self._priority_list.get(idx)
self._priority_list.delete(idx)
del self._settings.priority[idx]
self._game_selector._exclude_update(item)
def priority_only(self) -> None:
self._settings.priority_only = bool(self._vars["priority_only"].get())
self._game_selector._exclude_sync()
def exclude_add(self) -> None:
game_name: str = self._exclude_entry.get()
if not game_name:
# prevent adding empty strings
return
self._exclude_entry.clear()
e = self._settings.exclude
if game_name not in e:
e.add(game_name)
# insert it alphabetically
for i, item in enumerate(self._exclude_list.get(0, "end")):
if game_name < item:
self._exclude_list.insert(i, game_name)
self._exclude_list.see(i)
break
else:
self._exclude_list.insert("end", game_name)
self._exclude_list.see("end")
self._game_selector._exclude_update(game_name)
else:
# it was already there, select it
for i, item in enumerate(self._exclude_list.get(0, "end")):
if item == game_name:
existing_idx = i
break
else:
# something went horribly wrong and it's not there after all - just return
return
self._exclude_list.selection_set(existing_idx)
self._exclude_list.see(existing_idx)
def exclude_delete(self) -> None:
selection: tuple[int, ...] = self._exclude_list.curselection()
if not selection:
return None
idx: int = selection[0]
item: str = self._exclude_list.get(idx)
if item in self._settings.exclude:
self._settings.exclude.discard(item)
self._exclude_list.delete(idx)
self._game_selector._exclude_update(item)
class GUIManager: class GUIManager:
def __init__(self, twitch: Twitch): def __init__(self, twitch: Twitch):
self._twitch: Twitch = twitch self._twitch: Twitch = twitch
@@ -862,14 +1225,36 @@ class GUIManager:
root.resizable(False, True) root.resizable(False, True)
root.iconbitmap(resource_path("pickaxe.ico")) # window icon root.iconbitmap(resource_path("pickaxe.ico")) # window icon
root.title(WINDOW_TITLE) # window title root.title(WINDOW_TITLE) # window title
root.protocol("WM_DELETE_WINDOW", self.close) root.protocol("WM_DELETE_WINDOW", self.close) # hook the X window closing button
root.bind_all("<KeyPress-Escape>", self.unfocus) root.bind_all("<KeyPress-Escape>", self.unfocus) # pressing ESC unfocuses selection
# style adjustements
self._style = ttk.Style(root) self._style = ttk.Style(root)
# fix treeview's background color from tags not working (also see '_fixed_map')
self._style.map( self._style.map(
"Treeview", "Treeview",
foreground=self._fixed_map("foreground"), foreground=self._fixed_map("foreground"),
background=self._fixed_map("background"), background=self._fixed_map("background"),
) )
# remove Notebook.focus from the Notebook.Tab layout tree to avoid an ugly dotted line
# on tab selection. We fold the Notebook.focus children into Notebook.padding children.
original = self._style.layout("TNotebook.Tab")
sublayout = original[0][1]["children"][0][1]
sublayout["children"] = sublayout["children"][0][1]["children"]
self._style.layout("TNotebook.Tab", original)
# add padding to the tab names
self._style.configure("TNotebook.Tab", padding=[8, 4])
# remove Checkbutton.focus dotted line from checkbuttons
self._style.configure("TCheckbutton", padding=0)
original = self._style.layout("TCheckbutton")
sublayout = original[0][1]["children"]
sublayout[1] = sublayout[1][1]["children"][0]
del original[0][1]["children"][1]
self._style.layout("TCheckbutton", original)
# adds a style with a larger font for buttons
self._style.configure("Large.TButton", font=LARGE_FONT)
# end of style changes
root_frame = ttk.Frame(root, padding=8) root_frame = ttk.Frame(root, padding=8)
root_frame.grid(column=0, row=0, sticky="nsew") root_frame.grid(column=0, row=0, sticky="nsew")
root.rowconfigure(0, weight=1) root.rowconfigure(0, weight=1)
@@ -889,15 +1274,8 @@ class GUIManager:
self.channels = ChannelList(self, main_frame) self.channels = ChannelList(self, main_frame)
# Settings tab # Settings tab
settings_frame = ttk.Frame(root_frame, padding=8) settings_frame = ttk.Frame(root_frame, padding=8)
settings_frame.rowconfigure(0, weight=1) self.settings = SettingsPanel(self, settings_frame)
settings_frame.columnconfigure(0, weight=1)
self.tabs.add_tab(settings_frame, name="Settings") self.tabs.add_tab(settings_frame, name="Settings")
ttk.Label(
settings_frame,
font=(..., 20),
anchor="center",
text="Work In Progress",
).grid(column=0, row=0, sticky="nsew")
# clamp minimum window height (update first, so that geometry calculates the size) # clamp minimum window height (update first, so that geometry calculates the size)
root.update_idletasks() root.update_idletasks()
root.minsize(width=0, height=root.winfo_reqheight()) root.minsize(width=0, height=root.winfo_reqheight())
@@ -906,7 +1284,7 @@ class GUIManager:
self._handler.setFormatter(FORMATTER) self._handler.setFormatter(FORMATTER)
logging.getLogger("TwitchDrops").addHandler(self._handler) logging.getLogger("TwitchDrops").addHandler(self._handler)
# show the window when ready # show the window when ready
if not self._twitch.options.tray: if not self._twitch.settings.tray:
self._root.deiconify() self._root.deiconify()
# https://stackoverflow.com/questions/56329342/tkinter-treeview-background-tag-not-working # https://stackoverflow.com/questions/56329342/tkinter-treeview-background-tag-not-working
@@ -980,6 +1358,11 @@ class GUIManager:
# support pressing ESC to unfocus # support pressing ESC to unfocus
self._root.focus_set() self._root.focus_set()
self.channels.clear_selection() self.channels.clear_selection()
self.settings.clear_selection()
def set_games(self, games: abc.Iterable[Game]) -> None:
self.games.set_games(games)
self.settings.set_games(games)
def prevent_close(self): def prevent_close(self):
self._closed.clear() self._closed.clear()
@@ -995,7 +1378,6 @@ class GUIManager:
if __name__ == "__main__": if __name__ == "__main__":
# Everything below is for debug purposes only # Everything below is for debug purposes only
from functools import partial
from types import SimpleNamespace from types import SimpleNamespace
class StrNamespace(SimpleNamespace): class StrNamespace(SimpleNamespace):
@@ -1076,7 +1458,14 @@ if __name__ == "__main__":
async def main(exit_event: asyncio.Event): async def main(exit_event: asyncio.Event):
# Initialize GUI debug # Initialize GUI debug
mock = SimpleNamespace( mock = SimpleNamespace(
options=SimpleNamespace(game=None, tray=False, exclude={"Lit Game"}), channels={} settings=SimpleNamespace(
tray=False,
priority=[],
autostart=False,
priority_only=False,
autostart_tray=False,
exclude={"Lit Game"},
)
) )
mock.change_state = lambda state: mock.gui.print(f"State change: {state.value}") mock.change_state = lambda state: mock.gui.print(f"State change: {state.value}")
mock.state_change = lambda state: partial(mock.change_state, state) mock.state_change = lambda state: partial(mock.change_state, state)
@@ -1088,8 +1477,8 @@ if __name__ == "__main__":
gui._poll_task.add_done_callback(lambda t: exit_event.set()) gui._poll_task.add_done_callback(lambda t: exit_event.set())
# Login form # Login form
gui.login.update("Login required", None) gui.login.update("Login required", None)
# Game selector # Game selector and settings panel games
gui.games.set_games([ gui.set_games([
create_game(420690, "Lit Game"), create_game(420690, "Lit Game"),
create_game(123456, "Best Game"), create_game(123456, "Best Game"),
create_game(654321, "My Game Very Long Name"), create_game(654321, "My Game Very Long Name"),
+24 -54
View File
@@ -9,78 +9,42 @@ import logging
import argparse import argparse
import traceback import traceback
import tkinter as tk import tkinter as tk
from copy import copy
from pathlib import Path from pathlib import Path
from tkinter import messagebox from tkinter import messagebox
from typing import Generic, NoReturn from typing import IO, NoReturn
from utils import _T
from twitch import Twitch from twitch import Twitch
from settings import Settings
from version import __version__ from version import __version__
from exceptions import CaptchaRequired from exceptions import CaptchaRequired
from constants import FORMATTER, LOG_PATH, WINDOW_TITLE from constants import FORMATTER, LOG_PATH, WINDOW_TITLE
# we need a dummy invisible window for the parser
root = tk.Tk()
root.overrideredirect(True)
root.withdraw()
root.update()
class Parser(argparse.ArgumentParser): class Parser(argparse.ArgumentParser):
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._message: io.StringIO = io.StringIO() self._message: io.StringIO = io.StringIO()
def _print_message(self, message: str, *args, **kwargs) -> None: def _print_message(self, message: str, file: IO[str] | None = None) -> None:
self._message.write(message) self._message.write(message)
# print(message, file=self._message) # print(message, file=self._message)
def exit(self, *args, **kwargs) -> NoReturn: def exit(self, status: int = 0, message: str | None = None) -> NoReturn:
try: try:
super().exit(*args, **kwargs) super().exit(status, message)
finally: finally:
messagebox.showerror("Argument Parser Error", self._message.getvalue()) messagebox.showerror("Argument Parser Error", self._message.getvalue())
class SetCollectAction(argparse.Action, Generic[_T]):
def __init__(
self,
option_strings,
dest,
*,
const: _T | None = None,
nargs: int | str | None = None,
default: set[_T] | None = None,
**kwargs,
) -> None:
if nargs is not None and nargs in ('?', '*') or isinstance(nargs, int) and nargs <= 0:
raise ValueError("'nargs' has to be '+' or an integer greater than zero")
if default is None:
default = set()
elif not isinstance(default, set):
raise TypeError("'default' has to be of 'set' type")
super().__init__(option_strings, dest, nargs, const, default, **kwargs)
def __call__(self, parser, namespace, values, option_string=None):
items: set[_T] = getattr(namespace, self.dest, self.default)
items = copy(items)
for value in values:
items.add(value)
setattr(namespace, self.dest, items)
class ParsedArgs(argparse.Namespace): class ParsedArgs(argparse.Namespace):
_verbose: int _verbose: int
_debug_ws: bool _debug_ws: bool
_debug_gql: bool _debug_gql: bool
log: bool log: bool
tray: bool tray: bool
game: str | None
exclude: set[str]
no_run_check: bool no_run_check: bool
# TODO: replace int with union of literal values once typeshed updates
@property @property
def logging_level(self) -> int: def logging_level(self) -> int:
return { return {
@@ -113,8 +77,12 @@ class ParsedArgs(argparse.Namespace):
# handle input parameters # handle input parameters
# NOTE: due to using pythonw to run the main script, CLI help via '-h' and generally any # NOTE: parser output is shown via message box
# console output is not available. The input arguments still work though. # we also need a dummy invisible window for the parser
root = tk.Tk()
root.overrideredirect(True)
root.withdraw()
root.update()
parser = Parser( parser = Parser(
Path(sys.argv[0]).name, Path(sys.argv[0]).name,
description="A program that allows you to mine timed drops on Twitch.", description="A program that allows you to mine timed drops on Twitch.",
@@ -123,43 +91,45 @@ parser.add_argument("--version", action="version", version=f"v{__version__}")
parser.add_argument("-v", dest="_verbose", action="count", default=0) parser.add_argument("-v", dest="_verbose", action="count", default=0)
parser.add_argument("--tray", action="store_true") parser.add_argument("--tray", action="store_true")
parser.add_argument("--log", action="store_true") parser.add_argument("--log", action="store_true")
parser.add_argument("-g", "--game", default=None)
parser.add_argument("--exclude", action=SetCollectAction, nargs='+', metavar="GAME")
# undocumented debug args # undocumented debug args
parser.add_argument( parser.add_argument(
"--no-run-check", dest="no_run_check", action="store_true", help=argparse.SUPPRESS "--no-run-check", dest="no_run_check", action="store_true", help=argparse.SUPPRESS
) )
parser.add_argument("--debug-ws", dest="_debug_ws", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--debug-ws", dest="_debug_ws", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--debug-gql", dest="_debug_gql", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--debug-gql", dest="_debug_gql", action="store_true", help=argparse.SUPPRESS)
options: ParsedArgs = parser.parse_args(namespace=ParsedArgs()) args = parser.parse_args(namespace=ParsedArgs())
# dummy window isn't needed anymore # dummy window isn't needed anymore
root.destroy() root.destroy()
# load settings
settings = Settings(args)
# check if we're not already running # check if we're not already running
try: try:
exists = ctypes.windll.user32.FindWindowW(None, WINDOW_TITLE) exists = ctypes.windll.user32.FindWindowW(None, WINDOW_TITLE)
except AttributeError: except AttributeError:
# we're not on Windows - continue # we're not on Windows - continue
exists = False exists = False
if exists and not options.no_run_check: if exists and not settings.no_run_check:
# already running - exit # already running - exit
sys.exit() sys.exit()
# handle logging stuff # handle logging stuff
if options.logging_level > logging.DEBUG: if settings.logging_level > logging.DEBUG:
# redirect the root logger into a NullHandler, effectively ignoring all logging calls # redirect the root logger into a NullHandler, effectively ignoring all logging calls
# that aren't ours. This always runs, unless the main logging level is DEBUG or lower. # that aren't ours. This always runs, unless the main logging level is DEBUG or lower.
logging.getLogger().addHandler(logging.NullHandler()) logging.getLogger().addHandler(logging.NullHandler())
logger = logging.getLogger("TwitchDrops") logger = logging.getLogger("TwitchDrops")
logger.setLevel(options.logging_level) logger.setLevel(settings.logging_level)
if options.log: if settings.log:
handler = logging.FileHandler(LOG_PATH) handler = logging.FileHandler(LOG_PATH)
handler.setFormatter(FORMATTER) handler.setFormatter(FORMATTER)
logger.addHandler(handler) logger.addHandler(handler)
logging.getLogger("TwitchDrops.gql").setLevel(options.debug_gql) logging.getLogger("TwitchDrops.gql").setLevel(settings.debug_gql)
logging.getLogger("TwitchDrops.websocket").setLevel(options.debug_ws) logging.getLogger("TwitchDrops.websocket").setLevel(settings.debug_ws)
# client run # client run
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
client = Twitch(options) client = Twitch(settings)
signal.signal(signal.SIGINT, lambda *_: client.close()) signal.signal(signal.SIGINT, lambda *_: client.close())
signal.signal(signal.SIGTERM, lambda *_: client.close()) signal.signal(signal.SIGTERM, lambda *_: client.close())
try: try:
+1 -6
View File
@@ -4,10 +4,6 @@
Available command line arguments: Available command line arguments:
• -g <name>; --game <name>
On application start, begin mining immedately for the `<name>` game. Case sensitive.
• --exclude <name> [<name> ...]
Exclude the `<name>` game(s) from being mined. Case sensitive.
• --tray • --tray
Start application as minimised into tray. Start application as minimised into tray.
• -v • -v
@@ -19,5 +15,4 @@ Available command line arguments:
• --version • --version
Show application version information Show application version information
Note: Arguments containing spaces (such as game names) need to be encased in double quotes. Note: Additional settings are available within the application GUI.
For example: "Game Name".
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import winreg as reg
from typing import Any
from enum import Enum, Flag
from collections.abc import Generator
class RegistryError(Exception):
pass
class ValueNotExists(RegistryError):
pass
class Access(Flag):
KEY_READ = reg.KEY_READ
KEY_WRITE = reg.KEY_WRITE
KEY_NOTIFY = reg.KEY_NOTIFY
KEY_EXECUTE = reg.KEY_EXECUTE
KEY_SET_VALUE = reg.KEY_SET_VALUE
KEY_ALL_ACCESS = reg.KEY_ALL_ACCESS
KEY_CREATE_LINK = reg.KEY_CREATE_LINK
KEY_QUERY_VALUE = reg.KEY_QUERY_VALUE
KEY_CREATE_SUB_KEY = reg.KEY_CREATE_SUB_KEY
KEY_ENUMERATE_SUB_KEYS = reg.KEY_ENUMERATE_SUB_KEYS
class MainKey(Enum):
HKU = reg.HKEY_USERS
HKCR = reg.HKEY_CLASSES_ROOT
HKCU = reg.HKEY_CURRENT_USER
HKLM = reg.HKEY_LOCAL_MACHINE
HKEY_USERS = reg.HKEY_USERS
HKEY_CLASSES_ROOT = reg.HKEY_CLASSES_ROOT
HKEY_CURRENT_USER = reg.HKEY_CURRENT_USER
HKEY_LOCAL_MACHINE = reg.HKEY_LOCAL_MACHINE
HKEY_CURRENT_CONFIG = reg.HKEY_CURRENT_CONFIG
HKEY_PERFORMANCE_DATA = reg.HKEY_PERFORMANCE_DATA
class ValueType(Enum):
REG_SZ = reg.REG_SZ
REG_NONE = reg.REG_NONE
REG_LINK = reg.REG_LINK
REG_DWORD = reg.REG_DWORD
REG_QWORD = reg.REG_QWORD
REG_BINARY = reg.REG_BINARY
REG_MULTI_SZ = reg.REG_MULTI_SZ
REG_EXPAND_SZ = reg.REG_EXPAND_SZ
REG_RESOURCE_LIST = reg.REG_RESOURCE_LIST
REG_DWORD_BIG_ENDIAN = reg.REG_DWORD_BIG_ENDIAN
REG_DWORD_LITTLE_ENDIAN = reg.REG_DWORD_LITTLE_ENDIAN
REG_QWORD_LITTLE_ENDIAN = reg.REG_QWORD_LITTLE_ENDIAN
REG_FULL_RESOURCE_DESCRIPTOR = reg.REG_FULL_RESOURCE_DESCRIPTOR
REG_RESOURCE_REQUIREMENTS_LIST = reg.REG_RESOURCE_REQUIREMENTS_LIST
class RegistryKey:
def __init__(self, path: str):
main_key, _, path = path.replace('/', '\\').partition('\\')
self.main_key = MainKey[main_key]
self.path = path
self._handle = reg.OpenKey(
self.main_key.value, path, access=(Access.KEY_QUERY_VALUE | Access.KEY_SET_VALUE).value
)
def __enter__(self) -> RegistryKey:
return self
def __exit__(self, exc_type, exc, tb):
self._handle.Close()
def get(self, name: str) -> tuple[ValueType, Any]:
try:
value, value_type = reg.QueryValueEx(self._handle, name)
except FileNotFoundError:
# TODO: consider returning None for missing values
raise ValueNotExists(name)
return (ValueType(value_type), value)
def set(self, name: str, value_type: ValueType, value: Any) -> bool:
reg.SetValueEx(self._handle, name, 0, value_type.value, value)
return True # TODO: return False if the set operation fails
def delete(self, name: str, *, silent: bool = False) -> bool:
try:
reg.DeleteValue(self._handle, name)
except FileNotFoundError:
if not silent:
raise ValueNotExists(name)
return False
return True
def values(self) -> Generator[tuple[str, ValueType, Any], None, None]:
len_keys, len_values, last_modified = reg.QueryInfoKey(self._handle)
for i in range(len_values):
try:
name, value, value_type = reg.EnumValue(self._handle, i)
yield name, ValueType(value_type), value
except OSError:
return
if __name__ == "__main__":
with RegistryKey("HKCU/Software/Microsoft/Windows/CurrentVersion/Run") as key:
# key.get("test")
# key.set("test", ValueType.REG_SZ, "test\\path")
for name, value_type, value in key.values():
print(name, value_type, value)
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import json
from enum import Enum
from pathlib import Path
from typing import Any, TypedDict, TYPE_CHECKING
from constants import JsonType, SETTINGS_PATH
if TYPE_CHECKING:
from main import ParsedArgs
PATH = Path(SETTINGS_PATH)
class SettingsFile(TypedDict):
autostart: bool
exclude: set[str]
priority: list[str]
priority_only: bool
autostart_tray: bool
serialize_env: dict[str, type] = {
"set": set,
}
default_settings: SettingsFile = {
"priority": [],
"exclude": set(),
"autostart": False,
"priority_only": True,
"autostart_tray": False,
}
def serialize(obj: Any) -> Any:
if isinstance(obj, (set, Enum)):
if isinstance(obj, set):
d = list(obj)
elif isinstance(obj, Enum):
d = obj.value
return {
"__type": type(obj).__name__,
"data": d,
}
raise TypeError(obj)
def deserialize(obj: JsonType) -> Any:
if "__type" in obj:
t = eval(obj["__type"], None, serialize_env)
return t(obj["data"])
return obj
class Settings:
# from args
log: bool
tray: bool
no_run_check: bool
# args properties
debug_ws: int
debug_gql: int
logging_level: int
# from settings file
autostart: bool
exclude: set[str]
priority: list[str]
priority_only: bool
autostart_tray: bool
def __init__(self, args: ParsedArgs):
self._settings: SettingsFile = default_settings.copy()
if PATH.exists():
with open(PATH, 'r') as file:
self._settings.update(json.load(file, object_hook=deserialize))
self._args: ParsedArgs = args
# default logic of reading settings is to check args first, then the settings file
def __getattr__(self, name: str, /) -> Any:
if hasattr(self._args, name):
return getattr(self._args, name)
elif name in self._settings:
return self._settings[name] # type: ignore[misc]
return getattr(super(), name)
def __setattr__(self, name: str, value: Any, /) -> None:
if name in ("_settings", "_args"):
# passthrough
return super().__setattr__(name, value)
elif name in self._settings:
self._settings[name] = value # type: ignore[misc]
return
raise TypeError(f"{name} is missing a custom setter")
def __delattr__(self, name: str, /) -> None:
raise RuntimeError("settings can't be deleted")
def save(self) -> None:
with open(PATH, 'w') as file:
json.dump(self._settings, file, default=serialize, sort_keys=True, indent=4)
+11 -17
View File
@@ -41,7 +41,7 @@ if TYPE_CHECKING:
from utils import Game from utils import Game
from gui import LoginForm from gui import LoginForm
from main import ParsedArgs from settings import Settings
from inventory import TimedDrop from inventory import TimedDrop
from constants import JsonType, GQLOperation from constants import JsonType, GQLOperation
@@ -57,8 +57,8 @@ def viewers_key(channel: Channel) -> int:
class Twitch: class Twitch:
def __init__(self, options: ParsedArgs): def __init__(self, settings: Settings):
self.options = options self.settings: Settings = settings
# State management # State management
self._state: State = State.IDLE self._state: State = State.IDLE
self._state_change = asyncio.Event() self._state_change = asyncio.Event()
@@ -99,12 +99,14 @@ class Twitch:
if self._watching_task is not None: if self._watching_task is not None:
self._watching_task.cancel() self._watching_task.cancel()
self._watching_task = None self._watching_task = None
# close session and stop websocket # close session, save cookies and stop websocket
if self._session is not None: if self._session is not None:
self._session.cookie_jar.save(COOKIES_PATH) # type: ignore self._session.cookie_jar.save(COOKIES_PATH) # type: ignore
await self._session.close() await self._session.close()
self._session = None self._session = None
await self.websocket.stop() await self.websocket.stop()
# save settings
self.settings.save()
# wait at least one full second + whatever it takes to complete the closing # wait at least one full second + whatever it takes to complete the closing
# this allows aiohttp to safely close the session # this allows aiohttp to safely close the session
await asyncio.sleep(start_time + 0.5 - time()) await asyncio.sleep(start_time + 0.5 - time())
@@ -175,7 +177,6 @@ class Twitch:
WebsocketTopic("User", "Drops", self._user_id, self.process_drops), WebsocketTopic("User", "Drops", self._user_id, self.process_drops),
WebsocketTopic("User", "CommunityPoints", self._user_id, self.process_points), WebsocketTopic("User", "CommunityPoints", self._user_id, self.process_points),
]) ])
first_select: bool = True
full_cleanup: bool = False full_cleanup: bool = False
channels: Final[OrderedDict[int, Channel]] = self.channels channels: Final[OrderedDict[int, Channel]] = self.channels
self.change_state(State.INVENTORY_FETCH) self.change_state(State.INVENTORY_FETCH)
@@ -211,21 +212,14 @@ class Twitch:
return return
# only start the websocket after we confirm there are drops to mine # only start the websocket after we confirm there are drops to mine
await self.websocket.start() await self.websocket.start()
self.gui.games.set_games(games) self.gui.set_games(games)
self.change_state(State.GAME_SELECT) self.change_state(State.GAME_SELECT)
elif self._state is State.GAME_SELECT: elif self._state is State.GAME_SELECT:
new_game: Game | None = self.gui.games.get_selection() new_game: Game | None = self.gui.games.get_next()
if new_game is None:
if first_select:
# on first select, let the user make the choice
first_select = False
else:
new_game = self.gui.games.set_first()
if new_game is not None:
# restart the watch loop immediately on new game selected
self.restart_watching()
# signal channel cleanup that we're removing everything
if new_game != self.game: if new_game != self.game:
if new_game is not None:
# restart the watch loop immediately on new game selected
self.restart_watching()
self.game = new_game self.game = new_game
full_cleanup = True full_cleanup = True
self.change_state(State.CHANNELS_CLEANUP) self.change_state(State.CHANNELS_CLEANUP)