Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
pawamoy committed Oct 12, 2019
1 parent 5fea7d6 commit 2b2c021
Show file tree
Hide file tree
Showing 2 changed files with 145 additions and 33 deletions.
9 changes: 9 additions & 0 deletions src/aria2p/cli.py
Expand Up @@ -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
Expand Down
169 changes: 136 additions & 33 deletions 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?
Expand All @@ -14,6 +14,8 @@
#
# Well, asciimatics also provides a "top" example, so...

# pylint: disable=invalid-name

import time
from collections import defaultdict

Expand All @@ -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(" ")]
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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

Expand All @@ -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]:

Expand Down Expand Up @@ -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)

0 comments on commit 2b2c021

Please sign in to comment.