From fe6e5492cc6d6a189b88aad984adfc0a9138f9a8 Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Tue, 6 Aug 2024 13:52:07 +0400 Subject: [PATCH] fix: updating items of the pages after the first page not being visible refactor: merge `HeaderMenuPageWidget` and `NormalMenuPageWidget` into `MenuPageWidget` refactor: keep a clone of menu-page widget as Kivy needs the source and target widgets to be different for transitions while going up and down in a menu needs a transition of the same widget to itself as we just change its items and don't recreate it --- CHANGELOG.md | 6 + poetry.lock | 46 +- pyproject.toml | 6 +- scripts/deploy.sh | 19 +- ubo_gui/menu/__init__.py | 922 +---------------- .../menu/{transitions.py => _transitions.py} | 15 +- ubo_gui/menu/menu_widget.py | 943 ++++++++++++++++++ .../menu/widgets/header_menu_page_widget.py | 91 -- ...enu_page_widget.kv => menu_page_widget.kv} | 10 +- ubo_gui/menu/widgets/menu_page_widget.py | 113 +++ .../menu/widgets/normal_menu_page_widget.kv | 26 - .../menu/widgets/normal_menu_page_widget.py | 58 -- 12 files changed, 1118 insertions(+), 1137 deletions(-) rename ubo_gui/menu/{transitions.py => _transitions.py} (98%) create mode 100644 ubo_gui/menu/menu_widget.py delete mode 100644 ubo_gui/menu/widgets/header_menu_page_widget.py rename ubo_gui/menu/widgets/{header_menu_page_widget.kv => menu_page_widget.kv} (83%) create mode 100644 ubo_gui/menu/widgets/menu_page_widget.py delete mode 100644 ubo_gui/menu/widgets/normal_menu_page_widget.kv delete mode 100644 ubo_gui/menu/widgets/normal_menu_page_widget.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 458c8b4..ffb229d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Version 0.12.6 + +- fix: updating items of the pages after the first page not being visible +- refactor: merge `HeaderMenuPageWidget` and `NormalMenuPageWidget` into `MenuPageWidget` +- refactor: keep a clone of menu-page widget as Kivy needs the source and target widgets to be different for transitions while going up and down in a menu needs a transition of the same widget to itself as we just change its items and don't recreate it + ## Version 0.12.5 - fix: `go_home` should clear the selection of root before setting `stack` diff --git a/poetry.lock b/poetry.lock index 9e1410a..a77ee66 100644 --- a/poetry.lock +++ b/poetry.lock @@ -426,13 +426,13 @@ files = [ [[package]] name = "pyright" -version = "1.1.373" +version = "1.1.374" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.373-py3-none-any.whl", hash = "sha256:b805413227f2c209f27b14b55da27fe5e9fb84129c9f1eb27708a5d12f6f000e"}, - {file = "pyright-1.1.373.tar.gz", hash = "sha256:f41bcfc8b9d1802b09921a394d6ae1ce19694957b628bc657629688daf8a83ff"}, + {file = "pyright-1.1.374-py3-none-any.whl", hash = "sha256:55752bcf7a3646d293cd76710a983b71e16f6128aab2d42468e6eb7e46c0a70d"}, + {file = "pyright-1.1.374.tar.gz", hash = "sha256:d01b2daf864ba5e0362e56b844984865970d7204158e61eb685e2dab7804cb82"}, ] [package.dependencies] @@ -536,29 +536,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruff" -version = "0.5.4" +version = "0.5.6" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.5.4-py3-none-linux_armv6l.whl", hash = "sha256:82acef724fc639699b4d3177ed5cc14c2a5aacd92edd578a9e846d5b5ec18ddf"}, - {file = "ruff-0.5.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:da62e87637c8838b325e65beee485f71eb36202ce8e3cdbc24b9fcb8b99a37be"}, - {file = "ruff-0.5.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e98ad088edfe2f3b85a925ee96da652028f093d6b9b56b76fc242d8abb8e2059"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c55efbecc3152d614cfe6c2247a3054cfe358cefbf794f8c79c8575456efe19"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9b85eaa1f653abd0a70603b8b7008d9e00c9fa1bbd0bf40dad3f0c0bdd06793"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cf497a47751be8c883059c4613ba2f50dd06ec672692de2811f039432875278"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:09c14ed6a72af9ccc8d2e313d7acf7037f0faff43cde4b507e66f14e812e37f7"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:628f6b8f97b8bad2490240aa84f3e68f390e13fabc9af5c0d3b96b485921cd60"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3520a00c0563d7a7a7c324ad7e2cde2355733dafa9592c671fb2e9e3cd8194c1"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93789f14ca2244fb91ed481456f6d0bb8af1f75a330e133b67d08f06ad85b516"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:029454e2824eafa25b9df46882f7f7844d36fd8ce51c1b7f6d97e2615a57bbcc"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9492320eed573a13a0bc09a2957f17aa733fff9ce5bf00e66e6d4a88ec33813f"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6e1f62a92c645e2919b65c02e79d1f61e78a58eddaebca6c23659e7c7cb4ac7"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:768fa9208df2bec4b2ce61dbc7c2ddd6b1be9fb48f1f8d3b78b3332c7d71c1ff"}, - {file = "ruff-0.5.4-py3-none-win32.whl", hash = "sha256:e1e7393e9c56128e870b233c82ceb42164966f25b30f68acbb24ed69ce9c3a4e"}, - {file = "ruff-0.5.4-py3-none-win_amd64.whl", hash = "sha256:58b54459221fd3f661a7329f177f091eb35cf7a603f01d9eb3eb11cc348d38c4"}, - {file = "ruff-0.5.4-py3-none-win_arm64.whl", hash = "sha256:bd53da65f1085fb5b307c38fd3c0829e76acf7b2a912d8d79cadcdb4875c1eb7"}, - {file = "ruff-0.5.4.tar.gz", hash = "sha256:2795726d5f71c4f4e70653273d1c23a8182f07dd8e48c12de5d867bfb7557eed"}, + {file = "ruff-0.5.6-py3-none-linux_armv6l.whl", hash = "sha256:a0ef5930799a05522985b9cec8290b185952f3fcd86c1772c3bdbd732667fdcd"}, + {file = "ruff-0.5.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b652dc14f6ef5d1552821e006f747802cc32d98d5509349e168f6bf0ee9f8f42"}, + {file = "ruff-0.5.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:80521b88d26a45e871f31e4b88938fd87db7011bb961d8afd2664982dfc3641a"}, + {file = "ruff-0.5.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9bc8f328a9f1309ae80e4d392836e7dbc77303b38ed4a7112699e63d3b066ab"}, + {file = "ruff-0.5.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d394940f61f7720ad371ddedf14722ee1d6250fd8d020f5ea5a86e7be217daf"}, + {file = "ruff-0.5.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111a99cdb02f69ddb2571e2756e017a1496c2c3a2aeefe7b988ddab38b416d36"}, + {file = "ruff-0.5.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e395daba77a79f6dc0d07311f94cc0560375ca20c06f354c7c99af3bf4560c5d"}, + {file = "ruff-0.5.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c476acb43c3c51e3c614a2e878ee1589655fa02dab19fe2db0423a06d6a5b1b6"}, + {file = "ruff-0.5.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2ff8003f5252fd68425fd53d27c1f08b201d7ed714bb31a55c9ac1d4c13e2eb"}, + {file = "ruff-0.5.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c94e084ba3eaa80c2172918c2ca2eb2230c3f15925f4ed8b6297260c6ef179ad"}, + {file = "ruff-0.5.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1f77c1c3aa0669fb230b06fb24ffa3e879391a3ba3f15e3d633a752da5a3e670"}, + {file = "ruff-0.5.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f908148c93c02873210a52cad75a6eda856b2cbb72250370ce3afef6fb99b1ed"}, + {file = "ruff-0.5.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:563a7ae61ad284187d3071d9041c08019975693ff655438d8d4be26e492760bd"}, + {file = "ruff-0.5.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:94fe60869bfbf0521e04fd62b74cbca21cbc5beb67cbb75ab33fe8c174f54414"}, + {file = "ruff-0.5.6-py3-none-win32.whl", hash = "sha256:e6a584c1de6f8591c2570e171cc7ce482bb983d49c70ddf014393cd39e9dfaed"}, + {file = "ruff-0.5.6-py3-none-win_amd64.whl", hash = "sha256:d7fe7dccb1a89dc66785d7aa0ac283b2269712d8ed19c63af908fdccca5ccc1a"}, + {file = "ruff-0.5.6-py3-none-win_arm64.whl", hash = "sha256:57c6c0dd997b31b536bff49b9eee5ed3194d60605a4427f735eeb1f9c1b8d264"}, + {file = "ruff-0.5.6.tar.gz", hash = "sha256:07c9e3c2a8e1fe377dd460371c3462671a728c981c3205a5217291422209f642"}, ] [[package]] @@ -603,4 +603,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "6fbffa9a1626b223a5ef401df5d055b9d403f88cc2caf611aa82f8ebb7e07ccf" +content-hash = "3373b681a74f499f4b8836f17a65ba597e2ceac6aed22ea0579b0b01a89317d9" diff --git a/pyproject.toml b/pyproject.toml index f9d7fb8..65ac771 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,8 +19,8 @@ optional = true [tool.poetry.group.dev.dependencies] poethepoet = "^0.24.4" -pyright = "^1.1.373" -ruff = "^0.5.4" +pyright = "^1.1.374" +ruff = "^0.5.6" [build-system] requires = ["poetry-core"] @@ -32,7 +32,7 @@ typecheck = "pyright -p pyproject.toml ." sanity = ["typecheck", "lint"] download_font.shell = "mkdir -p ubo_gui/assets/fonts; wget https://github.com/google/material-design-icons/raw/master/variablefont/MaterialSymbolsOutlined%5BFILL%2CGRAD%2Copsz%2Cwght%5D.ttf -O ./ubo_gui/assets/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].ttf" -[tool.poe.tasks.deploy_to_device] +[tool.poe.tasks.deploy-to-device] cmd = 'scripts/deploy.sh' [tool.ruff] diff --git a/scripts/deploy.sh b/scripts/deploy.sh index e40d479..c27a589 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -4,7 +4,20 @@ set -e -o errexit poetry build -LATEST_VERSION=$(basename $(ls -t dist/*.whl | head -n 1)) +LATEST_VERSION=$(basename $(ls -rt dist/*.whl | tail -n 1)) -scp dist/$LATEST_VERSION pi@ubo-development-pod:/tmp/ -ssh pi@ubo-development-pod "source ubo-gui/bin/activate && pip install --upgrade --force-reinstall --no-deps /tmp/$LATEST_VERSION && (killall demo-menu -9 || true) && demo-menu" +function run_on_pod() { + if [ $# -lt 1 ]; then + echo "Usage: run_on_pod " + return 1 + fi + if [ $# -eq 1 ]; then + ssh ubo-development-pod "sudo XDG_RUNTIME_DIR=/run/user/\$(id -u ubo) -u ubo bash -c 'source \$HOME/.profile && source /etc/profile && source /opt/ubo/env/bin/activate && $1'" + return 0 + fi + return 1 +} + +scp dist/$LATEST_VERSION ubo-development-pod:/tmp/ + +run_on_pod "pip install --upgrade --force-reinstall --no-deps /tmp/$LATEST_VERSION" diff --git a/ubo_gui/menu/__init__.py b/ubo_gui/menu/__init__.py index 070f1ec..309ff46 100644 --- a/ubo_gui/menu/__init__.py +++ b/ubo_gui/menu/__init__.py @@ -1,921 +1 @@ -"""Implement a paginated menu. - -The first page starts with a heading and its sub-heading. -Each item may have sub items, in that case activating this item will open a new menu -with its sub items. -Each item can optionally be styled differently. -""" - -from __future__ import annotations - -import math -import pathlib -import threading -import uuid -import warnings -from typing import TYPE_CHECKING, Callable, Sequence, cast, overload - -from headless_kivy import HeadlessWidget -from kivy.lang.builder import Builder -from kivy.properties import ( - AliasProperty, - BooleanProperty, - ListProperty, - NumericProperty, -) -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.screenmanager import Screen, ScreenManager, TransitionBase - -from ubo_gui.logger import logger -from ubo_gui.menu.transitions import TransitionsMixin -from ubo_gui.page import PageWidget - -from .constants import PAGE_SIZE -from .stack_item import ( - VISUAL_SNAPSHOT_WIDTH, - StackApplicationItem, - StackItem, - StackMenuItem, - StackMenuItemSelection, -) -from .types import ( - ActionItem, - ApplicationItem, - HeadedMenu, - HeadlessMenu, - Item, - Menu, - SubMenuItem, - process_subscribable_value, -) -from .widgets.header_menu_page_widget import HeaderMenuPageWidget -from .widgets.normal_menu_page_widget import NormalMenuPageWidget - -if TYPE_CHECKING: - from kivy.uix.widget import Widget - - from ubo_gui.animated_slider import AnimatedSlider - - -class MenuWidget(BoxLayout, TransitionsMixin): - """Paginated menu.""" - - menu_subscriptions: set[Callable[[], None]] - menu_subscriptions_lock: threading.Lock - screen_subscriptions: set[Callable[[], None]] - screen_subscriptions_lock: threading.Lock - stack_lock: threading.Lock - - _current_menu_items: Sequence[Item] - _current_screen: Screen | None = None - _title: str | None = None - screen_manager: ScreenManager - slider: AnimatedSlider - - def __init__(self: MenuWidget, **kwargs: object) -> None: - """Initialize a `MenuWidget`.""" - self._current_menu_items = [] - self.menu_subscriptions = set() - self.menu_subscriptions_lock = threading.Lock() - self.screen_subscriptions = set() - self.screen_subscriptions_lock = threading.Lock() - self.stack_lock = threading.Lock() - super().__init__(**kwargs) - self.bind(stack=self._render) - - def __del__(self: MenuWidget) -> None: - """Clear all subscriptions.""" - self._clear_menu_subscriptions() - self._clear_screen_subscriptions() - for item in self.stack: - item.clear_subscriptions() - - def set_root_menu(self: MenuWidget, root_menu: Menu) -> None: - """Set the root menu.""" - with self.stack_lock: - if not self.stack: - self._push(root_menu, transition=self._no_transition) - else: - self._replace_menu(self.root, root_menu) - - def get_depth(self: MenuWidget) -> int: - """Return depth of the current screen.""" - return len(self.stack) - - def set_title(self: MenuWidget, title: str) -> bool: - """Set the title of the currently active menu.""" - self._title = title - return True - - def get_title(self: MenuWidget) -> str | None: - """Return the title of the currently active menu.""" - if self.current_application: - return getattr(self.current_application, 'title', None) - if self.current_menu: - return self._title - return None - - def go_down(self: MenuWidget) -> None: - """Go to the next page. - - If it is already the last page, rotate to the first page. - """ - if self.current_application: - self.current_application.go_down() - return - - if self.pages == 1: - return - if self.current_menu: - self.page_index = (self.page_index + 1) % self.pages - self._render_menu(self.current_menu) - self._switch_to( - self.current_screen, - transition=self._slide_transition, - direction='up', - ) - - def go_up(self: MenuWidget) -> None: - """Go to the previous page. - - If it is already the first page, rotate to the last page. - """ - if self.current_application: - self.current_application.go_up() - return - - if self.pages == 1: - return - if self.current_menu: - self.page_index = (self.page_index - 1) % self.pages - self._render_menu(self.current_menu) - self._switch_to( - self.current_screen, - transition=self._slide_transition, - direction='down', - ) - - def open_menu( - self: MenuWidget, - menu: Menu | Callable[[], Menu], - *, - key: str = '', - ) -> None: - """Open a menu.""" - parent = self.top - stack_item: StackMenuItem | None = None - subscription: Callable[[], None] | None = None - - def handle_menu_change(menu: Menu) -> None: - nonlocal stack_item, subscription - logger.debug( - 'Handle `sub_menu` change...', - extra={ - 'new_sub_menu': menu, - 'old_sub_menu': stack_item.menu if stack_item else None, - 'subscription_level': 'parent', - }, - ) - with self.stack_lock: - if stack_item: - stack_item = self._replace_menu(stack_item, menu) - else: - stack_item = self._push( - menu, - parent=parent, - key=key, - transition=self._slide_transition, - direction='left', - ) - if subscription: - stack_item.subscriptions.add(subscription) - - subscription = process_subscribable_value( - menu, - handle_menu_change, - ) - if stack_item: - stack_item.subscriptions.add(subscription) - - def select_action_item(self: MenuWidget, item: ActionItem) -> None: - """Select an action item.""" - result = item.action() - if not result: - return - if isinstance(result, type) and issubclass(result, PageWidget): - self.open_application(result()) - elif isinstance(result, PageWidget): - self.open_application(result) - elif isinstance(result, Menu) or callable(result): - self.open_menu(result) - else: - msg = f'Unsupported returned value by `ActionItem`: {type(result)}' - raise TypeError(msg) - - def select_application_item(self: MenuWidget, item: ApplicationItem) -> None: - """Select an application item.""" - application_instance: PageWidget | None = None - - def handle_application_change(application: type[PageWidget]) -> None: - nonlocal application_instance - logger.debug( - 'Handle `application` change...', - extra={ - 'new_application_class': application, - 'old_application_class': type(application_instance), - 'old_application': application_instance, - 'subscription_level': 'parent', - }, - ) - if application_instance: - self.close_application(application_instance) - application_instance = application() - self.open_application(application_instance) - - self.top.subscriptions.add( - process_subscribable_value( - item.application, - handle_application_change, - ), - ) - - def select_submenu_item(self: MenuWidget, item: SubMenuItem) -> None: - """Select a submenu item.""" - if item.key: - self.open_menu(item.sub_menu, key=item.key) - else: - self.open_menu(item.sub_menu) - - def select_item(self: MenuWidget, item: Item) -> None: - """Select an item. - - Parameters - ---------- - item: `Item` - The item to select - - """ - if isinstance(item, ActionItem): - self.select_action_item(item) - if isinstance(item, ApplicationItem): - self.select_application_item(item) - if isinstance(item, SubMenuItem): - self.select_submenu_item(item) - - def select(self: MenuWidget, index: int) -> None: - """Select one of the items currently visible on the screen based on its index. - - Parameters - ---------- - index: `int` - An integer number, can only take values greater than or equal to zero and - less than `PAGE_SIZE` - - """ - if not self.screen_manager.current_screen: - warnings.warn('`current_screen` is `None`', RuntimeWarning, stacklevel=1) - return - if self._is_preparation_in_progress: - return - current_page = cast(PageWidget, self.current_screen) - item = current_page.get_item(index) - if item: - self.select_item(item) - - def go_back(self: MenuWidget) -> None: - """Go back to the previous menu.""" - if self.current_application: - if not self.current_application.go_back(): - self.close_application(self.current_application) - elif self.current_menu: - with self.stack_lock: - self._pop() - - def go_home(self: MenuWidget) -> None: - """Go back to the root menu.""" - with self.stack_lock: - for item in self.stack[1:]: - item.clear_subscriptions() - if isinstance(item, StackApplicationItem): - item.application.dispatch('on_close') - self.root.selection = None - self.stack = self.stack[:1] - self._switch_to( - self.current_screen, - transition=self._rise_in_transition, - ) - - def _menu_items( - self: MenuWidget, - menu: Menu, - ) -> Sequence[Item | None]: - """Render a normal menu.""" - offset = -(PAGE_SIZE - 1) if isinstance(menu, HeadedMenu) else 0 - items: list[Item | None] = list( - self.current_menu_items[ - max(self.page_index * PAGE_SIZE + offset, 0) : self.page_index - * PAGE_SIZE - + PAGE_SIZE - + offset - ], - ) - if self.render_surroundings: - previous_item = ( - None - if self.page_index == 0 - else ( - padding_item := self.current_menu_items[ - self.page_index * PAGE_SIZE + offset - 1 - ] - ) - and Item( - label=padding_item.label, - icon=padding_item.icon, - background_color=padding_item.background_color, - is_short=padding_item.is_short, - opacity=0.6, - ) - ) - next_item = ( - None - if self.page_index == self.pages - 1 - else ( - padding_item := self.current_menu_items[ - self.page_index * PAGE_SIZE + PAGE_SIZE + offset - ] - ) - and Item( - label=padding_item.label, - icon=padding_item.icon, - background_color=padding_item.background_color, - is_short=padding_item.is_short, - opacity=0.6, - ) - ) - items = [previous_item, *items, next_item] - return items - - def _render_menu( - self: MenuWidget, - menu: Menu, - ) -> NormalMenuPageWidget | HeaderMenuPageWidget: - """Render the items of the current menu.""" - self._clear_menu_subscriptions() - if self.page_index >= self.pages: - self.page_index = self.pages - 1 - items = self._menu_items(menu) - if self.page_index == 0 and isinstance(self.current_menu, HeadedMenu): - list_widget = HeaderMenuPageWidget( - items, - name=f'Page {self.get_depth()} 0', - count=PAGE_SIZE - 2, - render_surroundings=self.render_surroundings, - padding_bottom=self.padding_bottom, - padding_top=self.padding_top, - ) - - def handle_heading_change(heading: str) -> None: - logger.debug( - 'Handle `heading` change...', - extra={ - 'new_heading': heading, - 'old_heading': list_widget.heading, - 'subscription_level': 'widget', - }, - ) - list_widget.heading = heading - - self.menu_subscriptions.add( - process_subscribable_value( - self.current_menu.heading, - handle_heading_change, - ), - ) - - def handle_sub_heading_change(sub_heading: str) -> None: - logger.debug( - 'Handle `sub_heading` change...', - extra={ - 'new_sub_heading': sub_heading, - 'old_sub_heading': list_widget.sub_heading, - 'subscription_level': 'widget', - }, - ) - list_widget.sub_heading = sub_heading - - self.menu_subscriptions.add( - process_subscribable_value( - self.current_menu.sub_heading, - handle_sub_heading_change, - ), - ) - else: - list_widget = NormalMenuPageWidget( - items, - name=f'Page {self.get_depth()} 0', - count=PAGE_SIZE, - render_surroundings=self.render_surroundings, - padding_bottom=self.padding_bottom, - padding_top=self.padding_top, - ) - - self.current_screen = list_widget - - return list_widget - - def _render(self: MenuWidget, *_: object) -> None: - """Return the current screen page.""" - self._clear_screen_subscriptions() - - if not self.stack: - return - - title = None - if isinstance(self.top, StackApplicationItem): - self.current_screen = self.top.application - title = self.top.application.title - if isinstance(self.top, StackMenuItem): - menu = self.top.menu - last_items = None - menu_widget: NormalMenuPageWidget | HeaderMenuPageWidget | None = None - placeholder = None - - def handle_items_change(items: Sequence[Item]) -> None: - nonlocal last_items, menu_widget - logger.debug( - 'Handle `items` change...', - extra={ - 'new_items': items, - 'old_items': last_items, - 'subscription_level': 'screen', - }, - ) - self.current_menu_items = items - if menu_widget is None: - menu_widget = self._render_menu(menu) - else: - menu_widget.items = self._menu_items(menu) - menu_widget.placeholder = placeholder - last_items = items - - self.screen_subscriptions.add( - process_subscribable_value(menu.items, handle_items_change), - ) - - def handle_placeholder_change(new_placeholder: str | None) -> None: - nonlocal placeholder - logger.debug( - 'Handle `placeholder` change...', - extra={ - 'new_placeholder': new_placeholder, - 'old_placeholder': placeholder, - 'subscription_level': 'widget', - }, - ) - if menu_widget: - placeholder = new_placeholder - menu_widget.placeholder = placeholder - - self.menu_subscriptions.add( - process_subscribable_value( - menu.placeholder, - handle_placeholder_change, - ), - ) - - title = menu.title - - def handle_title_change(title: str | None) -> None: - logger.debug( - 'Handle `title` change...', - extra={ - 'new_title': title, - 'old_title': self.title, - 'subscription_level': 'screen', - }, - ) - self.title = title - - self.screen_subscriptions.add( - process_subscribable_value(title, handle_title_change), - ) - - def get_current_screen(self: MenuWidget) -> Screen | None: - """Return current screen.""" - return self._current_screen - - def _get_current_screen(self: MenuWidget) -> Screen | None: - """Workaround for `AliasProperty` not working with overridden getters.""" - return self.get_current_screen() - - def set_current_screen(self: MenuWidget, screen: Screen) -> bool: - """Set the current screen page.""" - self._current_screen = screen - return True - - def open_application(self: MenuWidget, application: PageWidget) -> None: - """Open an application.""" - with self.stack_lock: - headless_widget = HeadlessWidget.get_instance(self) - if headless_widget: - headless_widget.activate_high_fps_mode() - application.name = uuid.uuid4().hex - application.padding_bottom = self.padding_bottom - application.padding_top = self.padding_top - self._push( - application, - transition=self._swap_transition, - duration=0.2, - direction='left', - ) - - def close_application(self: MenuWidget, application: PageWidget) -> None: - """Close an application after its `on_close` event is fired.""" - # Remove `application` and all applications in the stack with their `root` being - # `application` from stack and clear their bindings and subscriptions. - # If any of these applications are the top of the stack, remove it with `pop` to - # ensure the animation is played. - with self.stack_lock: - if any( - isinstance(item.root, StackApplicationItem) - and item.root.application is application - for item in self.stack - ): - to_be_removed = [ - cast(StackApplicationItem, item) - for item in self.stack - if isinstance(item.root, StackApplicationItem) - and item.root.application is application - and item is not self.top - ] - - for item in to_be_removed: - item.clear_subscriptions() - item.application.dispatch('on_close') - - self.stack = [item for item in self.stack if item not in to_be_removed] - - if ( - isinstance(self.top.root, StackApplicationItem) - and self.top.root.application is application - ): - self._pop() - - @property - def root(self: MenuWidget) -> StackMenuItem: - """Return the root item.""" - if isinstance(self.stack[0], StackMenuItem): - return self.stack[0] - msg = 'root is not a `StackMenuItem`' - raise ValueError(msg) - - @property - def top(self: MenuWidget) -> StackItem: - """Return the top item of the stack.""" - if not self.stack: - msg = 'stack is empty' - raise IndexError(msg) - return self.stack[-1] - - @property - def _visual_snapshot(self: MenuWidget) -> list[str]: - start = [ - '╭', - '│', - '│', - '│', - '╰', - ] - end = [ - '╮', - '│', - '│', - '│', - '╯', - ] - - cross_repeats = VISUAL_SNAPSHOT_WIDTH // 3 - cross = [ - '───' * cross_repeats, - '╲ ╱' * cross_repeats, # noqa: RUF001 - ' ╳ ' * cross_repeats, # noqa: RUF001 - '╱ ╲' * cross_repeats, # noqa: RUF001 - '───' * cross_repeats, - ] - - def append(item: list[str]) -> None: - for i in range(5): - output[-(5 - i)] += item[i] if i < len(item) else ' ' * len(item[0]) - - output = [] - for start_item in range(0, len(self.stack), 5): - output.extend([''] * 5) - for item in self.stack[start_item : min(start_item + 5, len(self.stack))]: - append(start) - append(item.visual_snapshot) - append(end) - append( - [ - f' {type(item).__name__} {item.title[:VISUAL_SNAPSHOT_WIDTH]} ' - for item in self.stack[ - start_item : min(start_item + 5, len(self.stack)) - ] - ], - ) - - output.extend([''] * 5) - for item in self.stack[start_item : min(start_item + 5, len(self.stack))]: - append(start) - append(item.parent.visual_snapshot if item.parent else cross) - append(end) - - output.extend([''] * 5) - for item in self.stack[start_item : min(start_item + 5, len(self.stack))]: - append(start) - append( - item.selection.item.visual_snapshot - if isinstance(item, StackMenuItem) and item.selection - else cross, - ) - append(end) - if len(self.stack) % 5 != 0: - for _ in range(5 - (len(self.stack) % 5)): - append([' ' * (VISUAL_SNAPSHOT_WIDTH + 1)] * 5) - - return output - - def _replace_menu( - self: MenuWidget, - stack_item: StackMenuItem, - menu: Menu, - *, - parent: StackItem | None = None, - ) -> StackMenuItem: - """Replace the current menu or application.""" - if stack_item not in self.stack: - msg = '`stack_item` not found in stack' - raise ValueError(msg) from None - if stack_item.selection: - items = [ - item - for item in (menu.items() if callable(menu.items) else menu.items) - if isinstance(item, SubMenuItem) - and item.key == stack_item.selection.key - ] - if len(items) == 1: - selection = items[0] - elif len(items) > 1: - msg = f'Found more than one item with key: {stack_item.selection.key}' - raise ValueError(msg) - else: - selection = None - else: - selection = None - index = self.stack.index(stack_item) - new_item = self.stack[index] = StackMenuItem( - menu=menu, - page_index=stack_item.page_index, - parent=parent or stack_item.parent, - subscriptions=stack_item.subscriptions, - ) - new_item.selection = ( - None - if selection is None - or stack_item.selection is None - or not isinstance(selection.sub_menu, Menu) - else StackMenuItemSelection( - key=stack_item.selection.key, - item=self._replace_menu( - stack_item.selection.item, - selection.sub_menu, - parent=new_item, - ), - ) - ) - - if new_item is self.top: - self._switch_to( - self.current_screen, - transition=self._no_transition, - ) - return new_item - - @overload - def _push( - self: MenuWidget, - item: Menu, - /, - *, - transition: TransitionBase | None, - duration: float | None = None, - direction: str | None = None, - parent: StackItem | None = None, - key: str | None = None, - ) -> StackMenuItem: ... - @overload - def _push( # pyright: ignore[reportOverlappingOverload] - self: MenuWidget, - item: PageWidget, - /, - *, - transition: TransitionBase | None, - duration: float | None = None, - direction: str | None = None, - parent: StackItem | None = None, - key: str | None = None, - ) -> StackApplicationItem: ... - def _push( # noqa: PLR0913 - self: MenuWidget, - item: Menu | PageWidget, - /, - *, - transition: TransitionBase | None, - duration: float | None = None, - direction: str | None = None, - parent: StackItem | None = None, - key: str | None = None, - ) -> StackItem: - """Go one level deeper in the menu stack.""" - if isinstance(item, Menu): - new_top = StackMenuItem(menu=item, page_index=0, parent=parent) - elif isinstance(item, PageWidget): - new_top = StackApplicationItem(application=item, parent=parent) - else: - msg = f'Unsupported type: {type(item)}' - raise TypeError(msg) - - if isinstance(parent, StackMenuItem) and isinstance(new_top, StackMenuItem): - parent.selection = StackMenuItemSelection(key=key or '', item=new_top) - - self.stack = [*self.stack, new_top] - - self._switch_to( - self.current_screen, - transition=transition, - duration=duration, - direction=direction, - ) - - return new_top - - def _pop( - self: MenuWidget, - /, - *, - transition: TransitionBase | None = None, - duration: float | None = None, - direction: str | None = 'right', - keep_subscriptions: bool = False, - ) -> None: - """Come up one level from of the menu stack.""" - if self.depth == 1: - return - popping_item = self.top - if not keep_subscriptions: - popping_item.clear_subscriptions() - if isinstance(popping_item, StackApplicationItem): - popping_item.application.dispatch('on_close') - if isinstance(popping_item.parent, StackMenuItem): - popping_item.parent.selection = None - - *self.stack, _ = self.stack - - target = self.top - - transition_ = self._slide_transition - if isinstance(target, StackApplicationItem) or self.current_application: - transition_ = self._swap_transition - self._switch_to( - self.current_screen, - transition=transition or transition_, - duration=duration, - direction=direction, - ) - - def get_is_scrollbar_visible(self: MenuWidget) -> bool: - """Return whether scroll-bar is needed or not.""" - return not self.current_application and self.pages > 1 - - def on_kv_post(self: MenuWidget, base_widget: Widget) -> None: - """Run after the widget is fully constructed.""" - _ = base_widget - self.screen_manager = cast(ScreenManager, self.ids.screen_manager) - self.slider = self.ids.slider - - def _clear_menu_subscriptions(self: MenuWidget) -> None: - """Clear widget subscriptions.""" - with self.menu_subscriptions_lock: - subscriptions = self.menu_subscriptions.copy() - self.menu_subscriptions.clear() - for unsubscribe in subscriptions: - unsubscribe() - - def _clear_screen_subscriptions(self: MenuWidget) -> None: - """Clear screen subscriptions.""" - # lock the mutex to do it atomic - with self.screen_subscriptions_lock: - subscriptions = self.screen_subscriptions.copy() - self.screen_subscriptions.clear() - for unsubscribe in subscriptions: - unsubscribe() - - def get_current_application(self: MenuWidget) -> PageWidget | None: - """Return the current application.""" - if self.stack and isinstance(self.top, StackApplicationItem): - return self.top.application - return None - - def get_current_menu(self: MenuWidget) -> Menu | None: - """Return the current menu.""" - if self.stack and isinstance(self.top, StackMenuItem): - return self.top.menu - return None - - def get_page_index(self: MenuWidget) -> int: - """Return the current page index.""" - if self.stack and isinstance(self.top, StackMenuItem): - return self.top.page_index - return 0 - - def set_page_index(self: MenuWidget, page_index: int) -> bool: - """Set the current page index.""" - if self.stack and isinstance(self.top, StackMenuItem): - if self.top.page_index != page_index: - self.top.page_index = page_index - return True - return False - return True - - def get_pages(self: MenuWidget) -> int: - """Return the number of pages of the currently active menu.""" - if isinstance(self.current_menu, HeadedMenu): - return max(math.ceil((len(self.current_menu_items) + 2) / 3), 1) - if isinstance(self.current_menu, HeadlessMenu): - return max(math.ceil(len(self.current_menu_items) / 3), 1) - return 0 - - def get_current_menu_items(self: MenuWidget) -> Sequence[Item] | None: - """Return current menu items.""" - return self._current_menu_items - - def set_current_menu_items(self: MenuWidget, items: Sequence[Item]) -> bool: - """Set current menu items.""" - self._current_menu_items = items - self.slider.value = self.get_pages() - 1 - self.page_index - return True - - stack: list[StackItem] = ListProperty() - title = AliasProperty( - getter=get_title, - setter=set_title, - bind=['stack'], - ) - depth: int = AliasProperty(getter=get_depth, bind=['stack'], cache=True) - pages: int = AliasProperty( - getter=get_pages, - bind=['current_menu_items', 'current_menu'], - cache=True, - ) - page_index = AliasProperty( - getter=get_page_index, - setter=set_page_index, - bind=['current_menu_items'], - ) - current_menu_items: Sequence[Item] = AliasProperty( - getter=get_current_menu_items, - setter=set_current_menu_items, - ) - current_application: PageWidget | None = AliasProperty( - getter=get_current_application, - bind=['stack'], - cache=True, - ) - current_menu: Menu | None = AliasProperty( - getter=get_current_menu, - bind=['stack'], - cache=True, - ) - current_screen: Screen | None = AliasProperty( - getter=_get_current_screen, - setter=set_current_screen, - ) - is_scrollbar_visible = AliasProperty( - getter=get_is_scrollbar_visible, - bind=['pages'], - cache=True, - ) - render_surroundings = BooleanProperty( - defaultvalue=False, - cache=True, - ) - padding_bottom = NumericProperty(defaultvalue=0) - padding_top = NumericProperty(defaultvalue=0) - - def __repr__(self: MenuWidget) -> str: - """Return a string representation of the widget.""" - return '\n'.join(self._visual_snapshot) - - -Builder.load_file( - pathlib.Path(__file__).parent.joinpath('menu.kv').resolve().as_posix(), -) +"""`MenuWidget` package.""" diff --git a/ubo_gui/menu/transitions.py b/ubo_gui/menu/_transitions.py similarity index 98% rename from ubo_gui/menu/transitions.py rename to ubo_gui/menu/_transitions.py index 1500316..9287526 100644 --- a/ubo_gui/menu/transitions.py +++ b/ubo_gui/menu/_transitions.py @@ -114,6 +114,7 @@ def _swap_transition(self: TransitionsMixin) -> SwapTransition: self._setup_transition(transition) return transition + @mainthread def _perform_switch( self: TransitionsMixin, screen: Screen | None, @@ -125,7 +126,7 @@ def _perform_switch( ) -> None: if duration is None: duration = 0.2 - mainthread(self.screen_manager.switch_to)( + self.screen_manager.switch_to( screen, transition=transition, duration=duration, @@ -148,7 +149,12 @@ def _switch_to( if duration is None: duration = 0 if transition is self._no_transition else 0.3 with self._transition_progress_lock: - if not self._is_transition_in_progress: + if self._is_transition_in_progress: + self.transition_queue = [ + *self.transition_queue, + (screen, transition, direction, duration), + ] + else: if isinstance(self, Widget): headless_widget = HeadlessWidget.get_instance(self) if headless_widget: @@ -161,8 +167,3 @@ def _switch_to( duration=duration, direction=direction, ) - else: - self.transition_queue = [ - *self.transition_queue, - (screen, transition, direction, duration), - ] diff --git a/ubo_gui/menu/menu_widget.py b/ubo_gui/menu/menu_widget.py new file mode 100644 index 0000000..813c86d --- /dev/null +++ b/ubo_gui/menu/menu_widget.py @@ -0,0 +1,943 @@ +"""Implement a paginated menu. + +The first page starts with a heading and its sub-heading. +Each item may have sub items, in that case activating this item will open a new menu +with its sub items. +Each item can optionally be styled differently. +""" + +from __future__ import annotations + +import math +import pathlib +import threading +import uuid +import warnings +from typing import TYPE_CHECKING, Callable, Sequence, cast, overload + +from headless_kivy import HeadlessWidget +from kivy.clock import mainthread +from kivy.lang.builder import Builder +from kivy.properties import ( + AliasProperty, + BooleanProperty, + ListProperty, + NumericProperty, +) +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.screenmanager import Screen, ScreenManager, TransitionBase + +from ubo_gui.logger import logger +from ubo_gui.menu._transitions import TransitionsMixin +from ubo_gui.page import PageWidget + +from .constants import PAGE_SIZE +from .stack_item import ( + VISUAL_SNAPSHOT_WIDTH, + StackApplicationItem, + StackItem, + StackMenuItem, + StackMenuItemSelection, +) +from .types import ( + ActionItem, + ApplicationItem, + HeadedMenu, + HeadlessMenu, + Item, + Menu, + SubMenuItem, + process_subscribable_value, +) +from .widgets.menu_page_widget import MenuPageWidget + +if TYPE_CHECKING: + from kivy.uix.widget import Widget + + from ubo_gui.animated_slider import AnimatedSlider + + +class MenuWidget(BoxLayout, TransitionsMixin): + """Paginated menu.""" + + menu_subscriptions: set[Callable[[], None]] + menu_subscriptions_lock: threading.Lock + screen_subscriptions: set[Callable[[], None]] + screen_subscriptions_lock: threading.Lock + stack_lock: threading.Lock + + _current_menu_items: Sequence[Item] + _current_screen: Screen | None = None + _title: str | None = None + screen_manager: ScreenManager + slider: AnimatedSlider + + def __init__(self: MenuWidget, **kwargs: object) -> None: + """Initialize a `MenuWidget`.""" + self._current_menu_items = [] + self.menu_subscriptions = set() + self.menu_subscriptions_lock = threading.Lock() + self.screen_subscriptions = set() + self.screen_subscriptions_lock = threading.Lock() + self.stack_lock = threading.Lock() + super().__init__(**kwargs) + self.bind(stack=self._render) + + def __del__(self: MenuWidget) -> None: + """Clear all subscriptions.""" + self._clear_menu_subscriptions() + self._clear_screen_subscriptions() + for item in self.stack: + item.clear_subscriptions() + + def set_root_menu(self: MenuWidget, root_menu: Menu) -> None: + """Set the root menu.""" + with self.stack_lock: + if not self.stack: + self._push(root_menu, transition=self._no_transition) + else: + self._replace_menu(self.root, root_menu) + + def get_depth(self: MenuWidget) -> int: + """Return depth of the current screen.""" + return len(self.stack) + + def set_title(self: MenuWidget, title: str) -> bool: + """Set the title of the currently active menu.""" + self._title = title + return True + + def get_title(self: MenuWidget) -> str | None: + """Return the title of the currently active menu.""" + if self.current_application: + return getattr(self.current_application, 'title', None) + if self.current_menu: + return self._title + return None + + @mainthread + def go_down(self: MenuWidget) -> None: + """Go to the next page. + + If it is already the last page, rotate to the first page. + """ + if self.current_application: + self.current_application.go_down() + return + + if self.pages == 1: + return + + if self.current_menu: + menu_page = cast(PageWidget, self.current_screen) + + menu_page.clone.page_index = self.page_index = ( + self.page_index + 1 + ) % self.pages + menu_page.clone.items = self._menu_items(self.current_menu) + + self._switch_to( + menu_page.clone, + transition=self._slide_transition, + direction='up', + ) + self.current_screen = menu_page.clone + + @mainthread + def go_up(self: MenuWidget) -> None: + """Go to the previous page. + + If it is already the first page, rotate to the last page. + """ + if self.current_application: + self.current_application.go_up() + return + + if self.pages == 1: + return + + if self.current_menu: + menu_page = cast(PageWidget, self.current_screen) + + menu_page.clone.page_index = self.page_index = ( + self.page_index - 1 + ) % self.pages + menu_page.clone.items = self._menu_items(self.current_menu) + + self._switch_to( + menu_page.clone, + transition=self._slide_transition, + direction='down', + ) + self.current_screen = menu_page.clone + + def open_menu( + self: MenuWidget, + menu: Menu | Callable[[], Menu], + *, + key: str = '', + ) -> None: + """Open a menu.""" + parent = self.top + stack_item: StackMenuItem | None = None + subscription: Callable[[], None] | None = None + + def handle_menu_change(menu: Menu) -> None: + nonlocal stack_item, subscription + logger.debug( + 'Handle `sub_menu` change...', + extra={ + 'new_sub_menu': menu, + 'old_sub_menu': stack_item.menu if stack_item else None, + 'subscription_level': 'parent', + }, + ) + with self.stack_lock: + if stack_item: + stack_item = self._replace_menu(stack_item, menu) + else: + stack_item = self._push( + menu, + parent=parent, + key=key, + transition=self._slide_transition, + direction='left', + ) + if subscription: + stack_item.subscriptions.add(subscription) + + subscription = process_subscribable_value( + menu, + handle_menu_change, + ) + if stack_item: + stack_item.subscriptions.add(subscription) + + def select_action_item(self: MenuWidget, item: ActionItem) -> None: + """Select an action item.""" + result = item.action() + if not result: + return + if isinstance(result, type) and issubclass(result, PageWidget): + self.open_application(result()) + elif isinstance(result, PageWidget): + self.open_application(result) + elif isinstance(result, Menu) or callable(result): + self.open_menu(result) + else: + msg = f'Unsupported returned value by `ActionItem`: {type(result)}' + raise TypeError(msg) + + def select_application_item(self: MenuWidget, item: ApplicationItem) -> None: + """Select an application item.""" + application_instance: PageWidget | None = None + + def handle_application_change(application: type[PageWidget]) -> None: + nonlocal application_instance + logger.debug( + 'Handle `application` change...', + extra={ + 'new_application_class': application, + 'old_application_class': type(application_instance), + 'old_application': application_instance, + 'subscription_level': 'parent', + }, + ) + if application_instance: + self.close_application(application_instance) + application_instance = application() + self.open_application(application_instance) + + self.top.subscriptions.add( + process_subscribable_value( + item.application, + handle_application_change, + ), + ) + + def select_submenu_item(self: MenuWidget, item: SubMenuItem) -> None: + """Select a submenu item.""" + if item.key: + self.open_menu(item.sub_menu, key=item.key) + else: + self.open_menu(item.sub_menu) + + def select_item(self: MenuWidget, item: Item) -> None: + """Select an item. + + Parameters + ---------- + item: `Item` + The item to select + + """ + if isinstance(item, ActionItem): + self.select_action_item(item) + if isinstance(item, ApplicationItem): + self.select_application_item(item) + if isinstance(item, SubMenuItem): + self.select_submenu_item(item) + + def select(self: MenuWidget, index: int) -> None: + """Select one of the items currently visible on the screen based on its index. + + Parameters + ---------- + index: `int` + An integer number, can only take values greater than or equal to zero and + less than `PAGE_SIZE` + + """ + if not self.screen_manager.current_screen: + warnings.warn('`current_screen` is `None`', RuntimeWarning, stacklevel=1) + return + if self._is_preparation_in_progress: + return + current_page = cast(PageWidget, self.current_screen) + item = current_page.get_item(index) + if item: + self.select_item(item) + + def go_back(self: MenuWidget) -> None: + """Go back to the previous menu.""" + if self.current_application: + if not self.current_application.go_back(): + self.close_application(self.current_application) + elif self.current_menu: + with self.stack_lock: + self._pop() + + def go_home(self: MenuWidget) -> None: + """Go back to the root menu.""" + with self.stack_lock: + for item in self.stack[1:]: + item.clear_subscriptions() + if isinstance(item, StackApplicationItem): + item.application.dispatch('on_close') + self.root.selection = None + self.stack = self.stack[:1] + self._switch_to( + self.current_screen, + transition=self._rise_in_transition, + ) + + def _menu_items( + self: MenuWidget, + menu: Menu, + ) -> Sequence[Item | None]: + """Render a normal menu.""" + if self.page_index >= self.pages: + self.page_index = self.pages - 1 + offset = -(PAGE_SIZE - 1) if isinstance(menu, HeadedMenu) else 0 + items: list[Item | None] = list( + self.current_menu_items[ + max(self.page_index * PAGE_SIZE + offset, 0) : self.page_index + * PAGE_SIZE + + PAGE_SIZE + + offset + ], + ) + if self.render_surroundings: + previous_item = ( + None + if self.page_index == 0 + else ( + padding_item := self.current_menu_items[ + self.page_index * PAGE_SIZE + offset - 1 + ] + ) + and Item( + label=padding_item.label, + icon=padding_item.icon, + background_color=padding_item.background_color, + is_short=padding_item.is_short, + opacity=0.6, + ) + ) + next_item = ( + None + if self.page_index == self.pages - 1 + else ( + padding_item := self.current_menu_items[ + self.page_index * PAGE_SIZE + PAGE_SIZE + offset + ] + ) + and Item( + label=padding_item.label, + icon=padding_item.icon, + background_color=padding_item.background_color, + is_short=padding_item.is_short, + opacity=0.6, + ) + ) + items = [previous_item, *items, next_item] + return items + + def _render_menu(self: MenuWidget, menu: Menu) -> MenuPageWidget: + """Render the items of the current menu.""" + self._clear_menu_subscriptions() + if self.page_index >= self.pages: + self.page_index = self.pages - 1 + items = self._menu_items(menu) + + list_widget = MenuPageWidget( + items, + page_index=self.page_index, + name=f'Page {self.get_depth()} {self.page_index}', + count=PAGE_SIZE, + render_surroundings=self.render_surroundings, + padding_bottom=self.padding_bottom, + padding_top=self.padding_top, + ) + + if isinstance(self.current_menu, HeadedMenu): + + def handle_heading_change(heading: str) -> None: + logger.debug( + 'Handle `heading` change...', + extra={ + 'new_heading': heading, + 'old_heading': list_widget.heading, + 'subscription_level': 'widget', + }, + ) + list_widget.heading = heading + + self.menu_subscriptions.add( + process_subscribable_value( + self.current_menu.heading, + handle_heading_change, + ), + ) + + def handle_sub_heading_change(sub_heading: str) -> None: + logger.debug( + 'Handle `sub_heading` change...', + extra={ + 'new_sub_heading': sub_heading, + 'old_sub_heading': list_widget.sub_heading, + 'subscription_level': 'widget', + }, + ) + list_widget.sub_heading = sub_heading + + self.menu_subscriptions.add( + process_subscribable_value( + self.current_menu.sub_heading, + handle_sub_heading_change, + ), + ) + + return list_widget + + def _render(self: MenuWidget, *_: object) -> None: + """Return the current screen page.""" + self._clear_screen_subscriptions() + + if not self.stack: + return + + title = None + if isinstance(self.top, StackApplicationItem): + self.current_screen = self.top.application + title = self.top.application.title + if isinstance(self.top, StackMenuItem): + menu = self.top.menu + last_items = None + menu_page: MenuPageWidget | None = None + placeholder = None + + def handle_items_change(items: Sequence[Item]) -> None: + nonlocal last_items, menu_page + logger.debug( + 'Handle `items` change...', + extra={ + 'new_items': items, + 'old_items': last_items, + 'subscription_level': 'screen', + }, + ) + self.current_menu_items = items + if menu_page is None: + menu_page = self._render_menu(menu) + # The clone here is solely needed for the visual transitions between + # menu pages, when `page_index` increases or decreases, the slide + # down/up transition needs a source and a target. So a clone of the + # original page is needed. If `ScreenManager` supported transition + # from a screen to itself we probably wouldn't need this. + menu_page.clone = self._render_menu(menu) + menu_page.clone.clone = menu_page + self.current_screen = menu_page + else: + if self.page_index >= self.pages: + menu_page.page_index = self.page_index = self.pages - 1 + menu_page.clone.page_index = self.page_index = self.pages - 1 + menu_page.items = self._menu_items(menu) + menu_page.clone.items = self._menu_items(menu) + menu_page.placeholder = placeholder + menu_page.clone.placeholder = placeholder + last_items = items + + self.screen_subscriptions.add( + process_subscribable_value(menu.items, handle_items_change), + ) + + def handle_placeholder_change(new_placeholder: str | None) -> None: + nonlocal placeholder + logger.debug( + 'Handle `placeholder` change...', + extra={ + 'new_placeholder': new_placeholder, + 'old_placeholder': placeholder, + 'subscription_level': 'widget', + }, + ) + placeholder = new_placeholder + if menu_page: + menu_page.placeholder = placeholder + + self.menu_subscriptions.add( + process_subscribable_value( + menu.placeholder, + handle_placeholder_change, + ), + ) + + title = menu.title + + def handle_title_change(title: str | None) -> None: + logger.debug( + 'Handle `title` change...', + extra={ + 'new_title': title, + 'old_title': self.title, + 'subscription_level': 'screen', + }, + ) + self.title = title + + self.screen_subscriptions.add( + process_subscribable_value(title, handle_title_change), + ) + + def get_current_screen(self: MenuWidget) -> Screen | None: + """Return current screen.""" + return self._current_screen + + def _get_current_screen(self: MenuWidget) -> Screen | None: + """Workaround for `AliasProperty` not working with overridden getters.""" + return self.get_current_screen() + + def set_current_screen(self: MenuWidget, screen: Screen) -> bool: + """Set the current screen page.""" + self._current_screen = screen + return True + + def open_application(self: MenuWidget, application: PageWidget) -> None: + """Open an application.""" + with self.stack_lock: + headless_widget = HeadlessWidget.get_instance(self) + if headless_widget: + headless_widget.activate_high_fps_mode() + application.name = uuid.uuid4().hex + application.padding_bottom = self.padding_bottom + application.padding_top = self.padding_top + self._push( + application, + transition=self._swap_transition, + duration=0.2, + direction='left', + ) + + def close_application(self: MenuWidget, application: PageWidget) -> None: + """Close an application after its `on_close` event is fired.""" + # Remove `application` and all applications in the stack with their `root` being + # `application` from stack and clear their bindings and subscriptions. + # If any of these applications are the top of the stack, remove it with `pop` to + # ensure the animation is played. + with self.stack_lock: + if any( + isinstance(item.root, StackApplicationItem) + and item.root.application is application + for item in self.stack + ): + to_be_removed = [ + cast(StackApplicationItem, item) + for item in self.stack + if isinstance(item.root, StackApplicationItem) + and item.root.application is application + and item is not self.top + ] + + for item in to_be_removed: + item.clear_subscriptions() + item.application.dispatch('on_close') + + self.stack = [item for item in self.stack if item not in to_be_removed] + + if ( + isinstance(self.top.root, StackApplicationItem) + and self.top.root.application is application + ): + self._pop() + + @property + def root(self: MenuWidget) -> StackMenuItem: + """Return the root item.""" + if isinstance(self.stack[0], StackMenuItem): + return self.stack[0] + msg = 'root is not a `StackMenuItem`' + raise ValueError(msg) + + @property + def top(self: MenuWidget) -> StackItem: + """Return the top item of the stack.""" + if not self.stack: + msg = 'stack is empty' + raise IndexError(msg) + return self.stack[-1] + + @property + def _visual_snapshot(self: MenuWidget) -> list[str]: + start = [ + '╭', + '│', + '│', + '│', + '╰', + ] + end = [ + '╮', + '│', + '│', + '│', + '╯', + ] + + cross_repeats = VISUAL_SNAPSHOT_WIDTH // 3 + cross = [ + '───' * cross_repeats, + '╲ ╱' * cross_repeats, # noqa: RUF001 + ' ╳ ' * cross_repeats, # noqa: RUF001 + '╱ ╲' * cross_repeats, # noqa: RUF001 + '───' * cross_repeats, + ] + + def append(item: list[str]) -> None: + for i in range(5): + output[-(5 - i)] += item[i] if i < len(item) else ' ' * len(item[0]) + + output = [] + for start_item in range(0, len(self.stack), 5): + output.extend([''] * 5) + for item in self.stack[start_item : min(start_item + 5, len(self.stack))]: + append(start) + append(item.visual_snapshot) + append(end) + append( + [ + f' {type(item).__name__} {item.title[:VISUAL_SNAPSHOT_WIDTH]} ' + for item in self.stack[ + start_item : min(start_item + 5, len(self.stack)) + ] + ], + ) + + output.extend([''] * 5) + for item in self.stack[start_item : min(start_item + 5, len(self.stack))]: + append(start) + append(item.parent.visual_snapshot if item.parent else cross) + append(end) + + output.extend([''] * 5) + for item in self.stack[start_item : min(start_item + 5, len(self.stack))]: + append(start) + append( + item.selection.item.visual_snapshot + if isinstance(item, StackMenuItem) and item.selection + else cross, + ) + append(end) + if len(self.stack) % 5 != 0: + for _ in range(5 - (len(self.stack) % 5)): + append([' ' * (VISUAL_SNAPSHOT_WIDTH + 1)] * 5) + + return output + + def _replace_menu( + self: MenuWidget, + stack_item: StackMenuItem, + menu: Menu, + *, + parent: StackItem | None = None, + ) -> StackMenuItem: + """Replace the current menu or application.""" + if stack_item not in self.stack: + msg = '`stack_item` not found in stack' + raise ValueError(msg) from None + if stack_item.selection: + items = [ + item + for item in (menu.items() if callable(menu.items) else menu.items) + if isinstance(item, SubMenuItem) + and item.key == stack_item.selection.key + ] + if len(items) == 1: + selection = items[0] + elif len(items) > 1: + msg = f'Found more than one item with key: {stack_item.selection.key}' + raise ValueError(msg) + else: + selection = None + else: + selection = None + index = self.stack.index(stack_item) + new_item = self.stack[index] = StackMenuItem( + menu=menu, + page_index=stack_item.page_index, + parent=parent or stack_item.parent, + subscriptions=stack_item.subscriptions, + ) + new_item.selection = ( + None + if selection is None + or stack_item.selection is None + or not isinstance(selection.sub_menu, Menu) + else StackMenuItemSelection( + key=stack_item.selection.key, + item=self._replace_menu( + stack_item.selection.item, + selection.sub_menu, + parent=new_item, + ), + ) + ) + + if new_item is self.top: + self._switch_to(self.current_screen, transition=self._no_transition) + return new_item + + @overload + def _push( + self: MenuWidget, + item: Menu, + /, + *, + transition: TransitionBase | None, + duration: float | None = None, + direction: str | None = None, + parent: StackItem | None = None, + key: str | None = None, + ) -> StackMenuItem: ... + @overload + def _push( # pyright: ignore[reportOverlappingOverload] + self: MenuWidget, + item: PageWidget, + /, + *, + transition: TransitionBase | None, + duration: float | None = None, + direction: str | None = None, + parent: StackItem | None = None, + key: str | None = None, + ) -> StackApplicationItem: ... + def _push( # noqa: PLR0913 + self: MenuWidget, + item: Menu | PageWidget, + /, + *, + transition: TransitionBase | None, + duration: float | None = None, + direction: str | None = None, + parent: StackItem | None = None, + key: str | None = None, + ) -> StackItem: + """Go one level deeper in the menu stack.""" + if isinstance(item, Menu): + new_top = StackMenuItem(menu=item, page_index=0, parent=parent) + elif isinstance(item, PageWidget): + new_top = StackApplicationItem(application=item, parent=parent) + else: + msg = f'Unsupported type: {type(item)}' + raise TypeError(msg) + + if isinstance(parent, StackMenuItem) and isinstance(new_top, StackMenuItem): + parent.selection = StackMenuItemSelection(key=key or '', item=new_top) + + self.stack = [*self.stack, new_top] + + self._switch_to( + self.current_screen, + transition=transition, + duration=duration, + direction=direction, + ) + + return new_top + + def _pop( + self: MenuWidget, + /, + *, + transition: TransitionBase | None = None, + duration: float | None = None, + direction: str | None = 'right', + keep_subscriptions: bool = False, + ) -> None: + """Come up one level from of the menu stack.""" + if self.depth == 1: + return + popping_item = self.top + if not keep_subscriptions: + popping_item.clear_subscriptions() + if isinstance(popping_item, StackApplicationItem): + popping_item.application.dispatch('on_close') + if isinstance(popping_item.parent, StackMenuItem): + popping_item.parent.selection = None + + *self.stack, _ = self.stack + + target = self.top + + transition_ = self._slide_transition + if isinstance(target, StackApplicationItem) or self.current_application: + transition_ = self._swap_transition + self._switch_to( + self.current_screen, + transition=transition or transition_, + duration=duration, + direction=direction, + ) + + def get_is_scrollbar_visible(self: MenuWidget) -> bool: + """Return whether scroll-bar is needed or not.""" + return not self.current_application and self.pages > 1 + + def on_kv_post(self: MenuWidget, base_widget: Widget) -> None: + """Run after the widget is fully constructed.""" + _ = base_widget + self.screen_manager = cast(ScreenManager, self.ids.screen_manager) + self.slider = self.ids.slider + + def _clear_menu_subscriptions(self: MenuWidget) -> None: + """Clear widget subscriptions.""" + with self.menu_subscriptions_lock: + subscriptions = self.menu_subscriptions.copy() + self.menu_subscriptions.clear() + for unsubscribe in subscriptions: + unsubscribe() + + def _clear_screen_subscriptions(self: MenuWidget) -> None: + """Clear screen subscriptions.""" + # lock the mutex to do it atomic + with self.screen_subscriptions_lock: + subscriptions = self.screen_subscriptions.copy() + self.screen_subscriptions.clear() + for unsubscribe in subscriptions: + unsubscribe() + + def get_current_application(self: MenuWidget) -> PageWidget | None: + """Return the current application.""" + if self.stack and isinstance(self.top, StackApplicationItem): + return self.top.application + return None + + def get_current_menu(self: MenuWidget) -> Menu | None: + """Return the current menu.""" + if self.stack and isinstance(self.top, StackMenuItem): + return self.top.menu + return None + + def get_page_index(self: MenuWidget) -> int: + """Return the current page index.""" + if self.stack and isinstance(self.top, StackMenuItem): + return self.top.page_index + return 0 + + def set_page_index(self: MenuWidget, page_index: int) -> bool: + """Set the current page index.""" + if self.stack and isinstance(self.top, StackMenuItem): + if self.top.page_index != page_index: + self.top.page_index = page_index + return True + return False + return True + + def get_pages(self: MenuWidget) -> int: + """Return the number of pages of the currently active menu.""" + if isinstance(self.current_menu, HeadedMenu): + return max(math.ceil((len(self.current_menu_items) + 2) / 3), 1) + if isinstance(self.current_menu, HeadlessMenu): + return max(math.ceil(len(self.current_menu_items) / 3), 1) + return 0 + + def get_current_menu_items(self: MenuWidget) -> Sequence[Item] | None: + """Return current menu items.""" + return self._current_menu_items + + def set_current_menu_items(self: MenuWidget, items: Sequence[Item]) -> bool: + """Set current menu items.""" + self._current_menu_items = items + self.slider.value = self.get_pages() - 1 - self.page_index + return True + + stack: list[StackItem] = ListProperty() + title = AliasProperty( + getter=get_title, + setter=set_title, + bind=['stack'], + ) + depth: int = AliasProperty(getter=get_depth, bind=['stack'], cache=True) + pages: int = AliasProperty( + getter=get_pages, + bind=['current_menu_items', 'current_menu'], + cache=True, + ) + page_index = AliasProperty( + getter=get_page_index, + setter=set_page_index, + bind=['current_menu_items'], + ) + current_menu_items: Sequence[Item] = AliasProperty( + getter=get_current_menu_items, + setter=set_current_menu_items, + ) + current_application: PageWidget | None = AliasProperty( + getter=get_current_application, + bind=['stack'], + cache=True, + ) + current_menu: Menu | None = AliasProperty( + getter=get_current_menu, + bind=['stack'], + cache=True, + ) + current_menu_type: type[Menu] | None = AliasProperty( + getter=lambda self: type(self.current_menu) if self.current_menu else None, + bind=['current_menu'], + cache=True, + ) + current_screen: Screen | None = AliasProperty( + getter=_get_current_screen, + setter=set_current_screen, + ) + is_scrollbar_visible = AliasProperty( + getter=get_is_scrollbar_visible, + bind=['pages'], + cache=True, + ) + render_surroundings = BooleanProperty( + defaultvalue=False, + cache=True, + ) + padding_bottom = NumericProperty(defaultvalue=0) + padding_top = NumericProperty(defaultvalue=0) + + def __repr__(self: MenuWidget) -> str: + """Return a string representation of the widget.""" + return '\n'.join(self._visual_snapshot) + + +Builder.load_file( + pathlib.Path(__file__).parent.joinpath('menu.kv').resolve().as_posix(), +) diff --git a/ubo_gui/menu/widgets/header_menu_page_widget.py b/ubo_gui/menu/widgets/header_menu_page_widget.py deleted file mode 100644 index ce9c4ee..0000000 --- a/ubo_gui/menu/widgets/header_menu_page_widget.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Module for the `HeaderMenuPageWidget` class.""" - -from __future__ import annotations - -import pathlib -import warnings -from typing import TYPE_CHECKING, Any, Sequence - -from kivy.lang.builder import Builder -from kivy.properties import StringProperty - -from ubo_gui.menu.widgets.item_widget import ItemWidget -from ubo_gui.page import PageWidget - -if TYPE_CHECKING: - from ubo_gui.menu.types import Item - - -HEADER_SIZE = 2 - - -class HeaderMenuPageWidget(PageWidget): - """Renders a header page of a `Menu`.""" - - heading = StringProperty() - sub_heading = StringProperty() - - def __init__( - self: HeaderMenuPageWidget, - items: Sequence[Item | None] | None = None, - **kwargs: Any, # noqa: ANN401 - ) -> None: - """Initialize a `HeaderMenuPageWidget`. - - Parameters - ---------- - items: `Sequence`[[`Item`]] - The item to be shown in this page - - kwargs: Any - Stuff that will get directly passed to the `__init__` method of Kivy's - `Screen`. - - """ - self.item_widgets: list[ItemWidget] = [] - self.bind(on_kv_post=self.adjust_item_widgets) - super().__init__(items, **kwargs) - self.bind(on_count=self.adjust_item_widgets) - self.bind(items=self.render) - - def adjust_item_widgets(self: HeaderMenuPageWidget, *args: object) -> None: - """Initialize the widget.""" - _ = args - for _ in range(len(self.item_widgets), self._count - self._offset): - self.item_widgets.append(ItemWidget(size_hint=(1, None))) - self.ids.layout.add_widget(self.item_widgets[-1]) - for _ in range(self._count - self._offset, len(self.item_widgets)): - self.ids.layout.remove_widget(self.item_widgets[-1]) - del self.item_widgets[-1] - self.render() - - def render(self: HeaderMenuPageWidget, *_: object) -> None: - """Render the widget.""" - if not self.item_widgets: - return - for i in range(self._offset, self._count): - self.item_widgets[i - self._offset].item = ( - self.items[i] if i < len(self.items) else None - ) - - def get_item(self: HeaderMenuPageWidget, index: int) -> Item | None: - """Get the item at the given index.""" - if index < HEADER_SIZE: - warnings.warn( - f'index must be greater than or equal to {HEADER_SIZE}', - ResourceWarning, - stacklevel=1, - ) - return None - try: - return self.items[index + self._offset - HEADER_SIZE] - except IndexError: - return None - - -Builder.load_file( - pathlib.Path(__file__) - .parent.joinpath('header_menu_page_widget.kv') - .resolve() - .as_posix(), -) diff --git a/ubo_gui/menu/widgets/header_menu_page_widget.kv b/ubo_gui/menu/widgets/menu_page_widget.kv similarity index 83% rename from ubo_gui/menu/widgets/header_menu_page_widget.kv rename to ubo_gui/menu/widgets/menu_page_widget.kv index 4b952d9..f3b2b00 100644 --- a/ubo_gui/menu/widgets/header_menu_page_widget.kv +++ b/ubo_gui/menu/widgets/menu_page_widget.kv @@ -1,6 +1,6 @@ #:kivy 2.3.0 -: +: BoxLayout: id: layout pos: 0, 0 if root.is_empty else (root.padding_bottom - dp(UBO_GUI_MENU_ITEM_HEIGHT + UBO_GUI_MENU_ITEM_GAP if root.render_surroundings else 0)) @@ -9,7 +9,7 @@ Widget: size_hint: 1, None - height: dp(14) + height: dp(14) if root.heading or root.sub_heading else 0 Label: text: root.heading @@ -19,7 +19,7 @@ font_size: dp(20) color: 1, 1, 1 size_hint: 1, 0 - height: dp(60) + height: dp(60) if root.heading or root.sub_heading else 0 max_lines: 2 strip: True shorten_from: 'right' @@ -31,7 +31,7 @@ font_size: dp(16) halign: 'center' valign: 'middle' - height: dp(50) + height: dp(50) if root.heading or root.sub_heading else 0 color: .8, .8, .8 size_hint: 1, 0 max_lines: 2 @@ -40,7 +40,7 @@ Widget: size_hint: 1, None - height: dp(10) + height: dp(10) if root.heading or root.sub_heading else 0 Label: text: ('Nothing here yet' if root.placeholder is None else root.placeholder) if root.is_empty else '' diff --git a/ubo_gui/menu/widgets/menu_page_widget.py b/ubo_gui/menu/widgets/menu_page_widget.py new file mode 100644 index 0000000..a826f9d --- /dev/null +++ b/ubo_gui/menu/widgets/menu_page_widget.py @@ -0,0 +1,113 @@ +"""Module for the `NormalMenuPageWidget` class.""" + +from __future__ import annotations + +import pathlib +import warnings +from typing import TYPE_CHECKING, Any, Sequence + +from kivy.lang.builder import Builder +from kivy.properties import NumericProperty, StringProperty + +from ubo_gui.menu.widgets.item_widget import ItemWidget +from ubo_gui.page import PageWidget + +if TYPE_CHECKING: + from ubo_gui.menu.types import Item + + +HEADER_SIZE = 2 + + +class MenuPageWidget(PageWidget): + """renders a page of a `Menu`.""" + + heading: str | None = StringProperty(allownone=True, default=None) + sub_heading: str | None = StringProperty(allownone=True, default=None) + page_index: int = NumericProperty() + + @property + def has_heading(self: MenuPageWidget) -> bool: + """Return whether the page has a heading.""" + return self.page_index == 0 and bool(self.heading or self.sub_heading) + + @property + def head_size(self: MenuPageWidget) -> int: + """Return the size of the header.""" + return HEADER_SIZE if self.has_heading else 0 + + @property + def _count(self: MenuPageWidget) -> int: + return super()._count - self.head_size + + def __init__( + self: MenuPageWidget, + items: Sequence[Item | None] | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> None: + """Initialize `MenuPageWidget`. + + Parameters + ---------- + items: `Sequence`[[`Item`]] + The item to be shown in this page + + kwargs: Any + Stuff that will get directly passed to the `__init__` method of Kivy's + `Screen`. + + """ + self.item_widgets: list[ItemWidget] = [] + self.bind(on_kv_post=self.adjust_item_widgets) + super().__init__(items, **kwargs) + self.bind(on_count=self.adjust_item_widgets) + self.bind(heading=self.adjust_item_widgets) + self.bind(sub_heading=self.adjust_item_widgets) + self.bind(page_index=self.adjust_item_widgets) + self.bind(items=self.render) + + def adjust_item_widgets(self: MenuPageWidget, *args: object) -> None: + """Initialize the widget.""" + _ = args + offset = self._offset if self.has_heading else 0 + for _ in range(len(self.item_widgets), self._count - offset): + self.item_widgets.append(ItemWidget(size_hint=(1, None))) + self.ids.layout.add_widget(self.item_widgets[-1]) + for _ in range(self._count - offset, len(self.item_widgets)): + self.ids.layout.remove_widget(self.item_widgets[-1]) + del self.item_widgets[-1] + self.render() + + def render(self: MenuPageWidget, *_: object) -> None: + """Render the item widgets.""" + if not self.item_widgets: + return + if self.has_heading: + for i in range(self._offset, self._count): + self.item_widgets[i - self._offset].item = ( + self.items[i] if i < len(self.items) else None + ) + else: + for i in range(self._count): + self.item_widgets[i].item = ( + self.items[i] if i < len(self.items) else None + ) + + def get_item(self: MenuPageWidget, index: int) -> Item | None: + """Get the item at the given index.""" + if index < self.head_size: + warnings.warn( + f'index must be greater than or equal to {self.head_size}', + ResourceWarning, + stacklevel=1, + ) + return None + try: + return self.items[index + self._offset - self.head_size] + except IndexError: + return None + + +Builder.load_file( + pathlib.Path(__file__).parent.joinpath('menu_page_widget.kv').resolve().as_posix(), +) diff --git a/ubo_gui/menu/widgets/normal_menu_page_widget.kv b/ubo_gui/menu/widgets/normal_menu_page_widget.kv deleted file mode 100644 index 05aa01f..0000000 --- a/ubo_gui/menu/widgets/normal_menu_page_widget.kv +++ /dev/null @@ -1,26 +0,0 @@ -#:kivy 2.3.0 - -: - BoxLayout: - id: layout - pos: 0, 0 if root.is_empty else (root.padding_bottom - dp(UBO_GUI_MENU_ITEM_HEIGHT + UBO_GUI_MENU_ITEM_GAP if root.render_surroundings else 0)) - size: root.size - orientation: 'vertical' - - Label: - text: ('Nothing here yet' if root.placeholder is None else root.placeholder) if root.is_empty else '' - text_size: self.size - font_size: dp(20) - halign: 'center' - valign: 'middle' - color: .6, .6, .6 - bold: True - italic: True - markup: True - - BoxLayout: - id: layout - height: 0 if root.is_empty else self.minimum_height - size_hint_y: 0 if root.is_empty else None - orientation: 'vertical' - spacing: dp(UBO_GUI_MENU_ITEM_GAP) diff --git a/ubo_gui/menu/widgets/normal_menu_page_widget.py b/ubo_gui/menu/widgets/normal_menu_page_widget.py deleted file mode 100644 index 9df5542..0000000 --- a/ubo_gui/menu/widgets/normal_menu_page_widget.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Module for the `NormalMenuPageWidget` class.""" - -from __future__ import annotations - -import pathlib -from typing import TYPE_CHECKING, Sequence - -from kivy.lang.builder import Builder - -from ubo_gui.page import PageWidget - -from .item_widget import ItemWidget - -if TYPE_CHECKING: - from ubo_gui.menu.types import Item - - -class NormalMenuPageWidget(PageWidget): - """renders a normal page of a `Menu`.""" - - def __init__( - self: PageWidget, - items: Sequence[Item | None] | None = None, - *args: object, - **kwargs: object, - ) -> None: - """Initialize `NormalMenuPageWidget`.""" - self.item_widgets: list[ItemWidget] = [] - self.bind(on_kv_post=self.adjust_item_widgets) - super().__init__(items, *args, **kwargs) - self.bind(on_count=self.adjust_item_widgets) - self.bind(items=self.render) - - def adjust_item_widgets(self: NormalMenuPageWidget, *args: object) -> None: - """Initialize the widget.""" - _ = args - for _ in range(len(self.item_widgets), self._count): - self.item_widgets.append(ItemWidget(size_hint=(1, None))) - self.ids.layout.add_widget(self.item_widgets[-1]) - for _ in range(self._count, len(self.item_widgets)): - self.ids.layout.remove_widget(self.item_widgets[-1]) - del self.item_widgets[-1] - self.render() - - def render(self: NormalMenuPageWidget, *_: object) -> None: - """Render the widget.""" - if not self.item_widgets: - return - for i in range(self._count): - self.item_widgets[i].item = self.items[i] if i < len(self.items) else None - - -Builder.load_file( - pathlib.Path(__file__) - .parent.joinpath('normal_menu_page_widget.kv') - .resolve() - .as_posix(), -)