#!/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 abc import abstractmethod from typing import Tuple, Callable, TypedDict, List import pygame import pygame.freetype class ButtonDef(TypedDict): text: str cbk: Callable[[], None] class ActionType(Enum): MSG = auto() class Action(TypedDict): label: str action_type: ActionType param: str class IBoard: @abstractmethod def draw(self, screen: pygame.Surface): raise NotImplementedError() @abstractmethod def handle_event(self, event: pygame.event.Event): raise NotImplementedError() class Button: def __init__(self, position: pygame.Rect, text: str, cbk: Callable[[], None]): self.position: pygame.Rect self.set_position(position) self.text = text 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) def set_position(self, position: pygame.Rect): self.position = position def draw(self, screen: pygame.Surface): pygame.draw.rect(screen, "black", self.position, 0) self.font.render_to(screen, self.text_pos, self.text, fgcolor="pink") 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, bg_color: str): 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.bg_color = bg_color def handle_event(self, _): pass def draw(self, screen: pygame.Surface): screen.fill(self.bg_color) self.font.render_to(screen, self.text_pos, self.text, fgcolor="black") class MenuBoard(IBoard): BUTTON_MARGINS = 10 def __init__(self, screen_rect: pygame.Rect, buttons: List[ButtonDef], bg_color: str): 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'], d['cbk']), buttons)) self.bg_color = bg_color 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): screen.fill(self.bg_color) for b in self.buttons: b.draw(screen) class App: def __init__(self, urls: List[Action], bg_color: str): pygame.init() info = pygame.display.Info() self.screen = pygame.display.set_mode((info.current_w, info.current_h), flags=pygame.FULLSCREEN) self.clock = pygame.time.Clock() self.running = False self.bg_color = bg_color buttons = list(map(lambda u: ButtonDef(text=u['label'], cbk=self.button_press_handler(u)), urls)) buttons.append(ButtonDef(text="Exit", cbk=self.quit)) self.board: IBoard = MenuBoard(self.get_screen_rect(), buttons, bg_color) self.task_q = Queue() 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 sleep(5) self.task_q.put(end_thr) process_thr = Thread(target=thr_fun) process_thr.start() self.board = MessageBoard(self.get_screen_rect(), action['param'], bg_color=self.bg_color) return impl def button_press_handler(self, action: Action) -> Callable[[], None]: if action['action_type'] == ActionType.MSG: return self.show_message_handler(action) raise NotImplementedError(action['action_type']) def loop(self): self.running = 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 self.board.handle_event(event) self.board.draw(self.screen) pygame.display.flip() self.clock.tick(30) pygame.quit() def quit(self): self.board = MessageBoard(self.get_screen_rect(), "Exiting...", bg_color=self.bg_color) self.running = False def get_url_defs(config_data: dict) -> List[Action]: url_defs = [] for d in data['urls']: url_defs.append(Action(label=d['label'], action_type=ActionType[d['action_type']], param=d['action_param'])) return url_defs if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument("config_path", help="Path to the config file.", type=str) 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, bg_color=data["bg_color"]) app.loop()