1
0
Fork 0
menu/menu.py

330 lines
13 KiB
Python
Raw Normal View History

2024-09-08 11:43:21 +00:00
#!/usr/bin/env python3
2024-09-08 14:44:47 +00:00
import math
2024-09-08 15:50:38 +00:00
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
2024-09-08 11:43:21 +00:00
import pygame
2024-09-08 14:44:47 +00:00
import pygame.freetype
2024-09-08 17:26:29 +00:00
import requests
2024-09-08 14:44:47 +00:00
from iboard import IBoard
from screensavers import ClockScreensaver
2024-09-08 14:44:47 +00:00
class ButtonDef(TypedDict):
text: str
2024-09-08 14:50:18 +00:00
cbk: Callable[[], None]
2024-09-08 11:43:21 +00:00
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):
2024-09-08 15:50:38 +00:00
label: str
action_type: ActionType
action_param: Optional[str]
action_message: Optional[str]
2024-09-08 15:50:38 +00:00
2024-09-10 21:07:25 +00:00
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
2024-09-08 11:43:21 +00:00
class Button:
2024-09-09 17:28:55 +00:00
PRESS_OFFSET = 2
2024-09-10 15:10:06 +00:00
HOVER_ANIMATION_STEPS = 5
2024-09-09 17:28:55 +00:00
2024-09-10 16:22:13 +00:00
def __init__(self, position: pygame.Rect, text: str, theme: Dict, cbk: Callable[[], None]):
2024-09-10 21:07:25 +00:00
font = pygame.freetype.SysFont(name=theme["btn_font_face"], size=int(theme["btn_font_size"]))
2024-09-08 11:43:21 +00:00
self.position: pygame.Rect
self.set_position(position)
2024-09-10 21:07:25 +00:00
self.label = MultilineText(text, font)
2024-09-10 16:22:13 +00:00
self.theme = theme
2024-09-08 11:43:21 +00:00
self.cbk = cbk
2024-09-10 21:07:25 +00:00
text_rect = self.label.get_rect()
2024-09-08 14:44:47 +00:00
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)
2024-09-10 16:22:13 +00:00
self.bg_color = pygame.Color(self.theme["btn_bg_color"])
self.hover_bg_color = pygame.Color(self.theme["btn_hover_color"])
2024-09-10 15:10:06 +00:00
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
2024-09-08 11:43:21 +00:00
def set_position(self, position: pygame.Rect):
self.position = position
2024-09-09 17:28:55 +00:00
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
2024-09-08 11:43:21 +00:00
def draw(self, screen: pygame.Surface):
2024-09-10 15:10:06 +00:00
pygame.draw.rect(screen, self.get_bg_color(), self.get_position(), 0)
2024-09-10 21:07:25 +00:00
self.label.render_to(screen, self.get_text_pos(), self.theme["btn_text_color"])
2024-09-08 11:43:21 +00:00
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):
2024-09-10 16:22:13 +00:00
def __init__(self, screen_rect: pygame.Rect, text: str, theme: Dict):
2024-09-10 21:07:25 +00:00
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((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)
2024-09-10 16:22:13 +00:00
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:
2024-09-10 16:22:13 +00:00
screen.fill(self.theme["message_bg_color"])
2024-09-10 21:07:25 +00:00
self.label.render_to(screen, self.text_pos, fgcolor=self.theme["message_text_color"])
self.was_drawn_already = True
2024-09-08 11:43:21 +00:00
class MenuBoard(IBoard):
2024-09-08 14:44:47 +00:00
BUTTON_MARGINS = 10
2024-09-10 16:22:13 +00:00
def __init__(self, screen_rect: pygame.Rect, buttons: List[ButtonDef], theme: Dict):
2024-09-08 15:50:38 +00:00
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
2024-09-08 14:44:47 +00:00
button_positions = self.generate_button_positions(screen_rect)
2024-09-10 16:22:13 +00:00
self.buttons = list(map(lambda d: Button(next(button_positions), d['text'], theme, d['cbk']), buttons))
self.theme = theme
2024-09-08 14:44:47 +00:00
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)
2024-09-08 11:43:21 +00:00
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):
2024-09-10 15:10:06 +00:00
if force_redraw or any(map(Button.needs_redrawing, self.buttons)):
2024-09-10 16:22:13 +00:00
screen.fill(self.theme["menu_bg_color"])
for b in self.buttons:
b.draw(screen)
2024-09-08 11:43:21 +00:00
class App:
2024-09-09 17:56:38 +00:00
FPS = 30
SHOW_REQUEST_MESSAGE_FOR_AT_LEAST_S = 5
IGNORE_EVENTS_FOR_TICKS_AFTER_SCREENSAVER = 5
2024-09-09 17:56:38 +00:00
2024-09-10 16:22:13 +00:00
def __init__(self, actions: List[Action], theme: Dict, fullscreen: bool, screensaver_delay: int):
2024-09-08 11:43:21 +00:00
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)
2024-09-08 11:43:21 +00:00
self.clock = pygame.time.Clock()
self.running = False
2024-09-10 16:22:13 +00:00
self.theme = theme
buttons = list(map(lambda u: ButtonDef(text=u['label'], cbk=self.button_press_handler(u)), actions))
2024-09-10 16:22:13 +00:00
self.board: IBoard = MenuBoard(self.get_screen_rect(), buttons, theme)
self.task_q = Queue()
2024-09-09 17:56:38 +00:00
self.screensaver = ClockScreensaver()
self.TICKS_UNTIL_SCREENSAVER = screensaver_delay * self.FPS
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
2024-09-08 15:20:47 +00:00
def thr_fun():
def end_thr():
self.board = previous_board
2024-09-08 15:20:47 +00:00
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)
2024-09-08 15:20:47 +00:00
process_thr = Thread(target=thr_fun)
process_thr.start()
2024-09-10 16:22:13 +00:00
self.board = MessageBoard(self.get_screen_rect(), action['action_message'], theme=self.theme)
return impl
2024-09-08 11:43:21 +00:00
2024-09-08 17:26:29 +00:00
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(f"Request done: {ret}"))
pygame.time.wait(max(self.SHOW_REQUEST_MESSAGE_FOR_AT_LEAST_S * 1000 - (pygame.time.get_ticks() - req_start_ts), 0))
2024-09-08 17:26:29 +00:00
self.task_q.put(end_thr)
self.board = MessageBoard(self.get_screen_rect(), action['action_message'], theme=self.theme)
2024-09-08 17:26:29 +00:00
process_thr = Thread(target=thr_fun)
process_thr.start()
return impl
def button_press_handler(self, action: Action) -> Callable[[], None]:
if action['action_type'] == ActionType.MSG:
return self.show_message_handler(action)
2024-09-08 17:26:29 +00:00
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'])
2024-09-08 11:43:21 +00:00
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
2024-09-08 11:43:21 +00:00
while self.running:
try:
self.task_q.get_nowait()()
except Empty:
pass
ticks_after_screensaver = max(ticks_after_screensaver-1, 0)
2024-09-08 11:43:21 +00:00
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
if screensaver_ticks != 0 and ticks_after_screensaver == 0:
2024-09-09 17:56:38 +00:00
self.board.handle_event(event)
screensaver_ticks = self.TICKS_UNTIL_SCREENSAVER
needs_redrawing = True
2024-09-08 11:43:21 +00:00
if screensaver_ticks == 0:
self.screensaver.draw(self.screen, False)
ticks_after_screensaver = self.IGNORE_EVENTS_FOR_TICKS_AFTER_SCREENSAVER
2024-09-09 17:56:38 +00:00
else:
self.board.draw(self.screen, needs_redrawing)
screensaver_ticks -= 1
needs_redrawing = False
2024-09-08 11:43:21 +00:00
pygame.display.flip()
2024-09-09 17:56:38 +00:00
self.clock.tick(self.FPS)
2024-09-08 11:43:21 +00:00
pygame.quit()
2024-09-08 14:50:18 +00:00
def quit(self):
2024-09-10 16:22:13 +00:00
self.board = MessageBoard(self.get_screen_rect(), "Exiting...", theme=self.theme)
2024-09-08 14:50:18 +00:00
self.running = False
2024-09-08 11:43:21 +00:00
def get_url_defs(config_data: dict) -> List[Action]:
2024-09-08 15:50:38 +00:00
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')))
2024-09-08 15:50:38 +00:00
return url_defs
2024-09-08 11:43:21 +00:00
if __name__ == '__main__':
2024-09-08 15:50:38 +00:00
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)
2024-09-08 15:50:38 +00:00
args = parser.parse_args()
2024-09-08 16:02:13 +00:00
url_defs: List[Action] = []
2024-09-08 16:02:13 +00:00
with open(args.config_path, "rb") as f:
data = json.load(f)
url_defs = get_url_defs(data)
2024-09-10 16:22:13 +00:00
app = App(url_defs, theme=data["theme"], fullscreen=not args.no_fullscreen,
screensaver_delay=args.screensaver_delay)
2024-09-08 11:43:21 +00:00
app.loop()