diff --git a/modules/app_components/menu.py b/modules/app_components/menu.py new file mode 100644 index 0000000..15c235c --- /dev/null +++ b/modules/app_components/menu.py @@ -0,0 +1,115 @@ +from typing import Any, Callable, Literal, Union + +from app import App +from events.input import BUTTON_TYPES, ButtonDownEvent +from system.eventbus import eventbus + + +def ease_out_quart(x): + return 1 - pow(1 - x, 4) + + +class Menu: + def __init__( + self, + app: App, + menu_items: list[str] = [], + position=0, + select_handler: Union[Callable[[str], Any], None] = None, + back_handler: Union[Callable, None] = None, + speed_ms=300, + item_font_size=20, + item_line_height=30, + focused_item_font_size=40, + focused_item_margin=20, + ): + self.app = app + self.menu_items = menu_items + self.position = position + self.select_handler = select_handler + self.back_handler = back_handler + self.speed_ms = speed_ms + self.item_font_size = item_font_size + self.item_line_height = item_line_height + self.focused_item_font_size = focused_item_font_size + self.focused_item_margin = focused_item_margin + + self.animation_time_ms = 0 + # self.is_animating: Literal["up", "down", "none"] = "none" + self.is_animating: Literal["up", "down", "none"] = "up" + + eventbus.on(ButtonDownEvent, self._handle_buttondown, app) + + def _cleanup(self): + eventbus.remove(ButtonDownEvent, self._handle_buttondown, self.app) + + def _handle_buttondown(self, event: ButtonDownEvent): + if BUTTON_TYPES["UP"] in event.button: + self.up_handler() + if BUTTON_TYPES["DOWN"] in event.button: + self.down_handler() + if BUTTON_TYPES["CANCEL"] in event.button: + if self.back_handler is not None: + self.back_handler() + if BUTTON_TYPES["CONFIRM"] in event.button: + if self.select_handler is not None: + self.select_handler( + self.menu_items[self.position % len(self.menu_items)] + ) + + def up_handler(self): + self.is_animating = "up" + self.animation_time_ms = 0 + self.position = (self.position - 1) % len(self.menu_items) + + def down_handler(self): + self.is_animating = "down" + self.animation_time_ms = 0 + self.position = (self.position + 1) % len(self.menu_items) + + def draw(self, ctx): + animation_progress = ease_out_quart(self.animation_time_ms / self.speed_ms) + animation_direction = 1 if self.is_animating == "up" else -1 + + # Current menu item + ctx.font_size = self.item_font_size + animation_progress * ( + self.focused_item_font_size - self.item_font_size + ) + + ctx.text_align = ctx.CENTER + ctx.text_baseline = ctx.MIDDLE + + ctx.rgb(1, 1, 1) + ctx.move_to( + 0, animation_direction * -30 + animation_progress * animation_direction * 30 + ).text(self.menu_items[self.position % len(self.menu_items)]) + + # Previous menu items + ctx.font_size = 20 + for i in range(1, 4): + if (self.position - i) >= 0: + ctx.move_to( + 0, + -self.focused_item_margin + + -i * self.item_line_height + - animation_direction * 30 + + animation_progress * animation_direction * 30, + ).text(self.menu_items[self.position - i]) + + # Next menu items + for i in range(1, 4): + if (self.position + i) < len(self.menu_items): + ctx.move_to( + 0, + self.focused_item_margin + + i * self.item_line_height + - animation_direction * 30 + + animation_progress * animation_direction * 30, + ).text(self.menu_items[self.position + i]) + + def update(self, delta): + if self.is_animating != "none": + self.animation_time_ms += delta + if self.animation_time_ms > self.speed_ms: + self.is_animating = "none" + self.animation_time_ms = self.speed_ms diff --git a/modules/apps/menu_demo.py b/modules/apps/menu_demo.py new file mode 100644 index 0000000..aea3f38 --- /dev/null +++ b/modules/apps/menu_demo.py @@ -0,0 +1,77 @@ +from typing import Literal + +from app import App +from app_components.menu import Menu + +main_menu_items = ["numbers", "letters", "words"] + +numbers_menu_items = ["one", "two", "three", "four", "five"] + +letters_menu_items = ["a", "b", "c", "d", "e"] + +words_menu_items = ["emfcamp", "bodgeham-on-wye", "hackers", "hexpansions", "tildagon"] + + +class MenuDemo(App): + def __init__(self): + self.current_menu = "main" + self.menu = Menu( + self, + main_menu_items, + select_handler=self.select_handler, + back_handler=self.back_handler, + ) + + def select_handler(self, item): + if item in ["numbers", "letters", "words", "main"]: + self.set_menu(item) + else: + # flash or spin the word or something + pass + + def set_menu(self, menu_name: Literal["main", "numbers", "letters", "words"]): + self.menu._cleanup() + self.current_menu = menu_name + if menu_name == "main": + self.menu = Menu( + self, + main_menu_items, + select_handler=self.select_handler, + back_handler=self.back_handler, + ) + elif menu_name == "numbers": + self.menu = Menu( + self, + numbers_menu_items, + select_handler=self.select_handler, + back_handler=self.back_handler, + ) + elif menu_name == "letters": + self.menu = Menu( + self, + letters_menu_items, + select_handler=self.select_handler, + back_handler=self.back_handler, + ) + elif menu_name == "words": + self.menu = Menu( + self, + words_menu_items, + select_handler=self.select_handler, + back_handler=self.back_handler, + ) + + def back_handler(self): + if self.current_menu == "main": + return + self.set_menu("main") + + def draw_background(self, ctx): + ctx.gray(0).rectangle(-120, -120, 240, 240).fill() + + def draw(self, ctx): + self.draw_background(ctx) + self.menu.draw(ctx) + + def update(self, delta): + self.menu.update(delta)