#!/usr/bin/env python3 import math import argparse import json from enum import Enum, auto from time import sleep, strftime 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 import requests 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 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 Screensaver(IBoard): def handle_event(self, _): pass class ClockScreensaver(Screensaver): def __init__(self): self.font = pygame.freetype.SysFont(name="Sans", size=20) def draw(self, screen: pygame.Surface): text = strftime("%H:%M:%S") screen.fill("black") pygame.Rect(0, 0, screen.get_width(), screen.get_height()) text_rect = self.font.get_rect(text) text_pos = pygame.Rect((screen.get_width() - text_rect.width)/2, (screen.get_height() - text_rect.height)/2, text_rect.width, text_rect.height) self.font.render_to(screen, text_pos, text, fgcolor="pink") class Button: PRESS_OFFSET = 2 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 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, "black", self.get_position(), 0) self.font.render_to(screen, self.get_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: FPS = 30 TICKS_UNTIL_SCREENSAVER = 1 * FPS 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() self.screensaver = ClockScreensaver() 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 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 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['param']) print(f"GET {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(), f"GETting {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) elif action['action_type'] == ActionType.GET: return self.get_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 if self.screensaver_ticks != 0: self.board.handle_event(event) self.screensaver_ticks = self.TICKS_UNTIL_SCREENSAVER if self.screensaver_ticks == 0: self.screensaver.draw(self.screen) else: self.board.draw(self.screen) self.screensaver_ticks -= 1 pygame.display.flip() self.clock.tick(self.FPS) 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()