#!/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()