1
0
Fork 0
menu/menu.py

298 lines
12 KiB
Python

#!/usr/bin/env python3
import math
import argparse
import json
from enum import Enum, auto
from time import sleep
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]
class ActionType(Enum):
MSG = auto() # Shows a message for about 5 seconds
GET = auto() # Performs a HTTP GET
QUIT = auto() # Quits an application
class Action(TypedDict):
label: str
action_type: ActionType
action_param: Optional[str]
action_message: Optional[str]
class Button:
PRESS_OFFSET = 2
HOVER_ANIMATION_STEPS = 5
def __init__(self, position: pygame.Rect, text: str, theme: Dict, cbk: Callable[[], None]):
self.position: pygame.Rect
self.set_position(position)
self.text = text
self.theme = theme
self.cbk = cbk
self.font = pygame.freetype.SysFont(name="Sans", size=20)
text_rect = self.font.get_rect(self.text)
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.font.render_to(screen, self.get_text_pos(), self.text, fgcolor=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):
self.text = text
self.font = pygame.freetype.SysFont(name="Sans", size=30)
text_rect = self.font.get_rect(self.text)
self.text_pos = pygame.Rect((screen_rect.left + (screen_rect.width - text_rect.width)/2),
(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.font.render_to(screen, self.text_pos, self.text, 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):
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(map(lambda d: Button(next(button_positions), d['text'], theme, d['cbk']), 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)
class App:
FPS = 30
def __init__(self, actions: List[Action], theme: Dict, fullscreen: bool, screensaver_delay: int):
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)), actions))
self.board: IBoard = MenuBoard(self.get_screen_rect(), buttons, theme)
self.task_q = Queue()
self.screensaver = ClockScreensaver()
self.TICKS_UNTIL_SCREENSAVER = screensaver_delay * self.FPS
self.screensaver_ticks = self.TICKS_UNTIL_SCREENSAVER
def get_screen_rect(self) -> pygame.Rect:
return pygame.Rect(0, 0, self.screen.get_width(), self.screen.get_height())
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'])
sleep(sleep_time)
except Exception as e:
def f(ex):
def x():
self.board = MessageBoard(self.get_screen_rect(), f"Exception caught: {ex}",
theme=self.theme)
return x
self.task_q.put(f(e))
sleep(5)
finally:
self.task_q.put(end_thr)
process_thr = Thread(target=thr_fun)
process_thr.start()
self.board = MessageBoard(self.get_screen_rect(), action['action_message'], theme=self.theme)
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
ret = requests.get(action['action_param'])
print(f"GET {action['action_param']}: {ret}")
self.task_q.put(end_thr)
process_thr = Thread(target=thr_fun)
process_thr.start()
self.board = MessageBoard(self.get_screen_rect(), action['action_message'], theme=self.theme)
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
raise NotImplementedError(action['action_type'])
def loop(self):
self.running = True
needs_redrawing = True
while self.running:
try:
self.task_q.get_nowait()()
except Empty:
pass
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
if self.screensaver_ticks != 0:
self.board.handle_event(event)
self.screensaver_ticks = self.TICKS_UNTIL_SCREENSAVER
needs_redrawing = True
if self.screensaver_ticks == 0:
self.screensaver.draw(self.screen, False)
else:
self.board.draw(self.screen, needs_redrawing)
self.screensaver_ticks -= 1
needs_redrawing = False
pygame.display.flip()
self.clock.tick(self.FPS)
pygame.quit()
def quit(self):
self.board = MessageBoard(self.get_screen_rect(), "Exiting...", theme=self.theme)
self.running = False
def get_url_defs(config_data: dict) -> List[Action]:
url_defs = []
for d in data['actions']:
url_defs.append(Action(label=d['label'],
action_type=ActionType[d['action_type']],
action_param=d.get('action_param'),
action_message=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)
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)
app.loop()