castero/perspective.py

Summary

Maintainability
A
0 mins
Test Coverage
B
81%
import curses
from abc import ABC, abstractmethod

import cjkwrap

from castero.config import Config
from castero.menu import Menu


class Perspective(ABC):
    """Extendable class for display "screens".

    This class is extended by perspectives -- classes which offer methods to
    handle display elements with a certain layout. Perspectives only control
    the "inside" elements of the display: the header and footer are controlled
    by the display class.

    The user is able to switch between perspectives by using the 0-9 keys
    corresponding to the perspective's ID.

    Instances of this class do not generally hold data variables, e.g. the
    instance of the Database class. We instead reference the variables held in
    the Display instance.
    """

    ID = -1

    @abstractmethod
    def __init__(self, display):
        """
        This method does not automatically create configure some of its
        necessary elements, i.e. the windows. Instead, for some methods it is
        expected that the Display instance will call identically-named methods
        for all of its "children" perspectives when "chain" methods are called.

        For example, the Display class has a _create_windows method, which
        should call this class's create_windows method.

        :param display the parent Display instance
        """
        self._display = display

    @abstractmethod
    def create_windows(self) -> None:
        """Create and set basic parameters for the windows."""

    @abstractmethod
    def create_menus(self) -> None:
        """Create the menus used in each window.

        Windows which have menus should be created prior to running this method
        (using _create_windows).
        """

    @abstractmethod
    def display(self) -> None:
        """Draws all windows and sub-features, including titles and borders."""

    @abstractmethod
    def display_all(self) -> None:
        """Force all windows to completely redraw their content.

        The normal display() method, which is used in the core UI loop, does
        not always redraw the contents of sub-features on each iteration. For
        example, it often does not draw menu contents because they only
        typically need to be drawn when the user moves within them.

        However, if the screen is cleared, the contents of these sub-features
        must be forcefully redrawn. That is what this method provides.
        """

    @abstractmethod
    def refresh(self) -> None:
        """Refresh the screen and all windows."""

    @abstractmethod
    def handle_input(self, c) -> bool:
        """Performs action corresponding to the user's input.

        :param c the input character
        :returns bool: whether or not the application should continue running
        """

    @abstractmethod
    def made_active(self) -> None:
        """Called each time the perspective is made active (switched to)."""

    @abstractmethod
    def update_menus(self) -> None:
        """Update/refresh the contents of all menus."""

    @abstractmethod
    def _invert_selected_menu(self) -> None:
        """Inverts the contents of the selected menu."""

    @abstractmethod
    def _get_active_menu(self) -> Menu:
        """Retrieve the active Menu, if there is one.

        :returns Menu: the active Menu, or None
        """

    def _generic_handle_input(self, c) -> bool:
        """Generic handler for performing actions corresponding to input.

        :param c the input character
        :returns bool: whether or not the application should continue running
        """
        queue = self._display.queue
        key_mapping = self._display.KEY_MAPPING

        keep_running = True
        if c == key_mapping[Config["key_exit"]]:
            self.update_current_episode_progress()
            self._display.terminate()
            keep_running = False
        elif c == key_mapping[Config["key_help"]]:
            self._display.show_help()
        elif c == key_mapping[Config["key_right"]]:
            self._change_active_window(1)
            self._metadata_updated = False
        elif c == key_mapping[Config["key_left"]]:
            self._change_active_window(-1)
            self._metadata_updated = False
        elif c == key_mapping[Config["key_up"]]:
            self._get_active_menu().move(1)
            self._metadata_updated = False
        elif c == key_mapping[Config["key_down"]]:
            self._get_active_menu().move(-1)
            self._metadata_updated = False
        elif c == key_mapping[Config["key_scroll_up"]]:
            self._get_active_menu().move_page(1)
            self._metadata_updated = False
        elif c == key_mapping[Config["key_scroll_down"]]:
            self._get_active_menu().move_page(-1)
            self._metadata_updated = False
        elif c == key_mapping[Config["key_clear"]]:
            self.update_current_episode_progress()
            queue.stop()
            queue.clear()
        elif c == key_mapping[Config["key_pause_play"]] or c == key_mapping[Config["key_pause_play_alt"]]:
            self.update_current_episode_progress()
            queue.toggle()
        elif c == key_mapping[Config["key_next"]]:
            self.update_current_episode_progress()
            queue.stop()
            queue.next()
            queue.play()
        elif (
            c == key_mapping[Config["key_seek_forward"]] or c == key_mapping[Config["key_seek_forward_alt"]]
        ):
            queue.seek(1)
            self.update_current_episode_progress()
        elif (
            c == key_mapping[Config["key_seek_backward"]]
            or c == key_mapping[Config["key_seek_backward_alt"]]
        ):
            queue.seek(-1)
            self.update_current_episode_progress()
        elif c == key_mapping[Config["key_volume_increase"]]:
            queue.change_volume(1)
        elif c == key_mapping[Config["key_volume_decrease"]]:
            queue.change_volume(-1)
        elif c == key_mapping[Config["key_rate_increase"]]:
            queue.change_rate(1, display=self._display)
        elif c == key_mapping[Config["key_rate_decrease"]]:
            queue.change_rate(-1, display=self._display)
        elif c == key_mapping[Config["key_add_feed"]]:
            self._display.add_feed()
        elif c == key_mapping[Config["key_remove"]]:
            if self._active_window == 0:
                self._display.delete_feed(self._feed_menu.item)
                self.update_menus()
        elif c == key_mapping[Config["key_reload"]]:
            self._display.reload_feeds()
        elif c == key_mapping[Config["key_reload_selected"]]:
            feed = self._feed_menu.item
            if feed is not None:
                self._display.reload_selected_feed(feed)
        elif c == key_mapping[Config["key_show_url"]]:
            if self._active_window == 1 and self._episode_menu.item:
                self._display.show_episode_url(self._episode_menu.item)
        elif c == key_mapping[Config["key_save"]]:
            if self._active_window == 0 and self._feed_menu.item:
                self._display.save_episodes(feed=self._feed_menu.item)
            elif self._active_window == 1 and self._episode_menu.item:
                self._display.save_episodes(episode=self._episode_menu.item)
        elif c == key_mapping[Config["key_delete"]]:
            if self._active_window == 0 and self._feed_menu.item:
                self._display.delete_episodes(feed=self._feed_menu.item)
            elif self._active_window == 1 and self._episode_menu.item:
                self._display.delete_episodes(episode=self._episode_menu.item)
        elif c == key_mapping[Config["key_execute"]]:
            if self._active_window == 1 and self._episode_menu.item:
                self._display.execute_command(self._episode_menu.item)
        elif c == key_mapping[Config["key_invert"]]:
            self._invert_selected_menu()
        elif c == key_mapping[Config["key_filter"]]:
            menu = self._get_active_menu()
            if menu.filter_text:
                menu.filter_text = ""
            else:
                self._display.filter_menu(menu)
            self.update_menus()
            menu.move(-1)
            menu.move(1)
        elif c == key_mapping[Config["key_mark_played"]]:
            if self._active_window == 0:
                feed = self._feed_menu.item
                if feed is not None:
                    episodes = self._display.database.episodes(feed)
                    for episode in episodes:
                        episode.played = not episode.played
                        self._clear_episode_progress(episode)
                    self._display.modified_episodes.extend(episodes)
            elif self._active_window == 1:
                episode = self._episode_menu.item
                if episode is not None:
                    episode.played = not episode.played
                    self._display.modified_episodes.append(episode)
                    self._episode_menu.move(-1)
                    self._clear_episode_progress(episode)

        return keep_running

    def _change_active_window(self, direction) -> None:
        """Changes _active_window to the next or previous window, if available.

        :param direction 1 to change to the next window, -1 to change to the
          previous (if it exists)
        """
        assert direction == 1 or direction == -1

        active_menu = self._get_active_menu()
        if active_menu is not None:
            active_menu.set_active(False)

        self._active_window += direction
        if self._active_window > 1:
            self._active_window = 1
        elif self._active_window < 0:
            self._active_window = 0

        new_active_menu = self._get_active_menu()
        if new_active_menu is not None:
            new_active_menu.set_active(True)

    def _draw_metadata(self, window) -> None:
        """Draws the metadata of the selected feed/episode onto the window.

        :param window the curses window which will display the metadata
        """
        assert window is not None

        max_lines = window.getmaxyx()[0] - 4
        max_line_width = window.getmaxyx()[1] - 1

        # clear the window by drawing blank lines
        for y in range(2, window.getmaxyx()[0] - 2):
            window.addstr(y, 0, " " * max_line_width)

        metadata = self._get_active_menu().metadata
        temp_lines = metadata.split("\n")

        lines = []
        for line in temp_lines:
            parts = cjkwrap.wrap(line, window.getmaxyx()[1] - 1, replace_whitespace=True)
            if len(parts) == 0:
                lines.append("")
            else:
                for part in parts:
                    lines.append(part.strip())

        y = 2
        for line in lines[:max_lines]:
            attr = curses.color_pair(1)
            if line.startswith("!cb"):
                attr |= curses.A_BOLD
                line = line[3:]

            window.addstr(y, 0, line, attr)
            y += 1

    def update_current_episode_progress(self) -> None:
        """Update progress of the first player in queue"""
        (episode, progress) = self._display.queue.get_episode_progress()
        if episode is not None and progress is not None:
            episode.progress = progress
            self._display.database.replace_progress(episode, progress)

    def _clear_episode_progress(self, episode) -> None:
        """remove progress of the episode

        :param episode the episode to clear progress from
        """
        self._display.database.delete_progress(episode)