From 2b2c021464ab143015d9f8ba642ff3064840c31f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Sat, 12 Oct 2019 02:32:05 +0200 Subject: [PATCH] wip --- src/aria2p/cli.py | 9 +++ src/aria2p/interface.py | 169 ++++++++++++++++++++++++++++++++-------- 2 files changed, 145 insertions(+), 33 deletions(-) diff --git a/src/aria2p/cli.py b/src/aria2p/cli.py index a96a038..79a5397 100644 --- a/src/aria2p/cli.py +++ b/src/aria2p/cli.py @@ -337,6 +337,15 @@ def subcommand_show(api): # ============ TOP SUBCOMMAND ============ # def subcommand_top(api): + """ + Top subcommand. + + Args: + api (API): the API instance to use. + + Returns: + int: always 0. + """ interface = Interface(api) interface.run() return 0 diff --git a/src/aria2p/interface.py b/src/aria2p/interface.py index 7b591a1..5a14bab 100644 --- a/src/aria2p/interface.py +++ b/src/aria2p/interface.py @@ -1,5 +1,5 @@ """ -This module contains all the top-like interface code. +This module contains all the code responsible for the HTOP-like interface. """ # Why using asciimatics? @@ -14,6 +14,8 @@ # # Well, asciimatics also provides a "top" example, so... +# pylint: disable=invalid-name + import time from collections import defaultdict @@ -25,6 +27,8 @@ class Keys: + """The actions and their shortcuts keys.""" + HELP = [ord("h"), Screen.KEY_F1] SETUP = [Screen.KEY_F2] TOGGLE_RESUME_PAUSE = [ord(" ")] @@ -51,11 +55,29 @@ class Keys: class Exit(Exception): - pass + """A simple exception to exit the interactive interface.""" class Column: + """ + A class to specify a column in the interface. + + It's composed of a header (the string to display on top), a padding (how to align the text), + and three callable functions to get the text from a Download object, to sort between downloads, + and to get a color palette based on the text. + """ + def __init__(self, header, padding, get_text, get_sort, get_palette): + """ + Initialization method. + + Args: + header (str): The string to display on top. + padding (str): How to align the text. + get_text (func): Function accepting a Download as argument and returning the text to display. + get_sort (func): Function accepting a Download as argument and returning the attribute used to sort. + get_palette (func): Function accepting text as argument and returning a palette or a palette identifier. + """ self.header = header self.padding = padding self.get_text = get_text @@ -64,14 +86,41 @@ def __init__(self, header, padding, get_text, get_sort, get_palette): class OffsetPrinter: + """ + A wrapper around asciimatics' Screen.print_at and Screen.paint methods. + + It allows to print the rows with an horizontal offset, used when moving left and right: + the first OFFSET characters will not be printed. + """ + def __init__(self, screen, offset=0): + """ + Initialization method. + + Args: + screen (Screen): The asciimatics screen object. + offset (int): Base offset to use when printing. Will decrease by one with each character skipped. + """ self.screen = screen self.offset = offset def set_offset(self, offset): + """Set the offset.""" self.offset = offset def print_at(self, text, x, y, palette): + """ + Wrapper print_at method. + + Args: + text (str): Text to print. + x (int): X axis position / column. + y (int): Y axis position / row. + palette (list/tuple): A length-3 tuple or a list of length-3 tuples representing asciimatics palettes. + + Returns: + int: The number of characters actually printed. + """ logger.debug(f"Printing following text with offset={self.offset}") logger.debug(text) if self.offset == 0: @@ -101,12 +150,16 @@ def print_at(self, text, x, y, palette): class Palette: + """A simple class to hold palettes getters.""" + @staticmethod def status(value): + """Return the palette for a STATUS cell.""" return "status_" + value @staticmethod def name(value): + """Return the palette for a NAME cell.""" if value.startswith("[METADATA]"): return ( [(Screen.COLOUR_GREEN, Screen.A_UNDERLINE, Screen.COLOUR_BLACK)] * 10 @@ -116,7 +169,30 @@ def name(value): return "name" +# TODO: allow drawing of a separate column on the left, for interactive picking of options, +# for example when removing a download: asking whether to force and/or remove files as well. +# I.e. adding a global horizontal offset. + +# TODO: allow other interfaces to be drawn, like the setup menu or the help menu +# TODO: allow vertical offset to be able to draw chart and stats above the table. + + class Interface: + """ + The main class responsible of drawing the HTOP-like interface. + + It should be instantiated with an API instance, and then ran with its ``run`` method. + + If you want to re-use this class' code to create an HTOP-like interface for another purpose, + simply change these few things: + + - columns, columns_order and palettes attributes + - sort and reverse attributes default values + - get_data method. It should return a list of objects that can be compared by equality (==, __eq__, __hash__) + - __init__ method to accept other arguments + - remove/change the few events with "download" or "self.api" in the process_event method + """ + sleep = 0.005 frames = 200 # 200 * 0.005 seconds == 1 second frame = 0 @@ -129,10 +205,11 @@ class Interface: width = None height = None screen = None + data = [] rows = [] printer = None follow = None - _bounds = [] + bounds = [] palettes = defaultdict(lambda: (Screen.COLOUR_WHITE, Screen.A_NORMAL, Screen.COLOUR_BLACK)) palettes.update( @@ -206,13 +283,18 @@ class Interface: } def __init__(self, api=None): + """ + Initialization method. + + Args: + api (API): An instance of API. + """ if api is None: api = API() self.api = api - self.downloads = [] - self.sorted_downloads = [] def run(self): + """The main drawing loop.""" try: # outer loop to support screen resize while True: @@ -235,16 +317,16 @@ def run(self): self.process_event(event) event = screen.get_event() - # time to update the rows + # time to update data and rows if self.frame == 0: - self.update_rows() + self.update_data() self.refresh = True # time to refresh the screen if self.refresh: # sort if needed, unless it was just done at frame 0 when updating rows if (self.sort, self.reverse) != previous_sort and self.frame != 0: - self.sort_rows() + self.update_rows() # actual printing and screen refresh self.print_headers() @@ -258,6 +340,15 @@ def run(self): pass def process_event(self, event): + """ + Process an event. + + For reactivity purpose, this method should not compute expensive stuff, only change the state of the interface, + changes that will be applied by update_data and update_rows methods. + + Args: + event (KeyboardEvent/MouseEvent): The event to process. + """ if event is None: return @@ -298,20 +389,20 @@ def process_event(self, event): pass # TODO elif event.key_code in Keys.TOGGLE_RESUME_PAUSE: - download = self.sorted_downloads[self.focused] + download = self.data[self.focused] if download.is_active or download.is_waiting: download.pause() elif download.is_paused: download.resume() elif event.key_code in Keys.PRIORITY_UP: - download = self.sorted_downloads[self.focused] + download = self.data[self.focused] if not download.is_active: download.move_up() self.follow = download elif event.key_code in Keys.PRIORITY_DOWN: - download = self.sorted_downloads[self.focused] + download = self.data[self.focused] if not download.is_active: download.move_down() self.follow = download @@ -346,19 +437,19 @@ def process_event(self, event): self.api.autopurge() elif event.key_code in Keys.FOLLOW_ROW: - self.follow = self.sorted_downloads[self.focused] + self.follow = self.data[self.focused] elif event.key_code in Keys.SEARCH: - pass + pass # TODO elif event.key_code in Keys.FILTER: - pass + pass # TODO elif event.key_code in Keys.TOGGLE_SELECT: - pass + pass # TODO elif event.key_code in Keys.UN_SELECT_ALL: - pass + pass # TODO elif event.key_code in Keys.QUIT: raise Exit() @@ -380,6 +471,7 @@ def process_event(self, event): # pass # TODO: expand/collapse def print_headers(self): + """Print the headers (columns names).""" self.printer.set_offset(self.x_offset) x, y, c = 0, 0, 0 @@ -401,6 +493,7 @@ def print_headers(self): c += 1 def print_rows(self): + """Print the rows.""" y = 1 for row in self.rows[self.row_offset : self.row_offset + self.height]: @@ -428,37 +521,47 @@ def print_rows(self): self.screen.print_at(" " * self.width, 0, y + i) def get_column_at_x(self, x): - for i, bound in enumerate(self._bounds): + """For an horizontal position X, return the column index.""" + for i, bound in enumerate(self.bounds): if bound[0] <= x <= bound[1]: return i + raise ValueError def set_screen(self, screen): + """Set the screen object, its printer wrapper, width, height, and columns bounds.""" self.screen = screen self.height, self.width = screen.dimensions self.printer = OffsetPrinter(screen) - self._bounds = [] + self.bounds = [] for column_name in self.columns_order: column = self.columns[column_name] if column.padding == "100%": - self._bounds.append((self._bounds[-1][1] + 1, self.width)) + self.bounds.append((self.bounds[-1][1] + 1, self.width)) else: padding = int(column.padding.lstrip("<>=^")) - if not self._bounds: - self._bounds = [(0, padding)] + if not self.bounds: + self.bounds = [(0, padding)] else: - self._bounds.append((self._bounds[-1][1] + 1, self._bounds[-1][1] + 1 + padding)) + self.bounds.append((self.bounds[-1][1] + 1, self.bounds[-1][1] + 1 + padding)) - def update_rows(self): - self.downloads = self.api.get_downloads() - self.sort_rows() + def get_data(self): + """Return a list of objects.""" + return self.api.get_downloads() - def sort_rows(self): - text_getters = [self.columns[c].get_text for c in self.columns_order] + def update_data(self): + """Set the interface data and rows contents.""" + self.data = self.sort_data(self.get_data()) + self.update_rows() + + def sort_data(self, data): + """Sort data according to interface state.""" sort_function = self.columns[self.columns_order[self.sort]].get_sort + return sorted(data, key=sort_function, reverse=self.reverse) + + def update_rows(self): + """Update rows contents according to interface state.""" + text_getters = [self.columns[c].get_text for c in self.columns_order] n_columns = len(self.columns_order) - self.sorted_downloads = sorted(self.downloads, key=sort_function, reverse=self.reverse) - self.rows = [] - for i, download in enumerate(self.sorted_downloads): - self.rows.append(tuple(text_getters[i](download) for i in range(n_columns))) - if self.follow and self.follow == download: - self.focused = i + self.rows = [tuple(text_getters[i](item) for i in range(n_columns)) for item in self.data] + if self.follow: + self.focused = self.data.index(self.follow)