#!/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 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 class Action(TypedDict): label: str action_type: ActionType action_param: str action_message: str 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 def __init__(self, urls: List[Action], bg_color: str, 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.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.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}", bg_color="red") 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'], 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['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'], 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']], action_param=d['action_param'], action_message=d['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, bg_color=data["bg_color"], fullscreen=not args.no_fullscreen, screensaver_delay=args.screensaver_delay) app.loop()