1
0
Fork 0
menu/menu.py

357 lines
14 KiB
Python

#!/usr/bin/env python3
import math
import argparse
import json
from enum import Enum, auto
from threading import Thread
from queue import Queue, Empty
from typing import Tuple, Callable, TypedDict, List, Dict, Optional
import pygame
import pygame.freetype
import requests
from iboard import IBoard
from screensavers import ClockScreensaver
class ButtonDef(TypedDict):
text: str
cbk: Callable[[], None]
is_empty: bool
class ActionType(Enum):
MSG = auto() # Shows a message for about 5 seconds
GET = auto() # Performs a HTTP GET
QUIT = auto() # Quits an application
EMPTY = auto() # Shows nothing instead of a button
class Action(TypedDict):
label: str
action_type: ActionType
action_param: Optional[str]
action_message: Optional[str]
class MultilineText:
VMARGIN = 10
def __init__(self, text: str, font: pygame.freetype.Font):
self.lines = text.splitlines()
self.font = font
self.all_rects = list(map(self.font.get_rect, self.lines))
self.width = max(map(lambda r: r.width, self.all_rects))
self.height = sum(map(lambda r: r.height + self.VMARGIN, self.all_rects))
def get_rect(self) -> pygame.Rect:
return pygame.Rect(0, 0, self.width, self.height)
def render_to(self, screen: pygame.Surface, pos: pygame.Rect, fgcolor: pygame.Color):
current_top = pos.top
for line, rect in zip(self.lines, self.all_rects):
self.font.render_to(screen,
pygame.Rect(pos.left, current_top, rect.width, rect.height),
line, fgcolor="black")
current_top += rect.top + self.VMARGIN
class Button(IBoard):
PRESS_OFFSET = 2
HOVER_ANIMATION_STEPS = 5
def __init__(self, position: pygame.Rect, text: str, theme: Dict, cbk: Callable[[], None]):
font = pygame.freetype.SysFont(name=theme["btn_font_face"], size=int(theme["btn_font_size"]))
self.position: pygame.Rect
self.set_position(position)
self.label = MultilineText(text, font)
self.theme = theme
self.cbk = cbk
text_rect = self.label.get_rect()
self.text_pos = pygame.Rect((self.position.left + (self.position.width - text_rect.width)/2),
(self.position.top + (self.position.height - text_rect.height)/2),
text_rect.width, text_rect.height)
self.bg_color = pygame.Color(self.theme["btn_bg_color"])
self.hover_bg_color = pygame.Color(self.theme["btn_hover_color"])
self.hover_animation_step = 0
def get_bg_color(self):
if self.pos_is_inside(pygame.mouse.get_pos()):
self.hover_animation_step = min(self.hover_animation_step + 1, self.HOVER_ANIMATION_STEPS)
else:
self.hover_animation_step = max(self.hover_animation_step - 1, 0)
if self.hover_animation_step == self.HOVER_ANIMATION_STEPS:
return self.hover_bg_color
if self.hover_animation_step == 0:
return self.bg_color
new_color = tuple(map(lambda v: v[0] + (((v[1]-v[0])/self.HOVER_ANIMATION_STEPS)*self.hover_animation_step),
zip(self.bg_color, self.hover_bg_color)))
return new_color
def needs_redrawing(self) -> bool:
return self.hover_animation_step == self.HOVER_ANIMATION_STEPS or self.hover_animation_step == 0
def set_position(self, position: pygame.Rect):
self.position = position
def get_position(self) -> pygame.Rect:
if pygame.mouse.get_pressed(3)[0] and self.pos_is_inside(pygame.mouse.get_pos()):
return pygame.Rect(self.position.left + self.PRESS_OFFSET, self.position.top + self.PRESS_OFFSET,
self.position.width, self.position.height)
return self.position
def get_text_pos(self) -> pygame.Rect:
if pygame.mouse.get_pressed(3)[0] and self.pos_is_inside(pygame.mouse.get_pos()):
return pygame.Rect(self.text_pos.left + self.PRESS_OFFSET, self.text_pos.top + self.PRESS_OFFSET,
self.text_pos.width, self.text_pos.height)
return self.text_pos
def draw(self, screen: pygame.Surface, _):
pygame.draw.rect(screen, self.get_bg_color(), self.get_position(), 0)
self.label.render_to(screen, self.get_text_pos(), self.theme["btn_text_color"])
def pos_is_inside(self, pos: Tuple) -> bool:
return (self.position.left < pos[0] and (self.position.left + self.position.width) > pos[0]) and \
(self.position.top < pos[1] and (self.position.top + self.position.height) > pos[1])
def handle_event(self, event: pygame.event.Event):
if event.type == pygame.MOUSEBUTTONUP:
if event.button == 1 and self.pos_is_inside(event.pos):
self.cbk()
class MessageBoard(IBoard):
def __init__(self, screen_rect: pygame.Rect, text: str, theme: Dict):
font = pygame.freetype.SysFont(name=theme["message_font_face"], size=int(theme["message_font_size"]))
self.label = MultilineText(text, font)
text_rect = self.label.get_rect()
self.text_pos = pygame.Rect(max(0, (screen_rect.left + (screen_rect.width - text_rect.width)/2)),
max(0, (screen_rect.top + (screen_rect.height - text_rect.height)/2)),
text_rect.width, text_rect.height)
self.theme = theme
self.was_drawn_already = False
def handle_event(self, _):
pass
def draw(self, screen: pygame.Surface, force_redraw: bool):
if not self.was_drawn_already or force_redraw:
screen.fill(self.theme["message_bg_color"])
self.label.render_to(screen, self.text_pos, fgcolor=self.theme["message_text_color"])
self.was_drawn_already = True
class MenuBoard(IBoard):
BUTTON_MARGINS = 10
def __init__(self, screen_rect: pygame.Rect, buttons: List[ButtonDef], theme: Dict):
def generate_buttons(button_positions_generator):
def impl(data: ButtonDef) -> Optional[IBoard]:
if data['is_empty']:
next(button_positions)
return None
else:
return Button(next(button_positions), data['text'], theme, data['cbk'])
return impl
self.rows = math.floor(math.sqrt(len(buttons)))
self.cols = math.ceil(math.sqrt(len(buttons)))
if (self.rows * self.cols) < len(buttons):
# extra row if buttons don't fit
self.rows += 1
button_positions = self.generate_button_positions(screen_rect)
self.buttons = list(filter(lambda x: x is not None, map(generate_buttons(button_positions), buttons)))
self.theme = theme
def generate_button_positions(self, screen_rect: pygame.Rect):
current_button_row = 0
current_button_col = 0
button_width = math.floor((screen_rect.width - (self.cols + 1) * self.BUTTON_MARGINS) / self.cols)
button_height = math.floor((screen_rect.height - (self.rows + 1) * self.BUTTON_MARGINS) / self.rows)
while (current_button_row * current_button_col) < (self.cols * self.rows):
top = self.BUTTON_MARGINS + (current_button_row * (button_height + self.BUTTON_MARGINS))
left = self.BUTTON_MARGINS + (current_button_col * (button_width + self.BUTTON_MARGINS))
current_button_col += 1
if current_button_col >= self.cols:
current_button_col = 0
current_button_row += 1
yield pygame.Rect(left, top, button_width, button_height)
def handle_event(self, event: pygame.event.Event):
for b in self.buttons:
b.handle_event(event)
def draw(self, screen: pygame.Surface, force_redraw: bool):
if force_redraw or any(map(Button.needs_redrawing, self.buttons)):
screen.fill(self.theme["menu_bg_color"])
for b in self.buttons:
b.draw(screen, force_redraw)
class App:
FPS = 30
SHOW_REQUEST_MESSAGE_FOR_AT_LEAST_S = 5
IGNORE_EVENTS_FOR_TICKS_AFTER_SCREENSAVER = 5
def __init__(self, actions: List[Action], theme: Dict, fullscreen: bool, screensaver_delay: int,
hide_cursor: bool):
pygame.init()
info = pygame.display.Info()
flags = 0
if fullscreen:
flags = pygame.FULLSCREEN
self.screen = pygame.display.set_mode((info.current_w, info.current_h), flags=flags)
self.clock = pygame.time.Clock()
self.running = False
self.theme = theme
buttons = list(map(lambda u: ButtonDef(text=u['label'],
cbk=self.button_press_handler(u),
is_empty=(u['action_type'] == ActionType.EMPTY)),
actions))
self.board: IBoard = MenuBoard(self.get_screen_rect(), buttons, theme)
self.task_q = Queue()
self.worker_q = Queue()
self.screensaver = ClockScreensaver()
self.TICKS_UNTIL_SCREENSAVER = screensaver_delay * self.FPS
if hide_cursor:
pygame.mouse.set_visible(False)
self.worker_thr = Thread(target=self.worker)
self.worker_thr.start()
def worker(self):
for job in iter(self.worker_q.get, None):
job()
def get_screen_rect(self) -> pygame.Rect:
return pygame.Rect(0, 0, self.screen.get_width(), self.screen.get_height())
def show_message(self, msg: str) -> Callable[[], None]:
def x():
self.board = MessageBoard(self.get_screen_rect(), msg, theme=self.theme)
return x
def show_message_handler(self, action: Action) -> Callable[[], None]:
def impl():
previous_board = self.board
def thr_fun():
def end_thr():
self.board = previous_board
try:
sleep_time = int(action['action_param'])
pygame.time.wait(sleep_time * 1000)
except Exception as e:
self.task_q.put(self.show_message(f"Exception caught: {e}"))
pygame.time.wait(5 * 1000)
finally:
self.task_q.put(end_thr)
self.board = MessageBoard(self.get_screen_rect(), action['action_message'], theme=self.theme)
self.worker_q.put(thr_fun)
return impl
def get_handler(self, action: Action) -> Callable[[], None]:
def impl():
previous_board = self.board
def thr_fun():
def end_thr():
self.board = previous_board
req_start_ts = pygame.time.get_ticks()
ret = requests.get(action['action_param'])
print(f"GET {action['action_param']}: {ret}")
self.task_q.put(self.show_message(ret.text))
pygame.time.wait(max(self.SHOW_REQUEST_MESSAGE_FOR_AT_LEAST_S * 1000 - (pygame.time.get_ticks() - req_start_ts), 0))
self.task_q.put(end_thr)
self.board = MessageBoard(self.get_screen_rect(), action['action_message'], theme=self.theme)
self.worker_q.put(thr_fun)
return impl
def button_press_handler(self, action: Action) -> Callable[[], None]:
if action['action_type'] == ActionType.MSG:
return self.show_message_handler(action)
elif action['action_type'] == ActionType.GET:
return self.get_handler(action)
elif action['action_type'] == ActionType.QUIT:
return self.quit
elif action['action_type'] == ActionType.EMPTY:
return lambda: print("Tried to do an action on EMPTY - shouldn't happen")
raise NotImplementedError(action['action_type'])
def loop(self):
self.running = True
needs_redrawing = True
# Block event handling for some time after screensaver is disabled to prevent
# user from clicking somewhere they don't intend to.
ticks_after_screensaver = 0
# how many ticks left until the screensaver should be engaged
screensaver_ticks = self.TICKS_UNTIL_SCREENSAVER
while self.running:
try:
self.task_q.get_nowait()()
except Empty:
pass
ticks_after_screensaver = max(ticks_after_screensaver-1, 0)
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
if screensaver_ticks != 0 and ticks_after_screensaver == 0:
self.board.handle_event(event)
screensaver_ticks = self.TICKS_UNTIL_SCREENSAVER
needs_redrawing = True
if screensaver_ticks == 0:
self.screensaver.draw(self.screen, False)
ticks_after_screensaver = self.IGNORE_EVENTS_FOR_TICKS_AFTER_SCREENSAVER
else:
self.board.draw(self.screen, needs_redrawing)
screensaver_ticks -= 1
needs_redrawing = False
pygame.display.flip()
self.clock.tick(self.FPS)
self.worker_thr.join()
pygame.quit()
def quit(self):
self.board = MessageBoard(self.get_screen_rect(), "Exiting...", theme=self.theme)
self.running = False
self.worker_q.put(None)
def get_url_defs(config_data: dict) -> List[Action]:
url_defs = []
for d in data['actions']:
url_defs.append(Action(label=d.get('label'),
action_type=ActionType[d['action_type']],
action_param=d.get('action_param'),
action_message=str(d.get('action_message'))))
return url_defs
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument("config_path", help="Path to the config file.", type=str)
parser.add_argument("--no-fullscreen", help="Fullscreen", action='store_true')
parser.add_argument("--screensaver-delay", help="Screensaver delay, in seconds. Default=30",
type=int, default=30)
parser.add_argument("--no-cursor", help="Hide the mouse cursor", action='store_true')
args = parser.parse_args()
url_defs: List[Action] = []
with open(args.config_path, "rb") as f:
data = json.load(f)
url_defs = get_url_defs(data)
app = App(url_defs, theme=data["theme"], fullscreen=not args.no_fullscreen,
screensaver_delay=args.screensaver_delay, hide_cursor=args.no_cursor)
app.loop()