"""Contains base classes for UI elements that deal with lists of entries, that can be scrolled through using arrow keys.
Best example of such an element is a Menu element - it has menu entries you can scroll through, which execute a callback
when you click on them. """
from copy import copy
from time import sleep
from threading import Event
from entry import Entry
from canvas import Canvas
from helpers import setup_logger
from base_ui import BaseUIElement
from utils import to_be_foreground, clamp_list_index
logger = setup_logger(__name__, "warning")
global_config = {}
# Documentation building process has problems with this import
try:
import ui.config_manager as config_manager
except (ImportError, AttributeError):
pass
else:
cm = config_manager.get_ui_config_manager()
cm.set_path("ui/configs")
try:
global_config = cm.get_global_config()
except OSError as e:
logger.error("Config files not available, running under ReadTheDocs?")
logger.exception(e)
class BaseListUIElement(BaseUIElement):
"""This is a base UI element for list-like UI elements.
This UI element has the ability to go into background. It's usually for the cases when
an UI element can call another UI element, after the second UI element returns,
context has to return to the first UI element - like in nested menus.
This UI element has built-in scrolling of entries - if the entry text is longer
than the screen, once the entry is selected, UI element will scroll through its text."""
contents = []
pointer = 0
start_pointer = 0
in_foreground = False
exit_entry = ["Back", "exit"]
config_key = "base_list_ui"
view_mixin = None
def __init__(self, contents, i, o, name=None, entry_height=1, append_exit=True, exitable=True, scrolling=True,
config=None, keymap=None, override_left=True):
self.exitable = exitable
self.custom_keymap = keymap if keymap else {}
BaseUIElement.__init__(self, i, o, name, input_necessary=True, override_left=override_left)
self.entry_height = entry_height
self.append_exit = append_exit
self.scrolling = {
"enabled": scrolling,
"current_scrollable": False
}
self.scrolling_defaults = {
"current_finished": False,
"current_speed": 1,
"counter": 0,
"pointer": 0
}
self.reset_scrolling()
self.config = config if config is not None else global_config
self.set_view(self.config.get(self.config_key, {}))
self.set_contents(contents)
self.inhibit_refresh = Event()
def set_views_dict(self):
self.views = {
"TextView": TextView,
"EightPtView": EightPtView,
"SixteenPtView": SixteenPtView,
"MainMenuTripletView": MainMenuTripletView,
"PrettyGraphicalView": SixteenPtView, # Not a descriptive name - left for compatibility
"SimpleGraphicalView": EightPtView # Not a descriptive name - left for compatibility
}
if self.view_mixin:
class_name = self.__class__.__name__
for view_name, view_class in self.views.items():
if view_class.use_mixin:
name = "{}-{}".format(view_name, class_name)
logger.debug("Subclassing {} into {}".format(view_name, name))
self.views[view_name] = type(name, (self.view_mixin, view_class), {})
def set_view(self, config):
view = None
self.set_views_dict()
if self.name in config.get("custom_views", {}).keys():
view_config = config["custom_views"][self.name]
if isinstance(view_config, basestring):
if view_config not in self.views:
logger.warning('Unknown view "{}" given for UI element "{}"!'.format(view_config, self.name))
else:
view = self.views[view_config]
elif isinstance(view_config, dict):
raise NotImplementedError
# This is the part where fine-tuning views will be possible,
# once passing args&kwargs is implemented, that is
else:
logger.error(
"Custom view description can only be a string or a dictionary; is {}!".format(type(view_config)))
elif not view and "default" in config:
view_config = config["default"]
if isinstance(view_config, basestring):
if view_config not in self.views:
logger.warning('Unknown view "{}" given for UI element "{}"!'.format(view_config, self.name))
else:
view = self.views[view_config]
elif isinstance(view_config, dict):
raise NotImplementedError # Again, this is for fine-tuning
elif not view:
view = self.get_default_view()
self.view = view(self.o, self)
def get_default_view(self):
"""Decides on the view to use for UI element when config file has
no information on it."""
if "b&w" in self.o.type:
return self.views["SixteenPtView"]
elif "char" in self.o.type:
return self.views["TextView"]
else:
raise ValueError("Unsupported display type: {}".format(repr(self.o.type)))
def before_activate(self):
"""
Hook for child UI elements, meant to be called.
For a start, resets the ``pointer`` to the ``start_pointer``.
"""
self.pointer = self.start_pointer
def to_foreground(self):
""" Is called when UI element's ``activate()`` method is used, sets flags
and performs all the actions so that UI element can display its contents
and receive keypresses. Also, refreshes the screen."""
self.reset_scrolling()
BaseUIElement.to_foreground(self)
def idle_loop(self):
"""Contains code which will be executed in UI element's idle loop.
By default, is just a 0.1 second sleep and a ``scroll()`` call. """
sleep(0.1)
self.scroll()
@property
def is_active(self):
return self.in_background
# Scroll functions - will likely be moved into a mixin or views later on
@to_be_foreground
def scroll(self):
if self.scrolling["enabled"] and not self.scrolling["current_finished"] and self.scrolling["current_scrollable"]:
self.scrolling["counter"] += 1
if self.scrolling["counter"] == 10:
self.scrolling["pointer"] += self.scrolling["current_speed"]
self.scrolling["counter"] = 0
self.refresh()
def reset_scrolling(self):
self.scrolling.update(self.scrolling_defaults)
# Debugging helpers - you can set them as callbacks for keys you don't use
def print_contents(self):
""" A debug method. Useful for hooking up to an input event so that
you can see the representation of current UI element's contents. """
logger.info(self.contents)
# Callbacks for moving up and down in the entry list
@to_be_foreground
def move_down(self):
""" Moves the pointer one entry down, if possible.
|Is typically used as a callback from input event processing thread.
|TODO: support going from bottom to top when pressing "down" with
last entry selected."""
if self.pointer < (len(self.contents) - 1):
logger.debug("moved down")
self.pointer += 1
self.reset_scrolling()
self.refresh()
return True
else:
return False
@to_be_foreground
def page_down(self, counter=None):
""" Scrolls up a full screen of entries, if possible.
If not possible, moves as far as it can."""
if not counter:
counter = self.view.get_entry_count_per_screen()
self.inhibit_refresh.set()
while counter != 0 and self.pointer < (len(self.contents) - 1):
counter -= 1
self.move_down()
self.inhibit_refresh.clear()
self.refresh()
self.reset_scrolling()
return True
@to_be_foreground
def move_up(self):
""" Moves the pointer one entry up, if possible.
|Is typically used as a callback from input event processing thread.
|TODO: support going from top to bottom when pressing "up" with
first entry selected."""
if self.pointer != 0:
logger.debug("moved up")
self.pointer -= 1
self.refresh()
self.reset_scrolling()
return True
else:
return False
@to_be_foreground
def page_up(self, counter=None):
""" Scrolls down a full screen of UI entries, if possible.
If not possible, moves as far as it can."""
if not counter:
counter = self.view.get_entry_count_per_screen()
self.inhibit_refresh.set()
while counter != 0 and self.pointer != 0:
counter -= 1
self.move_up()
self.inhibit_refresh.clear()
self.refresh()
self.reset_scrolling()
return True
@to_be_foreground
def move_to_start(self, counter=None):
""" Goes to the first entry if not already there. """
if self.pointer != 0:
logger.debug("moved to start")
self.pointer = 0
self.refresh()
self.reset_scrolling()
return True
else:
return False
@to_be_foreground
def move_to_end(self):
""" Goes to the last entry if not already there. """
if self.pointer != len(self.contents)-1:
logger.debug("moved to end")
self.pointer = len(self.contents)-1
self.refresh()
self.reset_scrolling()
return True
else:
return False
@to_be_foreground
def select_entry(self):
"""To be overridden by child UI elements. Is executed when ENTER is pressed
in UI element."""
logger.debug("Enter key press detected on {}".format(self.contents[self.pointer]))
@to_be_foreground
def process_right_press(self):
"""To be overridden by child UI elements. Is executed when RIGHT is pressed
in UI element."""
logger.debug("Right key press detected on {}".format(self.contents[self.pointer]))
# Working with the keymap
def generate_keymap(self):
"""Makes the keymap dictionary for the input device."""
return {
"KEY_UP": "move_up",
"KEY_DOWN": "move_down",
"KEY_F3": "page_up",
"KEY_F4": "page_down",
"KEY_HOME": "move_to_start",
"KEY_END": "move_to_end",
"KEY_ENTER": "select_entry",
"KEY_RIGHT": "process_right_press"
}
def set_keymap(self, keymap):
if self.exitable and self._override_left:
keymap["KEY_LEFT"] = "deactivate"
# BaseUIElement.process_contents ignores self.exitable
# and only honors self._override_left
# Let's save it to a temp variable and process the contents!
override_left = self._override_left
self._override_left = False
keymap.update(self.custom_keymap)
BaseUIElement.set_keymap(self, keymap)
# Restoring self._override_left
self._override_left = override_left
def set_contents(self, contents):
"""Sets the UI element contents and triggers pointer recalculation in the view."""
self.validate_contents(contents)
# Copy-ing the contents list is necessary because it can be modified
# by UI elements that are based on this class
self.contents = copy(contents)
self.process_contents()
self.view.fix_pointers_on_contents_update()
def validate_contents(self, contents):
"""A hook to validate contents before they're set. If validation is unsuccessful,
raise exceptions (it's better if exception message contains the faulty entry).
Does not check if the contents are falsey."""
# if not contents:
# raise ValueError("UI element 'contents' argument has to be set to a non-empty list!")
for entry in contents:
if isinstance(entry, Entry):
pass # We got an Entry object, we don't validate those yet
else:
entry_repr = entry[0]
if not isinstance(entry_repr, basestring) and not isinstance(entry_repr, list):
raise Exception("Entry labels can be either strings or lists of strings - {} is neither!".format(entry))
if isinstance(entry_repr, list):
for entry_str in entry_repr:
if not isinstance(entry_str, basestring):
raise Exception("List entries can only contain strings - {} is not a string!".format(entry_str))
def process_contents(self):
"""Processes contents for custom callbacks. Currently, only 'exit' calbacks are supported.
If ``self.append_exit`` is set, it goes through the menu and removes every callback which either is ``self.deactivate`` or is just a string 'exit'.
|Then, it appends a single "Exit" entry at the end of menu contents. It makes dynamically appending entries to menu easier and makes sure there's only one "Exit" callback, at the bottom of the menu."""
if self.append_exit:
# filtering possible duplicate exit entries
for entry in self.contents:
if not isinstance(entry, Entry):
if len(entry) > 1 and entry[1] == 'exit':
self.contents.remove(entry)
self.contents.append(self.exit_entry)
logger.debug("{}: contents processed".format(self.name))
def get_displayed_contents(self):
"""
This function is to be used for views, in case an UI element wants to
display entries differently than they're stored (for example, this is used
in ``NumberedMenu``).
"""
return self.contents
def add_view_wrapper(self, wrapper):
self.view.wrappers.append(wrapper)
@to_be_foreground
def refresh(self):
""" A placeholder to be used for BaseUIElement. """
if self.inhibit_refresh.isSet():
return False
self.view.refresh()
return True
# Views.
class TextView(object):
use_mixin = True
first_displayed_entry = 0
scrolling_speed_divisor = 4
fde_increment = 1
# Default wrapper
def __init__(self, o, ui_element):
self.o = o
self.el = ui_element
self.entry_height = self.el.entry_height
self.wrappers = []
self.setup_scrolling()
def setup_scrolling(self):
self.el.scrolling_defaults["current_speed"] = self.get_fow_width_in_chars()/self.scrolling_speed_divisor
@property
def in_foreground(self):
# Is necessary so that @to_be_foreground works
# Is to_be_foreground even necessary here?
return self.el.in_foreground
def get_entry_count_per_screen(self):
return self.get_fow_height_in_chars() / self.entry_height
def get_fow_width_in_chars(self):
return self.o.cols
def get_fow_height_in_chars(self):
return self.o.rows
def fix_pointers_on_contents_update(self):
"""Boundary-checks ``pointer``, re-sets the ``first_displayed_entry`` pointer."""
full_entries_shown = self.get_entry_count_per_screen()
contents = self.el.get_displayed_contents()
entry_count = len(contents)
new_pointer = clamp_list_index(self.el.pointer, contents) # Makes sure the pointer isn't larger than the entry count
if new_pointer == self.el.pointer:
return # Pointer didn't change from clamping, no action needs to be taken
self.el.pointer = new_pointer
if self.first_displayed_entry < new_pointer - full_entries_shown:
self.first_displayed_entry = new_pointer - full_entries_shown
def fix_pointers_on_refresh(self):
full_entries_shown = self.get_entry_count_per_screen()
if self.el.pointer < self.first_displayed_entry:
logger.debug("Pointer went too far to top, correcting")
self.first_displayed_entry = self.el.pointer
while self.el.pointer >= self.first_displayed_entry + full_entries_shown:
logger.debug("Pointer went too far to bottom, incrementing first_displayed_entry")
self.first_displayed_entry += self.fde_increment
logger.debug("First displayed entry is {}".format(self.first_displayed_entry))
def entry_is_active(self, entry_num):
return entry_num == self.el.pointer
def get_displayed_text(self, contents):
"""Generates the displayed data for a character-based output device. The output of this function can be fed to the o.display_data function.
|Corrects last&first_displayed_entry pointers if necessary, then gets the currently displayed entries' numbers, renders each one
of them and concatenates them into one big list which it returns.
|Doesn't support partly-rendering entries yet."""
displayed_data = []
full_entries_shown = self.get_entry_count_per_screen()
entries_shown = min(len(contents), full_entries_shown)
disp_entry_positions = range(self.first_displayed_entry, self.first_displayed_entry+entries_shown)
for entry_num in disp_entry_positions:
text_to_display = self.render_displayed_entry_text(entry_num, contents)
displayed_data += text_to_display
logger.debug("Displayed data: {}".format(displayed_data))
return displayed_data
def process_active_entry(self, entry):
""" This function processes text of the active entry in order to scroll it. """
avail_display_chars = (self.get_fow_width_in_chars() * self.entry_height)
# Scrolling only works with strings for now
# Maybe scrolling should be its own mixin?
# Likely, yes.
self.el.scrolling["current_scrollable"] = len(entry) > avail_display_chars
if not self.el.scrolling["current_scrollable"]:
return entry
overflow_amount = len(entry) - self.el.scrolling["pointer"] - avail_display_chars
if overflow_amount <= -self.el.scrolling["current_speed"]:
self.el.scrolling["pointer"] = 0
self.el.scrolling["current_finished"] = True
elif overflow_amount < 0:
# If a pointer is clamped, we still need to display the last part
# - without whitespace
self.el.scrolling["pointer"] = len(entry) - avail_display_chars
if self.el.scrolling["current_scrollable"] and not self.el.scrolling["current_finished"]:
entry = entry[self.el.scrolling["pointer"]:]
return entry
def process_inactive_entry(self, entry):
return entry
def render_displayed_entry_text(self, entry_num, contents):
"""Renders an UI element entry by its position number in self.contents, determined also by display width, self.entry_height and entry's representation type.
If entry representation is a string, splits it into parts as long as the display's width in characters.
If active flag is set, appends a "*" as the first entry's character. Otherwise, appends " ".
TODO: omit " " and "*" if entry height matches the display's row count.
If entry representation is a list, it returns that list as the rendered entry, trimming and padding with empty strings when necessary (to match the ``entry_height``).
"""
rendered_entry = []
entry = contents[entry_num]
if isinstance(entry, Entry):
text = entry.text
else:
text = entry[0]
active = self.entry_is_active(entry_num)
display_columns = self.get_fow_width_in_chars()
if isinstance(text, basestring):
if active:
text = self.process_active_entry(text)
else:
text = self.process_inactive_entry(text)
rendered_entry.append(text[:display_columns]) # First part of string displayed
text = text[display_columns:] # Shifting through the part we just displayed
for row_num in range(
self.entry_height - 1): # First part of string done, if there are more rows to display, we give them the remains of string
rendered_entry.append(text[:display_columns])
text = text[display_columns:]
elif type(text) == list:
text = text[
:self.entry_height] # Can't have more arguments in the list argument than maximum entry height
while len(text) < self.entry_height: # Can't have less either, padding with empty strings if necessary
text.append('')
return [str(entry_str)[:display_columns] for entry_str in text]
else:
# Something slipped past the check in set_contents
raise Exception("Entries may contain either strings or lists of strings as their representations")
logger.debug("Rendered entry: {}".format(rendered_entry))
return rendered_entry
def get_active_line_num(self):
return (self.el.pointer - self.first_displayed_entry) * self.entry_height
def refresh(self):
logger.debug("{}: refreshed data on display".format(self.el.name))
self.fix_pointers_on_refresh()
displayed_data = self.get_displayed_text(self.el.get_displayed_contents())
for wrapper in self.wrappers:
displayed_data = wrapper(displayed_data)
self.o.noCursor()
self.o.display_data(*displayed_data)
self.o.setCursor(self.get_active_line_num(), 0)
self.o.cursor()
class EightPtView(TextView):
charwidth = 6
charheight = 8
x_offset = 2
x_scrollbar_offset = 5
scrollbar_y_offset = 1
font = None
default_full_width_cursor = False
def __init__(self, *args, **kwargs):
self.full_width_cursor = kwargs.pop("full_width_cursor", self.default_full_width_cursor)
TextView.__init__(self, *args, **kwargs)
def get_fow_width_in_chars(self):
return (self.o.width - self.x_scrollbar_offset) / self.charwidth
def get_fow_height_in_chars(self):
return self.o.height / self.charheight
def refresh(self):
logger.debug("{}: refreshed data on display".format(self.el.name))
self.fix_pointers_on_refresh()
image = self.get_displayed_image()
for wrapper in self.wrappers:
image = wrapper(image)
self.o.display_image(image)
def scrollbar_needed(self, contents):
# No scrollbar if all the entries fit on the screen
full_entries_shown = self.get_entry_count_per_screen()
total_entry_count = len(contents)
return total_entry_count > full_entries_shown
def get_scrollbar_top_bottom(self, contents):
if not self.scrollbar_needed(contents):
return 0, 0
full_entries_shown = self.get_entry_count_per_screen()
total_entry_count = len(contents)
scrollbar_max_length = self.o.height - (self.scrollbar_y_offset * 2)
entries_before = self.first_displayed_entry
# Scrollbar length per one entry
length_unit = float(scrollbar_max_length) / total_entry_count
top = self.scrollbar_y_offset + int(entries_before * length_unit)
length = int(full_entries_shown * length_unit)
bottom = top + length
return top, bottom
def draw_scrollbar(self, c, contents):
scrollbar_coordinates = self.get_scrollbar_top_bottom(contents)
# Drawing scrollbar, if applicable
if scrollbar_coordinates == (0, 0):
# left offset is dynamic and depends on whether there's a scrollbar or not
left_offset = self.x_offset
else:
left_offset = self.x_scrollbar_offset
y1, y2 = scrollbar_coordinates
c.rectangle((1, y1, 2, y2))
return left_offset
def draw_menu_text(self, c, menu_text, left_offset):
for i, line in enumerate(menu_text):
y = (i * self.charheight - 1) if i != 0 else 0
c.text(line, (left_offset, y), font=self.font)
def draw_cursor(self, c, menu_text, left_offset):
cursor_y = self.get_active_line_num()
# We might not need to draw the cursor if there are no items present
if cursor_y is not None:
c_y = cursor_y * self.charheight + 1
if self.full_width_cursor:
x2 = c.width
else:
menu_texts = menu_text[cursor_y:cursor_y+self.entry_height]
max_menu_text_len = max([len(t) for t in menu_texts])
x2 = self.charwidth * max_menu_text_len + left_offset
cursor_dims = (
left_offset - 1,
c_y - 1,
x2,
c_y + self.charheight*self.entry_height - 1
)
c.invert_rect(cursor_dims)
def get_displayed_image(self):
"""Generates the displayed data for a canvas-based output device. The output of this function can be fed to the o.display_image function.
|Doesn't support partly-rendering entries yet."""
c = Canvas(self.o)
# Get the display-ready contents
contents = self.el.get_displayed_contents()
# Get the menu text
menu_text = self.get_displayed_text(contents)
# Drawing the scrollbar (will only be drawn if applicable)
left_offset = self.draw_scrollbar(c, contents)
# Drawing the text itself
self.draw_menu_text(c, menu_text, left_offset)
# Drawing the cursor
self.draw_cursor(c, menu_text, left_offset)
# Returning the image
return c.get_image()
class SixteenPtView(EightPtView):
charwidth = 8
charheight = 16
font = ("Fixedsys62.ttf", 16)
class MainMenuTripletView(SixteenPtView):
# TODO: enable scrolling
use_mixin = False
charwidth = 8
charheight = 16
def __init__(self, *args, **kwargs):
SixteenPtView.__init__(self, *args, **kwargs)
self.charheight = self.o.height / 3
def get_displayed_image(self):
# This view doesn't have a cursor, instead, the entry that's currently active is in the display center
contents = self.el.get_displayed_contents()
pointer = self.el.pointer # A shorthand
c = Canvas(self.o)
central_position = (10, 16)
font = c.load_font("Fixedsys62.ttf", 32)
current_entry = contents[pointer]
c.text(current_entry[0], central_position, font=font)
font = c.load_font("Fixedsys62.ttf", 16)
if pointer != 0:
line = contents[pointer - 1][0]
c.text(line, (2, 0), font=font)
if pointer < len(contents) - 1:
line = contents[pointer + 1][0]
c.text(line, (2, 48), font=font)
return c.get_image()