RASCSI/python/ctrlboard/src/menu/menu_renderer.py

298 lines
11 KiB
Python

"""Module provides the abstract menu renderer class"""
import time
import math
import itertools
from abc import ABC, abstractmethod
from pydoc import locate
from typing import Optional
from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont
from menu.menu import Menu
from menu.menu_renderer_config import MenuRendererConfig
from menu.screensaver import ScreenSaver
class MenuRenderer(ABC):
"""The abstract menu renderer class provides the base for concrete menu
renderer classes that implement functionality based on conrete hardware or available APIs."""
def __init__(self, config: MenuRendererConfig):
self.message = ""
self.mini_message = ""
self._menu = None
self._config = config
self.disp = self.display_init()
self.image = Image.new("1", (self.disp.width, self.disp.height))
self.draw = ImageDraw.Draw(self.image)
self.font = ImageFont.truetype(config.font_path, size=config.font_size)
# just a sample text to work with the font height
self.font_height = self.font.getbbox("ABCabc")[3]
self.cursor_position = 0
self.frame_start_row = 0
self.render_timestamp = None
# effectively a small state machine that deals with the scrolling
self._perform_scrolling_stage = 0
self._x_scrolling = 0
self._current_line_horizontal_overlap = None
self._stage_timestamp: Optional[int] = None
screensaver = locate(self._config.screensaver)
# noinspection PyCallingNonCallable
self.screensaver: ScreenSaver = screensaver(self._config.screensaver_delay, self)
@abstractmethod
def display_init(self):
"""Method initializes the displays for usage."""
@abstractmethod
def display_clear(self):
"""Methods clears the screen. Possible hardware clear call if necessary."""
@abstractmethod
def blank_screen(self):
"""Method blanks the screen. Based on drawing a blank rectangle."""
@abstractmethod
def update_display_image(self, image):
"""Method displays an image using PIL."""
@abstractmethod
def update_display(self):
"""Method updates the display."""
def set_config(self, config: MenuRendererConfig):
"""Configures the menu renderer with a generic menu renderer configuration."""
self._config = config
def get_config(self):
"""Returns the menu renderer configuration."""
return self._config
def set_menu(self, menu: Menu):
"""Method sets the menu that the menu renderer should draw."""
self._menu = menu
def rows_per_screen(self):
"""Calculates the number of rows per screen based on the configured font size."""
rows = self.disp.height / self.font_height
return math.floor(rows)
def draw_row(self, row_number: int, text: str, selected: bool):
"""Draws a single row of the menu."""
x_pos = 0
y_pos = row_number * self.font_height
if selected:
selection_extension = 0
if row_number < self.rows_per_screen():
selection_extension = self._config.row_selection_pixel_extension
self.draw.rectangle(
(
x_pos,
y_pos,
self.disp.width,
y_pos + self._config.font_size + selection_extension,
),
outline=0,
fill=255,
)
# in stage 1, we initialize scrolling for the currently selected line
if self._perform_scrolling_stage == 1:
self.setup_horizontal_scrolling(text)
self._perform_scrolling_stage = 2 # don't repeat once the scrolling has started
# in stage 2, we know the details and can thus perform the scrolling to the left
if self._perform_scrolling_stage == 2:
if self._current_line_horizontal_overlap + self._x_scrolling > 0:
self._x_scrolling -= 1
if self._current_line_horizontal_overlap + self._x_scrolling == 0:
self._stage_timestamp = int(time.time())
self._perform_scrolling_stage = 3
# in stage 3, we wait for a little when we have scrolled to the end of the text
if self._perform_scrolling_stage == 3:
current_time = int(time.time())
time_diff = current_time - self._stage_timestamp
if time_diff >= int(self._config.scroll_line_end_delay):
self._stage_timestamp = None
self._perform_scrolling_stage = 4
# in stage 4, we scroll back to the right
if self._perform_scrolling_stage == 4:
if self._current_line_horizontal_overlap + self._x_scrolling >= 0:
self._x_scrolling += 1
if (
self._current_line_horizontal_overlap + self._x_scrolling
) == self._current_line_horizontal_overlap:
self._stage_timestamp = int(time.time())
self._perform_scrolling_stage = 5
# in stage 5, we wait again for a little while before we start again
if self._perform_scrolling_stage == 5:
current_time = int(time.time())
time_diff = current_time - self._stage_timestamp
if time_diff >= int(self._config.scroll_line_end_delay):
self._stage_timestamp = None
self._perform_scrolling_stage = 2
self.draw.text(
(x_pos + self._x_scrolling, y_pos),
text,
font=self.font,
spacing=0,
stroke_fill=0,
fill=0,
)
else:
self.draw.text((x_pos, y_pos), text, font=self.font, spacing=0, stroke_fill=0, fill=255)
def draw_fullsceen_message(self, text: str):
"""Draws a fullscreen message, i.e., a full-screen message."""
font_width = self.font.getlength(text)
font_height = self.font.getbbox(text)[3]
centered_width = (self.disp.width - font_width) / 2
centered_height = (self.disp.height - font_height) / 2
self.draw.rectangle((0, 0, self.disp.width, self.disp.height), outline=0, fill=255)
self.draw.text(
(centered_width, centered_height),
text,
align="center",
font=self.font,
stroke_fill=0,
fill=0,
textsize=20,
)
def draw_mini_message(self, text: str):
"""Draws a fullscreen message, i.e., a message covering only the center portion of
the screen. The remaining areas stay visible."""
font_width = self.font.getlength(text)
centered_width = (self.disp.width - font_width) / 2
centered_height = (self.disp.height - self.font_height) / 2
self.draw.rectangle(
(
0,
centered_height - 4,
self.disp.width,
centered_height + self.font_height + 4,
),
outline=0,
fill=255,
)
self.draw.text(
(centered_width, centered_height),
text,
align="center",
font=self.font,
stroke_fill=0,
fill=0,
textsize=20,
)
def draw_menu(self):
"""Method draws the menu set to the class instance."""
if self._menu.item_selection >= self.frame_start_row + self.rows_per_screen():
if self._config.scroll_behavior == "page":
self.frame_start_row = (
self.frame_start_row + (round(self.rows_per_screen() / 2)) + 1
)
if self.frame_start_row > len(self._menu.entries) - self.rows_per_screen():
self.frame_start_row = len(self._menu.entries) - self.rows_per_screen()
else: # extend as default behavior
self.frame_start_row = self._menu.item_selection + 1 - self.rows_per_screen()
if self._menu.item_selection < self.frame_start_row:
if self._config.scroll_behavior == "page":
self.frame_start_row = (
self.frame_start_row - (round(self.rows_per_screen() / 2)) - 1
)
if self.frame_start_row < 0:
self.frame_start_row = 0
else: # extend as default behavior
self.frame_start_row = self._menu.item_selection
self.draw_menu_frame(self.frame_start_row, self.frame_start_row + self.rows_per_screen())
def draw_menu_frame(self, frame_start_row: int, frame_end_row: int):
"""Draws row frame_start_row to frame_end_row of the class instance menu, i.e., it
draws a given frame of the complete menu that fits the screen."""
self.draw.rectangle((0, 0, self.disp.width, self.disp.height), outline=0, fill=0)
row_on_screen = 0
row_in_menuitems = frame_start_row
for menu_entry in itertools.islice(self._menu.entries, frame_start_row, frame_end_row):
if row_in_menuitems == self._menu.item_selection:
self.draw_row(row_on_screen, menu_entry["text"], True)
else:
self.draw_row(row_on_screen, menu_entry["text"], False)
row_in_menuitems += 1
row_on_screen += 1
if row_on_screen >= self.rows_per_screen():
break
def render(self, display_on_device=True):
"""Method renders the menu."""
if display_on_device is True:
self.screensaver.reset_timer()
self._perform_scrolling_stage = 0
self._current_line_horizontal_overlap = None
self._x_scrolling = 0
if self._menu is None:
self.draw_fullsceen_message("No menu set!")
self.disp.image(self.image)
if display_on_device is True:
self.disp.show()
return None
self.display_clear()
if self.message != "":
self.draw_fullsceen_message(self.message)
elif self.mini_message != "":
self.draw_mini_message(self.mini_message)
else:
self.draw_menu()
if display_on_device is True:
self.update_display_image(self.image)
self.update_display()
self.render_timestamp = int(time.time())
return self.image
def setup_horizontal_scrolling(self, text):
"""Configure horizontal scrolling based on the configured screen dimensions."""
font_width = self.font.getlength(text)
self._current_line_horizontal_overlap = font_width - self.disp.width
def update(self):
"""Method updates the menu drawing within the main loop in a non-blocking manner.
Also updates the current entry scrolling if activated."""
if self._config.scroll_line is False:
return
self.screensaver.check_timer()
if self.screensaver.enabled is True:
self.screensaver.draw()
return
current_time = int(time.time())
time_diff = current_time - self.render_timestamp
if time_diff >= self._config.scroll_delay:
if self._perform_scrolling_stage == 0:
self._perform_scrolling_stage = 1
self.draw_menu()
self.update_display_image(self.image)