- Python Programming Blueprints
- Daniel Furtado Marcus Pennington
- 1400字
- 2021-06-24 18:53:50
Implementing the menu panel
Now, we are going to implement the menu panel, which will be the container class that will accommodate all the menu items, handle events, and perform rendering on the terminal screen.
Before we start with the implementation of the menu panel, let's add an enumeration that will represent different item alignment options, so we can have a bit more flexibility on how to display the menu items inside the menu.
Create a file called alignment.py in the musicterminal/client directory with the following contents:
from enum import Enum, auto
class Alignment(Enum):
LEFT = auto()
RIGHT = auto()
You should be an enumeration expert if you followed the code in the first chapter. There's nothing as complicated here; we define a class Alignment inheriting from Enum and define two attributes, LEFT and RIGHT, both with their values set to auto(), which means that the values will be set automatically for us and they will be 1 and 2, respectively.
Now, we are ready to create the menu. Let's go ahead and create a final class called menu.py in the musicterminal/client directory.
Let's add some imports and the constructor:
import curses
import curses.panel
from .alignment import Alignment
from .panel import Panel
class Menu(Panel):
def __init__(self, title, dimensions, align=Alignment.LEFT,
items=[]):
super().__init__(title, dimensions)
self._align = align
self.items = items
The Menu class inherits from the Panel base class that we just created, and the class initializer gets a few arguments: the title, the dimensions (tuple with height, width, y and x values) the alignment setting which is LEFT by default, and the items. The items argument is a list of MenuItems objects. This is optional and it will be set to an empty list if no value is specified.
The first thing we do in the class initializer is invoke the __init__ method in the base class. We can do that by using the super function. If you remember, the __init__ method on the Panel class gets two arguments, title and dimension, so we pass it to the base class initializer.
Next, we assign the values for the properties align and items.
We also need a method that returns the currently selected item on the list of menu items:
def get_selected(self):
items = [x for x in self.items if x.selected]
return None if not items else items[0]
This method is very straightforward; the comprehension returns a list of selected items, and it will return None if no items are selected; otherwise, it returns the first item on the list.
Now, we can implement the method that will handle item selection. Let's add another method called _select:
def _select(self, expr):
current = self.get_selected()
index = self.items.index(current)
new_index = expr(index)
if new_index < 0:
return
if new_index > index and new_index >= len(self.items):
return
self.items[index].selected = False
self.items[new_index].selected = True
Here, we start getting the current item selected, and right after that we get the index of the item in the list of menu items using the index method from the array. This is possible because we implemented the __eq__ method in the Panel class.
Then, we get to run the function passed as the argument, expr, passing the value of the currently selected item index.
expr will determine the next current item index. If the new index is less than 0, it means that we reached the top of the menu item's list, so we don't take any action.
If the new index is greater than the current index, and the new index is greater than or equal to the number of menu items on the list, then we have reached the bottom of the list, so no action is required at this point and we can continue selecting the same item.
However, if we haven't reached to top or the bottom of the list, we need to swap the selected items. To do this, we set the selected property on the current item to False and set the selected property of the next item to True.
The _select method is a private method, and it is not intended to be called externally, so we define two methods—next and previous:
def next(self):
self._select(lambda index: index + 1)
def previous(self):
self._select(lambda index: index - 1)
The next method will invoke the _select method and pass a lambda expression that will receive an index and add one to it, and the previous method will do the same thing, but instead of increasing the index by 1, it will subtract it. So, in the _select method when we call:
new_index = expr(index)
We are calling either lambda index: index + 1 or lambda index: index + 1.
Great! Now, we are going to add a method that will be responsible for formatting menu items before we render them on the screen. Create a method called _initialize_items, which is shown as follows:
def _initialize_items(self):
longest_label_item = max(self.items, key=len)
for item in self.items:
if item != longest_label_item:
padding = (len(longest_label_item) - len(item)) * ' '
item.label = (f'{item}{padding}'
if self._align == Alignment.LEFT
else f'{padding}{item}')
if not self.get_selected():
self.items[0].selected = True
First, we get the menu item that has the largest label; we can do that by using the built-in function max and passing the items, and, as the key, another built-in function called len. This will work because we implemented the special method __len__ in the menu item.
After discovering the menu item with the largest label, we loop through the items of the list, adding padding on the LEFT or RIGHT, depending on the alignment options. Finally, if there's no menu item in the list with the selected flag set to True, we select the first item as selected.
We also want to provide a method called init that will initialize the items on the list for us:
def init(self):
self._initialize_items()
We also need to handle keyboard events so we can perform a few actions when the user specifically presses the Up and Down arrow keys, as well as Enter.
First, we need to define a few constants at the top of the file. You can add these constants between the imports and the class definition:
NEW_LINE = 10
CARRIAGE_RETURN = 13
Let's go ahead and include a method called handle_events:
def handle_events(self, key):
if key == curses.KEY_UP:
self.previous()
elif key == curses.KEY_DOWN:
self.next()
elif key == curses.KEY_ENTER or key == NEW_LINE or key ==
CARRIAGE_RETURN:
selected_item = self.get_selected()
return selected_item.action
This method is pretty simple; it gets a key argument, and if the key is equal to curses.KEY_UP, then we call the previous method. If the key is equal to curses.KEY_DOWN, then we call the next method. Now, if the key is ENTER, then we get the selected item and return its action. The action is a function that will execute another function; in our case, we might be selecting an artist or song on a list or executing a function that will play a music track.
In addition to testing whether the key is curses.KEY_ENTER, we also need to check whether the key is a new line \n or a carriage return \r. This is necessary because the code for the Enter key can differ depending on the configuration of the terminal the application is running in.
We are going to implement the __iter__ method, which will make our Menu class behave like an iterable object:
def __iter__(self):
return iter(self.items)
The last method of this class is the update method. This method will do the actual work of rendering the menu items and refreshing the window screen:
def update(self):
pos_x = 2
pos_y = 2
for item in self.items:
self._win.addstr(
pos_y,
pos_x,
item.label,
curses.A_REVERSE if item.selected else
curses.A_NORMAL)
pos_y += 1
self._win.refresh()
First, we set the x and y coordinates to 2, so the menu on this window will start at line 2 and column 2. We loop through the menu items and call the addstr method to print the item on the screen.
The addstr method gets a y position, the x position, the string that will be written on the screen, in our case item.label, and the last argument is the style. If the item is selected, we want to show it highlighted; otherwise, it will display with normal colors. The following screenshot illustrates what the rendered list will look like: