Skip to content

interface ¤

This module contains all the code responsible for the HTOP-like interface.

Classes:

  • Column

    A class to specify a column in the interface.

  • Exit

    A simple exception to exit the interactive interface.

  • HorizontalScroll

    A wrapper around asciimatics' Screen.print_at and Screen.paint methods.

  • Interface

    The main class responsible for drawing the HTOP-like interface.

  • Key

    A class to represent an input key.

  • Keys

    The actions and their shortcuts keys.

  • Palette

    A simple class to hold palettes getters.

Functions:

Column ¤

Column(
    header: str,
    padding: str,
    get_text: Callable,
    get_sort: Callable,
    get_palette: Callable,
)

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 Python object, to sort between these objects, and to get a color palette based on the text.

Parameters:

  • header (str) –

    The string to display on top.

  • padding (str) –

    How to align the text.

  • get_text (Callable) –

    Function accepting a Download as argument and returning the text to display.

  • get_sort (Callable) –

    Function accepting a Download as argument and returning the attribute used to sort.

  • get_palette (Callable) –

    Function accepting text as argument and returning a palette or a palette identifier.

Source code in src/aria2p/interface.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def __init__(
    self,
    header: str,
    padding: str,
    get_text: Callable,
    get_sort: Callable,
    get_palette: Callable,
) -> None:
    """Initialize the object.

    Parameters:
        header: The string to display on top.
        padding: How to align the text.
        get_text: Function accepting a Download as argument and returning the text to display.
        get_sort: Function accepting a Download as argument and returning the attribute used to sort.
        get_palette: Function accepting text as argument and returning a palette or a palette identifier.
    """
    self.header = header
    self.padding = padding
    self.get_text = get_text
    self.get_sort = get_sort
    self.get_palette = get_palette

Exit ¤

Bases: Exception

A simple exception to exit the interactive interface.

HorizontalScroll ¤

HorizontalScroll(screen: Screen, scroll: int = 0)

A wrapper around asciimatics' Screen.print_at and Screen.paint methods.

It allows scroll the rows horizontally, used when moving left and right: the first N characters will not be printed.

Parameters:

  • screen (Screen) –

    The asciimatics screen object.

  • scroll (int, default: 0 ) –

    Base scroll to use when printing. Will decrease by one with each character skipped.

Methods:

Source code in src/aria2p/interface.py
245
246
247
248
249
250
251
252
253
def __init__(self, screen: Screen, scroll: int = 0) -> None:
    """Initialize the object.

    Parameters:
        screen (Screen): The asciimatics screen object.
        scroll (int): Base scroll to use when printing. Will decrease by one with each character skipped.
    """
    self.screen = screen
    self.scroll = scroll

print_at ¤

print_at(
    text: str, x: int, y: int, palette: list | tuple
) -> int

Wrapper print_at method.

Parameters:

  • 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.

Source code in src/aria2p/interface.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
def print_at(self, text: str, x: int, y: int, palette: list | tuple) -> int:
    """Wrapper print_at method.

    Parameters:
        text: Text to print.
        x: X axis position / column.
        y: Y axis position / row.
        palette: A length-3 tuple or a list of length-3 tuples representing asciimatics palettes.

    Returns:
        The number of characters actually printed.
    """
    if self.scroll == 0:
        if isinstance(palette, list):
            self.screen.paint(text, x, y, colour_map=palette)
        else:
            self.screen.print_at(text, x, y, *palette)
        written = len(text)
    else:
        text_length = len(text)
        if text_length > self.scroll:
            new_text = text[self.scroll :]
            written = len(new_text)
            if isinstance(palette, list):
                new_palette = palette[self.scroll :]
                self.screen.paint(new_text, x, y, colour_map=new_palette)
            else:
                self.screen.print_at(new_text, x, y, *palette)
            self.scroll = 0
        else:
            self.scroll -= text_length
            written = 0
    return written

set_scroll ¤

set_scroll(scroll: int) -> None

Set the scroll value.

Source code in src/aria2p/interface.py
255
256
257
def set_scroll(self, scroll: int) -> None:
    """Set the scroll value."""
    self.scroll = scroll

Interface ¤

Interface(api: API | None = None)

The main class responsible for 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

Parameters:

  • api (API | None, default: None ) –

    An instance of API.

Methods:

  • get_column_at_x

    For an horizontal position X, return the column index.

  • get_data

    Return a list of objects.

  • print_headers

    Print the headers (columns names).

  • print_rows

    Print the rows.

  • process_event

    Process an event.

  • run

    The main drawing loop.

  • set_screen

    Set the screen object, its scroller wrapper, width, height, and columns bounds.

  • sort_data

    Sort data according to interface state.

  • update_data

    Set the interface data and rows contents.

  • update_rows

    Update rows contents according to data and interface state.

Source code in src/aria2p/interface.py
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
def __init__(self, api: API | None = None) -> None:
    """Initialize the object.

    Parameters:
        api: An instance of API.
    """
    if api is None:
        api = API()
    self.api = api

    self.rows = []
    self.data = []
    self.bounds = []
    self.downloads_uris = []
    self.height = 20
    self.width = 80

    # reduce curses' 1 second delay when hitting escape to 25 ms
    os.environ.setdefault("ESCDELAY", "25")

    self.state_mapping: dict[int, Interface.StateConf] = {
        self.State.MAIN: {
            "process_keyboard_event": self.process_keyboard_event_main,
            "process_mouse_event": self.process_mouse_event_main,
            "print_functions": [self.print_table],
        },
        self.State.HELP: {
            "process_keyboard_event": self.process_keyboard_event_help,
            "process_mouse_event": self.process_mouse_event_help,
            "print_functions": [self.print_help],
        },
        self.State.SETUP: {
            "process_keyboard_event": self.process_keyboard_event_setup,
            "process_mouse_event": self.process_mouse_event_setup,
            "print_functions": [],
        },
        self.State.REMOVE_ASK: {
            "process_keyboard_event": self.process_keyboard_event_remove_ask,
            "process_mouse_event": self.process_mouse_event_remove_ask,
            "print_functions": [self.print_remove_ask_column, self.print_table],
        },
        self.State.SELECT_SORT: {
            "process_keyboard_event": self.process_keyboard_event_select_sort,
            "process_mouse_event": self.process_mouse_event_select_sort,
            "print_functions": [self.print_select_sort_column, self.print_table],
        },
        self.State.ADD_DOWNLOADS: {
            "process_keyboard_event": self.process_keyboard_event_add_downloads,
            "process_mouse_event": self.process_mouse_event_add_downloads,
            "print_functions": [self.print_add_downloads, self.print_table],
        },
    }

get_column_at_x ¤

get_column_at_x(x: int) -> int

For an horizontal position X, return the column index.

Source code in src/aria2p/interface.py
1144
1145
1146
1147
1148
1149
def get_column_at_x(self, x: int) -> int:
    """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("clicked outside of boundaries")

get_data ¤

get_data() -> list[Download]

Return a list of objects.

Source code in src/aria2p/interface.py
1168
1169
1170
def get_data(self) -> list[Download]:
    """Return a list of objects."""
    return self.api.get_downloads()

print_headers ¤

print_headers() -> None

Print the headers (columns names).

Source code in src/aria2p/interface.py
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
def print_headers(self) -> None:
    """Print the headers (columns names)."""
    self.scroller.set_scroll(self.x_scroll)
    x, y, c = self.x_offset, self.y_offset, 0

    for column_name in self.columns_order:
        column = self.columns[column_name]
        palette = self.palettes["focused_header"] if c == self.sort else self.palettes["header"]

        if column.padding == "100%":
            header_string = f"{column.header}"
            fill_up = " " * max(0, self.width - x - len(header_string))
            written = self.scroller.print_at(header_string, x, y, palette)
            self.scroller.print_at(fill_up, x + written, y, self.palettes["header"])

        else:
            header_string = f"{column.header:{column.padding}} "
            written = self.scroller.print_at(header_string, x, y, palette)

        x += written
        c += 1

print_rows ¤

print_rows() -> None

Print the rows.

Source code in src/aria2p/interface.py
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
def print_rows(self) -> None:
    """Print the rows."""
    y = self.y_offset + 1
    for row in self.rows[self.row_offset : self.row_offset + self.height]:
        self.scroller.set_scroll(self.x_scroll)
        x = self.x_offset

        for i, column_name in enumerate(self.columns_order):
            column = self.columns[column_name]
            padding = f"<{max(0, self.width - x)}" if column.padding == "100%" else column.padding

            if self.focused == y - self.y_offset - 1 + self.row_offset:
                palette = self.palettes["focused_row"]
            else:
                palette = column.get_palette(row[i])
                if isinstance(palette, str):
                    palette = self.palettes[palette]

            field_string = f"{row[i]:{padding}} "
            written = self.scroller.print_at(field_string, x, y, palette)
            x += written

        y += 1

    for i in range(self.height - y):
        self.screen.print_at(" " * self.width, self.x_offset, y + i, *self.palettes["ui"])

process_event ¤

process_event(event: KeyboardEvent | MouseEvent) -> None

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.

Parameters:

  • event (KeyboardEvent | MouseEvent) –

    The event to process.

Source code in src/aria2p/interface.py
592
593
594
595
596
597
598
599
600
601
602
603
604
605
def process_event(self, event: KeyboardEvent | MouseEvent) -> None:
    """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.

    Parameters:
        event (KeyboardEvent | MouseEvent): The event to process.
    """
    if isinstance(event, KeyboardEvent):
        self.process_keyboard_event(event)

    elif isinstance(event, MouseEvent):
        self.process_mouse_event(event)

run ¤

run() -> bool

The main drawing loop.

Source code in src/aria2p/interface.py
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
def run(self) -> bool:
    """The main drawing loop."""
    try:
        # outer loop to support screen resize
        while True:
            with ManagedScreen() as screen:
                logger.debug(f"Created new screen {screen}")
                self.set_screen(screen)
                self.frame = 0
                # break (and re-enter) when screen has been resized
                while not screen.has_resized():
                    # keep previous sort in memory to know if we have to re-sort the rows
                    # once all events are processed (to avoid useless/redundant sort passes)
                    previous_sort = (self.sort, self.reverse)

                    # we only refresh when explicitly asked for
                    self.refresh = False

                    # process all events before refreshing screen,
                    # otherwise the reactivity is slowed down a lot with fast inputs
                    event = screen.get_event()
                    logger.debug(f"Got event {event}")
                    while event:
                        # avoid crashing the interface if exceptions occur while processing an event
                        try:
                            self.process_event(event)
                        except Exit:
                            logger.debug("Received exit command")
                            return True
                        except Exception as error:  # noqa: BLE001
                            # TODO: display error in status bar
                            logger.exception(error)
                        event = screen.get_event()
                        logger.debug(f"Got event {event}")

                    # time to update data and rows
                    if self.frame == 0:
                        logger.debug("Tick! Updating data and rows")
                        self.update_data()
                        self.update_rows()
                        self.refresh = True

                    # time to refresh the screen
                    if self.refresh:
                        logger.debug("Refresh! Printing text")
                        # sort if needed, unless it was just done at frame 0 when updating
                        if (self.sort, self.reverse) != previous_sort and self.frame != 0:
                            self.sort_data()
                            self.update_rows()

                        # actual printing and screen refresh
                        for print_function in self.state_mapping[self.state]["print_functions"]:
                            print_function()
                        screen.refresh()

                    # sleep and increment frame
                    time.sleep(self.sleep)
                    self.frame = (self.frame + 1) % self.frames
                logger.debug("Screen has resized")
                self.post_resize()
    except Exception as error:  # noqa: BLE001
        logger.exception(error)
        return False

set_screen ¤

set_screen(screen: Screen) -> None

Set the screen object, its scroller wrapper, width, height, and columns bounds.

Source code in src/aria2p/interface.py
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
def set_screen(self, screen: Screen) -> None:
    """Set the screen object, its scroller wrapper, width, height, and columns bounds."""
    self.screen = screen
    self.height, self.width = screen.dimensions
    self.scroller = HorizontalScroll(screen)
    self.bounds = []
    for column_name in self.columns_order:
        column = self.columns[column_name]
        if column.padding == "100%":  # last column
            self.bounds.append((self.bounds[-1][1] + 1, self.width))
        else:
            padding = int(column.padding.lstrip("<>=^"))
            if not self.bounds:
                self.bounds = [(0, padding)]
            else:
                self.bounds.append((self.bounds[-1][1] + 1, self.bounds[-1][1] + 1 + padding))

sort_data ¤

sort_data() -> None

Sort data according to interface state.

Source code in src/aria2p/interface.py
1180
1181
1182
1183
def sort_data(self) -> None:
    """Sort data according to interface state."""
    sort_function = self.columns[self.columns_order[self.sort]].get_sort
    self.data = sorted(self.data, key=sort_function, reverse=self.reverse)

update_data ¤

update_data() -> None

Set the interface data and rows contents.

Source code in src/aria2p/interface.py
1172
1173
1174
1175
1176
1177
1178
def update_data(self) -> None:
    """Set the interface data and rows contents."""
    try:
        self.data = self.get_data()
        self.sort_data()
    except requests.exceptions.Timeout:
        logger.debug("Request timeout")

update_rows ¤

update_rows() -> None

Update rows contents according to data and interface state.

Source code in src/aria2p/interface.py
1185
1186
1187
1188
1189
1190
1191
def update_rows(self) -> None:
    """Update rows contents according to data and interface state."""
    text_getters = [self.columns[c].get_text for c in self.columns_order]
    n_columns = len(self.columns_order)
    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)

Key ¤

Key(name: str, value: int | None = None)

A class to represent an input key.

Parameters:

  • name (str) –

    The key name.

  • value (int | None, default: None ) –

    The key value.

Source code in src/aria2p/interface.py
132
133
134
135
136
137
138
139
140
141
142
def __init__(self, name: str, value: int | None = None) -> None:
    """Initialize the object.

    Parameters:
        name: The key name.
        value: The key value.
    """
    self.name = name
    if value is None:
        value = self.get_value(name)
    self.value = value

Keys ¤

The actions and their shortcuts keys.

Palette ¤

A simple class to hold palettes getters.

Methods:

  • name

    Return the palette for a NAME cell.

  • status

    Return the palette for a STATUS cell.

name staticmethod ¤

name(value: str) -> str | list[tuple[int, int, int]]

Return the palette for a NAME cell.

Source code in src/aria2p/interface.py
302
303
304
305
306
307
308
309
310
311
@staticmethod
def name(value: str) -> str | list[tuple[int, int, int]]:
    """Return the palette for a NAME cell."""
    if value.startswith("[METADATA]"):
        return (
            [(Screen.COLOUR_GREEN, Screen.A_UNDERLINE, Screen.COLOUR_BLACK)] * 10
            + [Interface.palettes["metadata"]] * (len(value.strip()) - 10)
            + [Interface.palettes["row"]]
        )
    return "name"

status staticmethod ¤

status(value: str) -> str

Return the palette for a STATUS cell.

Source code in src/aria2p/interface.py
297
298
299
300
@staticmethod
def status(value: str) -> str:
    """Return the palette for a STATUS cell."""
    return "status_" + value

color_palette_parser ¤

color_palette_parser(palette: str) -> tuple[int, int, int]

Return a color tuple (foreground color, mode, background color).

Parameters:

  • palette (str) –

    The palette name.

Returns:

Source code in src/aria2p/interface.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def color_palette_parser(palette: str) -> tuple[int, int, int]:
    """Return a color tuple (foreground color, mode, background color).

    Parameters:
        palette: The palette name.

    Returns:
        Foreground color, mode, background color.
    """
    default_colors = configs["DEFAULT"]["colors"]
    colors = configs.get("USER", {}).get("colors", default_colors)

    # get values of colors and modes for ascimatics.screen module
    color_map = {
        "BLACK": Screen.COLOUR_BLACK,
        "WHITE": Screen.COLOUR_WHITE,
        "RED": Screen.COLOUR_RED,
        "CYAN": Screen.COLOUR_CYAN,
        "YELLOW": Screen.COLOUR_YELLOW,
        "BLUE": Screen.COLOUR_BLUE,
        "GREEN": Screen.COLOUR_GREEN,
        "DEFAULT": Screen.COLOUR_DEFAULT,
    }
    mode_map = {
        "NORMAL": Screen.A_NORMAL,
        "BOLD": Screen.A_BOLD,
        "UNDERLINE": Screen.A_UNDERLINE,
        "REVERSE": Screen.A_REVERSE,
    }

    palette_colors = colors.get(palette, default_colors[palette])
    palette_fg, palette_mode, palette_bg = palette_colors.split(" ")

    return (
        color_map[palette_fg],
        mode_map[palette_mode],
        color_map[palette_bg],
    )

key_bind_parser ¤

key_bind_parser(action: str) -> list[Key]

Return a list of Key instances.

Parameters:

  • action (str) –

    The action name.

Returns:

Source code in src/aria2p/interface.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def key_bind_parser(action: str) -> list[Key]:
    """Return a list of Key instances.

    Parameters:
        action: The action name.

    Returns:
        A list of keys.
    """
    default_bindings = configs["DEFAULT"]["key_bindings"]
    bindings = configs.get("USER", {}).get("key_bindings", default_bindings)

    key_binds = bindings.get(action, default_bindings[action])

    if isinstance(key_binds, list):
        return [Key(k) for k in key_binds]
    return [Key(key_binds)]