summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2022-05-26 22:52:46 +0200
committerDaniel Schadt <kingdread@gmx.de>2022-05-26 22:53:12 +0200
commitbd865c10efe828c48e71e1c77478f0da6fdabc54 (patch)
treef32b260138186ff003f7856337a00949a881cbe9
parent4f549de2e9b38bf81be891446cd3e2c296c9b92c (diff)
downloadtf2sgu-bd865c10efe828c48e71e1c77478f0da6fdabc54.tar.gz
tf2sgu-bd865c10efe828c48e71e1c77478f0da6fdabc54.tar.bz2
tf2sgu-bd865c10efe828c48e71e1c77478f0da6fdabc54.zip
first version with the curses TUI
-rwxr-xr-xtf2sgu251
1 files changed, 249 insertions, 2 deletions
diff --git a/tf2sgu b/tf2sgu
index fac02c8..452b957 100755
--- a/tf2sgu
+++ b/tf2sgu
@@ -22,6 +22,7 @@ logger = logging.getLogger("tf2sgu")
class SaveGame(namedtuple("SaveGame", "name date base")):
"""Represents a savegame on disk."""
+
def __str__(self):
return f"{self.name} - {self.date}"
@@ -39,6 +40,7 @@ class SaveGame(namedtuple("SaveGame", "name date base")):
class UserException(Exception):
"""Exception that shouldbe displayed to the user."""
+
def __init__(self, msg):
super().__init__()
self.msg = msg
@@ -46,6 +48,7 @@ class UserException(Exception):
class Profile:
"""An on-disk Steam profile."""
+
def __init__(self, path):
self.path = path
@@ -144,7 +147,9 @@ def select_profile(args):
def cmd_tui(args):
"""Load the user interface."""
- raise NotImplementedError()
+ import curses
+
+ curses.wrapper(lambda screen: _curses_tui(args, screen))
def cmd_list_profiles(_args):
@@ -245,12 +250,254 @@ def get_parser():
def main():
"""Main entry point."""
- logging.basicConfig(level=logging.DEBUG)
parser = get_parser()
args = parser.parse_args()
args.func(args)
+###########################################################################
+# Behold, below this line sleep the demons that are known as curses TUIs. #
+# Tread carefully. #
+###########################################################################
+
+
+class TuiListView:
+ """A list-view like ncurses window."""
+
+ PAIR_HL = 1
+
+ def __init__(self, scr, items):
+ self.scr = scr
+ self.items = items
+ self.selection = 0
+ self.top = 0
+ self.is_active = False
+
+ def set_items(self, items):
+ self.items = items
+ self.selection = 0
+ self.top = 0
+
+ def set_window(self, window):
+ self.scr = window
+
+ def activate(self):
+ self.is_active = True
+
+ def deactivate(self):
+ self.is_active = False
+
+ def render(self):
+ import curses
+
+ self.scr.border()
+ height, width = self.scr.getmaxyx()
+ for row, item in enumerate(self.items[self.top : self.top + height - 2]):
+ attr = 0
+ if self.is_active and row + self.top == self.selection:
+ attr = curses.color_pair(self.PAIR_HL)
+ str_item = f"{item!s:{width - 2}}"
+ self.scr.addnstr(row + 1, 1, str_item, width - 2, attr)
+ self.scr.refresh()
+
+ def up(self):
+ if self.selection == 0:
+ return
+ self.selection -= 1
+ if self.selection < self.top:
+ self.top -= 1
+
+ def down(self):
+ if self.selection == len(self.items) - 1:
+ return
+ self.selection += 1
+ height, _ = self.scr.getmaxyx()
+ if self.selection >= self.top + height - 2:
+ self.top += 1
+
+ def selected_item(self):
+ return self.items[self.selection]
+
+
+_TUI_ERROR_HL = 2
+
+
+def _tui_error(screen, msg):
+ import curses
+
+ height, width = screen.getmaxyx()
+ wanted_width = width - 4
+ wanted_height = 10
+ window = screen.derwin(
+ wanted_height,
+ wanted_width,
+ int((height - wanted_height) / 2),
+ int((width - wanted_width) / 2),
+ )
+ window.attron(curses.color_pair(_TUI_ERROR_HL))
+ window.bkgd(" ", curses.color_pair(_TUI_ERROR_HL))
+ window.border()
+
+ title = "ERROR"
+ window.addstr(0, int((wanted_width - len(title)) / 2), title)
+ window.addstr(1, 1, msg)
+ window.addstr(wanted_height - 2, 1, "Press <Enter> to exit.")
+
+ while True:
+ window.refresh()
+ c = screen.getch()
+ print(c)
+ if c in {10, curses.KEY_ENTER}:
+ break
+
+
+def _tui_select_profile(screen, profiles):
+ import curses
+
+ _tui_title(screen, "Select your Steam profile")
+ screen.addstr(2, 0, "<Up> and <Down> to select")
+ screen.addstr(3, 0, "<Enter> to confirm")
+ screen.addstr(4, 0, "<q> to quit")
+
+ height, width = screen.getmaxyx()
+
+ names = [profile.user_id for profile in profiles]
+ view = TuiListView(screen.derwin(height - 5, width, 5, 0), names)
+ view.activate()
+ view.render()
+
+ while True:
+ c = screen.getch()
+
+ if c in {ord("Q"), ord("q")}:
+ break
+ elif c == curses.KEY_UP:
+ view.up()
+ elif c == curses.KEY_DOWN:
+ view.down()
+ elif c in {10, curses.KEY_ENTER}:
+ return profiles[view.selection]
+
+
+def _tui(args, screen, profile):
+ import curses
+
+ def init_layout():
+ _tui_title(screen, "Copy savegames")
+ height, width = screen.getmaxyx()
+
+ screen.addstr(2, 0, "<Up> and <Down> to select")
+ screen.addstr(3, 0, "<Tab> to switch panel")
+ screen.addstr(4, 0, "<Enter> to copy")
+ screen.addstr(5, 0, "<r> to refresh")
+ screen.addstr(6, 0, "<q> to quit")
+
+ screen.addstr(9, 0, "Local Savegames", curses.A_BOLD)
+ screen.addstr(9, int(width / 2), "Cloud Savegames", curses.A_BOLD)
+
+ wins = [
+ screen.derwin(height - 10, int(width / 2), 10, 0),
+ screen.derwin(height - 10, int(width / 2), 10, int(width / 2)),
+ ]
+ return wins
+
+ windows = init_layout()
+
+ views = [
+ TuiListView(windows[0], []),
+ TuiListView(windows[1], []),
+ ]
+ active = 0
+ views[active].activate()
+
+ def reload():
+ local_saves = profile.saves()
+ remote_saves = read_saves(args.remote_dir)
+ views[0].set_items(local_saves)
+ views[1].set_items(remote_saves)
+
+ reload()
+
+ while True:
+ for view in views:
+ view.render()
+
+ c = screen.getch()
+
+ if c in {ord("Q"), ord("q")}:
+ break
+
+ elif c == curses.KEY_UP:
+ views[active].up()
+
+ elif c == curses.KEY_DOWN:
+ views[active].down()
+
+ elif c == ord("\t"):
+ views[active].deactivate()
+ active = 1 - active
+ views[active].activate()
+
+ elif c in {ord("R"), ord("r")}:
+ reload()
+
+ elif c in {10, curses.KEY_ENTER}:
+ try:
+ item = views[active].selected_item()
+ except IndexError:
+ pass
+ else:
+ if active == 0:
+ dest = args.remote_dir
+ else:
+ dest = profile.save_dir
+ item.copy_to(dest)
+ reload()
+ screen.addstr(8, 0, f"{item.name} copied!")
+
+ elif c == curses.KEY_RESIZE:
+ screen.clear()
+ windows = init_layout()
+ for view, window in zip(views, windows):
+ view.set_window(window)
+
+
+def _tui_title(screen, title):
+ import curses
+
+ _, width = screen.getmaxyx()
+ title = f"{title!s:{width}}"
+ screen.addnstr(0, 0, title, width, curses.A_REVERSE)
+
+
+def _curses_tui(args, screen):
+ import curses
+
+ curses.use_default_colors()
+ curses.init_pair(TuiListView.PAIR_HL, curses.COLOR_BLUE, curses.COLOR_WHITE)
+ curses.init_pair(_TUI_ERROR_HL, curses.COLOR_WHITE, curses.COLOR_RED)
+ curses.curs_set(0)
+
+ # First, we gotta find the right profile
+ profiles = get_steam_profiles()
+ if args.profile_id is not None:
+ profile = find(profiles, user_id=args.profile_id)
+ if profile is None:
+ _tui_error(screen, f"User profile with ID {args.profile_id} was not found.")
+ return
+ elif not profiles:
+ _tui_error(screen, f"No user profiles found.")
+ return
+ elif len(profiles) == 1:
+ profile = profiles[0]
+ else:
+ profile = _tui_select_profile(screen, profiles)
+
+ screen.clear()
+
+ _tui(args, screen, profile)
+
+
if __name__ == "__main__":
try:
main()