diff --git a/.vscode/launch.json b/.vscode/launch.json index 7d1c1f4..9604a53 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -51,7 +51,7 @@ "type": "python", "request": "launch", "program": "main.py", - "args": ["--wrong"], + "args": ["--excludes", "1", "2", "3"], "console": "integratedTerminal", "justMyCode": false }, diff --git a/gui.py b/gui.py index c436ec2..c6babaf 100644 --- a/gui.py +++ b/gui.py @@ -262,6 +262,7 @@ class GameSelector: self._list.pack(fill="both", expand=True) self._selection: Optional[str] = self._manager._twitch.options.game self._games: OrderedDict[str, Game] = OrderedDict() + self._excluded: Set[int] = set() self._list.bind("<>", self._on_select) def set_games(self, games: Iterable[Game]): @@ -270,30 +271,42 @@ class GameSelector: self._list.delete(0, "end") self._list.insert("end", *self._games.keys()) self._list.config(width=0) # autoadjust listbox width - if self._selection is not None: - selected_index: Optional[int] = next( - ( - i - for i, str_game in enumerate(self._games.keys()) - if str_game == self._selection - ), - None, - ) - if selected_index is not None: - # reselect the currently selected item - self._list.selection_clear(0, "end") - self._list.selection_set(selected_index) - else: - # the game we've had selected isn't there anymore - clear selection - self._selection = None + # process excluded games and relink the selection + self._excluded.clear() + selected_index: Optional[int] = None + exclude = self._manager._twitch.options.exclude + for i, game_name in enumerate(self._list.get(0, "end")): + 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") + if selected_index is not None: + # reselect the currently selected item + self._list.selection_set(selected_index) + elif self._selection is not None: + # the game we've had selected isn't there anymore - clear selection + self._selection = None def _on_select(self, event): - current = self._list.curselection() + current: Tuple[int, ...] = self._list.curselection() if not current: # can happen when the user clicks on an empty list - new_selection = None - else: - new_selection = self._list.get(current[0]) + return + idx: int = current[0] + if idx in self._excluded: + # user clicked on an excluded game - reselect the previous one if possible + self._list.selection_clear(0, "end") + if self._selection is not None: + for i, game_name in enumerate(self._list.get(0, "end")): + if game_name == self._selection: + self._list.selection_set(i) + break + else: + self._selection = None + return + new_selection: str = self._list.get(idx) if new_selection != self._selection: self._selection = new_selection self._manager._twitch.change_state(State.GAME_SELECT) @@ -303,15 +316,15 @@ class GameSelector: return None return self._games[self._selection] - def set_first(self) -> Game: - # select and return the first game from the list + def set_first(self) -> Optional[Game]: + # select and return the first non-excluded game from the list self._list.selection_clear(0, "end") - self._list.selection_set(0) - for game_name, first_game in self._games.items(): - # just one iteration to get, set and return the first game - self._selection = game_name - return first_game - raise RuntimeError("No games to select from") + for i, game_name in enumerate(self._list.get(0, "end")): + if i not in self._excluded: + self._selection = game_name + self._list.selection_set(i) + return self._games[game_name] + return None class _BaseVars(TypedDict): @@ -1028,7 +1041,9 @@ if __name__ == "__main__": async def main(exit_event: asyncio.Event): # Initialize GUI debug - mock = SimpleNamespace(options=SimpleNamespace(game=None, tray=False), channels={}) + mock = SimpleNamespace( + options=SimpleNamespace(game=None, tray=False, exclude={"Lit Game"}), channels={} + ) mock.change_state = lambda state: mock.gui.print(f"State change: {state.value}") mock.state_change = lambda state: partial(mock.change_state, state) gui = GUIManager(mock) # type: ignore @@ -1041,19 +1056,21 @@ if __name__ == "__main__": gui.login.update("Login required", None) # Game selector gui.games.set_games([ + create_game(420690, "Lit Game"), create_game(123456, "Best Game"), - # create_game(654321, "My Game Very Long Name"), + create_game(654321, "My Game Very Long Name"), ]) # Channel list gui.channels.display( create_channel( name="Thomus", status=0, game=None, drops=False, viewers=0, points=0, priority=True - ) + ), + add=True, ) channel = create_channel( name="Traitus", status=1, game=None, drops=False, viewers=0, points=0, priority=True ) - gui.channels.display(channel) + gui.channels.display(channel, add=True,) gui.channels.set_watching(channel) gui.channels.display( create_channel( @@ -1064,7 +1081,8 @@ if __name__ == "__main__": viewers=42, points=1234567, priority=False, - ) + ), + add=True, ) gui.channels.display( create_channel( @@ -1075,7 +1093,8 @@ if __name__ == "__main__": viewers=69, points=1234567, priority=False, - ) + ), + add=True, ) gui._root.update() gui.channels.get_selection() diff --git a/main.py b/main.py index d07bf86..bf97ab6 100644 --- a/main.py +++ b/main.py @@ -9,10 +9,12 @@ import logging import argparse import traceback import tkinter as tk +from copy import copy from pathlib import Path from tkinter import messagebox -from typing import Optional, NoReturn +from typing import Optional, Union, Set, NoReturn, Generic +from utils import _T from twitch import Twitch from version import __version__ from exceptions import CaptchaRequired @@ -41,12 +43,40 @@ class Parser(argparse.ArgumentParser): messagebox.showerror("Argument Parser Error", self._message.getvalue()) +class SetCollectAction(argparse.Action, Generic[_T]): + def __init__( + self, + option_strings, + dest, + *, + nargs: Optional[Union[int, str]] = None, + const: Optional[_T] = None, + default: Optional[Set[_T]] = 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): _verbose: int _debug_ws: bool _debug_gql: bool log: bool tray: bool + exclude: Set[str] no_run_check: bool game: Optional[str] @@ -90,9 +120,10 @@ parser = Parser( ) parser.add_argument("--version", action="version", version=f"v{__version__}") parser.add_argument("-v", dest="_verbose", action="count", default=0) -parser.add_argument("-g", "--game", default=None) parser.add_argument("--tray", 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 parser.add_argument( "--no-run-check", dest="no_run_check", action="store_true", help=argparse.SUPPRESS diff --git a/twitch.py b/twitch.py index be1dc74..218d89c 100644 --- a/twitch.py +++ b/twitch.py @@ -180,7 +180,6 @@ class Twitch: WebsocketTopic("User", "CommunityPoints", self._user_id, self.process_points), ]) first_select: bool = True - games: List[Game] = [] full_cleanup: bool = False channels: Final[OrderedDict[int, Channel]] = self.channels self.change_state(State.INVENTORY_FETCH) @@ -193,7 +192,7 @@ class Twitch: self.change_state(State.GAMES_UPDATE) elif self._state is State.GAMES_UPDATE: # Figure out which games to watch, and claim the drops we can - games.clear() + games: List[Game] = [] for game, campaigns in self.inventory.items(): add_game = False for campaign in campaigns: