castero/menu.py
import curses
from abc import ABC, abstractmethod, abstractproperty
class Menu(ABC):
"""A navigable menu in the display.
This class is used to display interactable menus. It displays a list of
items to its window and allows the user to cycle through them.
This class does not handle user input -- that is done in the Display class.
Methods in that class simply call appropriate methods here in response to
user input in order to change the state of the menu.
"""
@abstractmethod
def __init__(self, window, source, child=None, active=False) -> None:
"""
:param window the curses.window which this menu is placed on
:param items a 2D array where rows represent indices of the parent menu
:param child (optional, default None) the submenu of this menu
:param active (optional, default False) whether this menu is active
"""
assert window.getmaxyx()[0] >= 3
assert child is None or isinstance(child, Menu)
assert isinstance(active, bool)
self._window = window
self._source = source
self._child = child
self._active = active
self._selected = 0
self._display_start_y = 2
self._top_index = 0
self._inverted = False
self._filter_text = ""
@abstractmethod
def __len__(self) -> int:
"""int: the number of items in the menu"""
@abstractproperty
@property
def _items(self):
"""A list of items in the menu represented as dictionaries.
The dictionary contains the fields 'attr', 'tags', and 'text'.
"""
@abstractproperty
@property
def title(self) -> str:
"""str: the title of the menu to display in the window header"""
@abstractproperty
@property
def item(self):
"""the selected item"""
@abstractproperty
@property
def metadata(self) -> str:
"""str: metadata for the selected item"""
@abstractmethod
def update_items(self, obj) -> None:
"""Called by the parent menu (if we have one) to update our items.
:param obj an object of some type understood by the specific
implementation of this menu
"""
@abstractmethod
def update_child(self) -> None:
"""Implementation-specific method to update our child, if we have one.
This method calls the child's update_items with an
implementation-specific object understood by the child.
"""
@abstractmethod
def invert(self) -> None:
"""Invert the menu order.
Inversion is not just a visual effect -- the contents of the items list
must also be reversed.
"""
self._inverted = not self._inverted
def _pad_text(self, text) -> str:
"""Pads an item string with spaces to be the full length of the menu.
Note that this does not create a string the entire length of the
window, it only makes it as large as the menu. Since the window has a
border, the string will be 1 column shorter than the window.
:param item the item text as a string
:returns str: a string the length of the menu, with the left justified item
"""
max_width = self._window.getmaxyx()[1] - 1
return text.ljust(max_width)[:max_width]
def _draw_item(self, item, position, selected) -> None:
"""Draws an item on the window.
This method applies the appropriate color pair to the item: 4 if the
item is selected and the window is active, 3 if the item is selected
but the window is not active, and 1 otherwise.
:param item the item to draw, which should be a dict including the fields
'attr', 'tags', and 'text' -- see EpisodeMenu for an example
:param position the y-position to draw the item, without accounting
for _display_start_y
:param selected whether the item is selected
"""
tag_str = ""
if len(item["tags"]) > 0:
tag_str = "".join(["[%s]" % tag for tag in item["tags"]]) + " "
text = tag_str + item["text"]
attr = curses.color_pair(1)
if selected:
if self._active:
attr = curses.color_pair(4)
else:
attr = curses.color_pair(3)
else:
attr = attr | item["attr"]
self._window.addstr(self._display_start_y + position, 0, self._pad_text(text), attr)
def _sanitize(self) -> None:
"""Sanitizes _selected and _top_index.
Checks that _selected and _top_index are valid (inside all boundaries),
setting them to appropriate extremes if they are not.
"""
num_my_items = len(self)
# _selected can not be past the displayed items
if self._selected >= self._top_index + self.max_displayed_items:
self._selected = self._top_index + self.max_displayed_items - 1
# _selected cannot be outside range of items
if self._selected < 0:
self._selected = 0
if self._selected > num_my_items - 1:
self._selected = num_my_items - 1
# if there is no next page, then the current page should be as full
# as possible
if self._top_index + self.max_displayed_items > num_my_items:
self._top_index = num_my_items - self.max_displayed_items
# _top_index cannot be outside range of items
if self._top_index > num_my_items - 1:
self._top_index = num_my_items - 1
if self._top_index < 0:
self._top_index = 0
def display(self) -> None:
"""Draw all visible items on this menu to the window.
Visible items are items with an index greater than or equal to
_top_index but less than max_displayed_items greater than _top_index.
That is, all items that can fit on the screen starting from _top_index.
"""
items = self._items
position = 0
for i in range(self._top_index, self._top_index + self.max_displayed_items):
if i <= len(self) - 1:
selected = i == self._selected
self._draw_item(items[i], position, selected)
position += 1
# fill unused rows with blank lines
# avoids an issue with entries not being properly removed when the
# items are updated
for y in range(self._display_start_y + position, self._display_start_y + self.max_displayed_items):
self._window.addstr(y, 0, self._pad_text(""))
def set_active(self, active) -> None:
"""Sets whether this menu is active.
The only effect this method has on this object is whether or not to
display the selected item as yellow or as grayed-out. Any movement
operations could still be run, but doing so would confuse the user
assuming _active has been properly set.
:param active whether this menu is active or not
"""
assert isinstance(active, bool)
self._active = active
self.display()
def move(self, direction) -> None:
"""Change the selected item to an adjacent item.
:param direction 1 to move up, -1 to move down
"""
assert direction == 1 or direction == -1
self._selected -= direction
if self._selected < self._top_index:
# the cursor went above the menu
self._top_index -= 1
elif self._selected >= self._top_index + self.max_displayed_items:
# the cursor went below the menu
self._top_index += 1
self._sanitize()
if self._child is not None:
self.update_child()
self.display()
def move_page(self, direction) -> None:
"""Change the selected item to the next "page".
Effectively the same as moving max_displayed_items times.
We always try to make the menu as full as possible -- if the movement
would leave us with only a few items on the screen, we instead reset
_top_index to make the screen as full as possible. Therefore we can
assume that if there are enough items in the menu to fill the screen,
the menu will *always* fill the screen.
:param direction 1 to move up, -1 to move down
"""
assert direction == 1 or direction == -1
if direction == 1:
self._selected -= self.max_displayed_items
self._top_index -= self.max_displayed_items
elif direction == -1:
self._selected += self.max_displayed_items
self._top_index += self.max_displayed_items
self._sanitize()
if self._child is not None:
self.update_child()
self.display()
def refresh(self) -> None:
"""Refresh the menu, accounting for any changes to the display/window.
Also refreshes this menu's child, it we have one.
"""
self._sanitize()
if self._child is not None:
self._child.refresh()
@property
def max_displayed_items(self) -> int:
return self._window.getmaxyx()[0] - 4
@property
def selected_index(self) -> int:
"""int: the current selected index of the menu"""
return self._selected
@property
def window(self):
"""window: the curses.window which this menu is placed on"""
return self._window
@window.setter
def window(self, window) -> None:
self._window = window
@property
def filter_text(self) -> str:
"""str: the filter applied to the menu"""
return self._filter_text
@filter_text.setter
def filter_text(self, filter_text) -> None:
self._filter_text = filter_text