diff --git a/CHANGELOG.md b/CHANGELOG.md index 91bc12d..da29be9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Version 0.9.1 + +- hotfix: remove debug background rectangle + +## Version 0.9.0 + +- refactor: remove `current_application` and `current_menu` from `MenuWidget`, just + keep them as a proxy for the top item of the `stack` +- refactor: clean subscriptions in different levels of screen, widget and item +- feat: allow action items to return subscribable menus +- feat: add `logger` and log subscriptions + ## Version 0.8.0 - feat: add a mechanism to sync properties with subscribable values (defined in python-redux). diff --git a/pyproject.toml b/pyproject.toml index 59e20c6..6ba58ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ubo-gui" -version = "0.8.0" +version = "0.9.1" description = "GUI sdk for Ubo Pod" authors = ["Sassan Haradji "] license = "Apache-2.0" diff --git a/ubo_gui/__init__.py b/ubo_gui/__init__.py index 65c7c04..83b0a1b 100644 --- a/ubo_gui/__init__.py +++ b/ubo_gui/__init__.py @@ -16,7 +16,7 @@ Factory.register('AnimatedSlider', module='ubo_gui.animated_slider') Factory.register('GaugeWidget', module='ubo_gui.gauge') -Factory.register('ItemWidget', module='ubo_gui.menu.item_widget') +Factory.register('ItemWidget', module='ubo_gui.menu.widgets.item_widget') Factory.register('MenuWidget', module='ubo_gui.menu') Factory.register('NotificationWidget', module='ubo_gui.notification') Factory.register('PromptWidget', module='ubo_gui.prompt') diff --git a/ubo_gui/animated_slider/__init__.py b/ubo_gui/animated_slider/__init__.py index 86425f7..c6639b1 100644 --- a/ubo_gui/animated_slider/__init__.py +++ b/ubo_gui/animated_slider/__init__.py @@ -50,7 +50,7 @@ def on_max(self: AnimatedSlider, *largs: float) -> None: def on_animated_value( self: AnimatedSlider, - _instance: AnimatedSlider, + _: AnimatedSlider, new_value: float, ) -> None: """Handle the `animated_value` property being set to a new value. @@ -60,5 +60,6 @@ def on_animated_value( Arguments: --------- new_value: The new value that the `animated_value` property is being set to. + """ Animation(value=new_value, duration=0.2).start(self) diff --git a/ubo_gui/logger.py b/ubo_gui/logger.py new file mode 100644 index 0000000..5dc79c3 --- /dev/null +++ b/ubo_gui/logger.py @@ -0,0 +1,69 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107 +from __future__ import annotations + +import logging +import sys + +logger = logging.getLogger('ubo-gui') +logger.propagate = False + + +class ExtraFormatter(logging.Formatter): + def_keys = ( + 'name', + 'msg', + 'args', + 'levelname', + 'levelno', + 'pathname', + 'filename', + 'module', + 'exc_info', + 'exc_text', + 'stack_info', + 'lineno', + 'funcName', + 'created', + 'msecs', + 'relativeCreated', + 'thread', + 'threadName', + 'processName', + 'process', + 'message', + ) + + def format(self: ExtraFormatter, record: logging.LogRecord) -> str: + string = super().format(record) + extra = {k: v for k, v in record.__dict__.items() if k not in self.def_keys} + if len(extra) > 0: + string += ' - extra: ' + str(extra) + + return string + + +def add_stdout_handler(level: int = logging.DEBUG) -> None: + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setLevel(level) + stdout_handler.setFormatter( + ExtraFormatter( + '%(created)f [%(levelname)s] %(message)s', + '%Y-%m-%d %H:%M:%S', + ), + ) + logger.addHandler(stdout_handler) + + +def add_file_handler(level: int = logging.DEBUG) -> None: + file_handler = logging.FileHandler('ubo-gui.log') + file_handler.setLevel(level) + file_handler.setFormatter( + ExtraFormatter( + '%(created)f [%(levelname)s] %(message)s', + '%Y-%m-%d %H:%M:%S', + ), + ) + logger.addHandler(file_handler) + + +__all__ = ('logger', 'add_stdout_handler', 'add_file_handler') diff --git a/ubo_gui/menu/__init__.py b/ubo_gui/menu/__init__.py index 8970b41..e338ade 100644 --- a/ubo_gui/menu/__init__.py +++ b/ubo_gui/menu/__init__.py @@ -9,127 +9,133 @@ import math import pathlib +import threading import uuid import warnings -from functools import cached_property -from typing import TYPE_CHECKING, Any, Callable, Sequence, cast +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Self, Sequence, cast from headless_kivy_pi import HeadlessWidget from kivy.app import Builder -from kivy.properties import AliasProperty, ListProperty, NumericProperty +from kivy.properties import AliasProperty, ListProperty from kivy.uix.boxlayout import BoxLayout -from kivy.uix.screenmanager import ( - NoTransition, - Screen, - ScreenManager, - SlideTransition, - SwapTransition, - TransitionBase, -) +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 .header_menu_page_widget import HeaderMenuPageWidget -from .normal_menu_page_widget import NormalMenuPageWidget 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 menu.types import Menu - from ubo_gui.animated_slider import AnimatedSlider -class MenuWidget(BoxLayout): +@dataclass(kw_only=True) +class BaseStackItem: + """An item in a menu stack.""" + + parent: StackItem | None + subscriptions: set[Callable[[], None]] = field(default_factory=set) + + def clear_subscriptions(self: BaseStackItem) -> None: + """Clear all subscriptions.""" + subscriptions = self.subscriptions.copy() + self.subscriptions.clear() + for subscription in subscriptions: + subscription() + + @property + def root(self: BaseStackItem) -> StackItem: + """Return the root item.""" + if self.parent: + return self.parent.root + return cast(StackItem, self) + + @property + def title(self: Self) -> str: + """Return the title of the item.""" + raise NotImplementedError + + +@dataclass(kw_only=True) +class StackMenuItem(BaseStackItem): + """A menu item in a menu stack.""" + + menu: Menu + page_index: int + + @property + def title(self: StackMenuItem) -> str: + """Return the title of the menu.""" + return self.menu.title() if callable(self.menu.title) else self.menu.title + + +@dataclass(kw_only=True) +class StackApplicationItem(BaseStackItem): + """An application item in a menu stack.""" + + application: PageWidget + + @property + def title(self: StackApplicationItem) -> str: + """Return the title of the application.""" + return self.application.name + + +StackItem = StackMenuItem | StackApplicationItem + + +class MenuWidget(BoxLayout, TransitionsMixin): """Paginated menu.""" - _subscriptions: list[Callable[[], None]] + widget_subscriptions: set[Callable[[], None]] + widget_subscriptions_lock: threading.Lock + screen_subscriptions: set[Callable[[], None]] + screen_subscriptions_lock: threading.Lock - _title: str | None = None - _current_menu: Menu | None = None _current_menu_items: Sequence[Item] - _current_application: PageWidget | None = None + _current_screen: Screen | None = None + _title: str | None = None screen_manager: ScreenManager slider: AnimatedSlider - def _handle_transition_progress( - self: MenuWidget, - transition: TransitionBase, - progression: float, - ) -> None: - if progression is 0: # noqa: F632 - float 0.0 is not accepted, we are looking for int 0 - HeadlessWidget.activate_high_fps_mode() - transition.screen_out.opacity = 1 - progression - transition.screen_in.opacity = progression - - def _handle_transition_complete( - self: MenuWidget, - transition: TransitionBase, - ) -> None: - transition.screen_out.opacity = 0 - transition.screen_in.opacity = 1 - HeadlessWidget.activate_low_fps_mode() - - def _setup_transition(self: MenuWidget, transition: TransitionBase) -> None: - transition.bind(on_progress=self._handle_transition_progress) - transition.bind(on_complete=self._handle_transition_complete) - - @cached_property - def _no_transition(self: MenuWidget) -> NoTransition: - transition = NoTransition() - self._setup_transition(transition) - return transition - - @cached_property - def _slide_transition(self: MenuWidget) -> SlideTransition: - transition = SlideTransition() - self._setup_transition(transition) - return transition - - @cached_property - def _swap_transition(self: MenuWidget) -> SwapTransition: - transition = SwapTransition() - self._setup_transition(transition) - return transition - def __init__(self: MenuWidget, **kwargs: dict[str, Any]) -> None: """Initialize a `MenuWidget`.""" - super().__init__(**kwargs) - self._subscriptions = [] self._current_menu_items = [] + self.widget_subscriptions = set() + self.widget_subscriptions_lock = threading.Lock() + self.screen_subscriptions = set() + self.screen_subscriptions_lock = threading.Lock() + super().__init__(**kwargs) + self.bind(stack=self.render) def __del__(self: MenuWidget) -> None: - """Unsubscribe from the item.""" - self.clear_subscriptions() + """Clear all subscriptions.""" + self.clear_widget_subscriptions() + self.clear_screen_subscriptions() def set_root_menu(self: MenuWidget, root_menu: Menu) -> None: """Set the root menu.""" self.stack = [] - self.current_menu = root_menu - self.screen_manager.switch_to( - self.current_screen, - transition=self._no_transition, - ) + self.push(root_menu, transition=self._no_transition) def get_depth(self: MenuWidget) -> int: """Return depth of the current screen.""" return len(self.stack) - def get_pages(self: MenuWidget) -> int: - """Return the number of pages of the currently active menu.""" - if not self.current_menu: - return 0 - if isinstance(self.current_menu, HeadedMenu): - return math.ceil((len(self.current_menu_items) + 2) / 3) - return math.ceil(len(self.current_menu_items) / 3) - def set_title(self: MenuWidget, title: str) -> bool: """Set the title of the currently active menu.""" self._title = title @@ -152,9 +158,10 @@ def go_down(self: MenuWidget) -> None: self.current_application.go_down() return - if len(self.current_menu_items) == 0: + if self.pages <= 1: return self.page_index = (self.page_index + 1) % self.pages + self.render_items() self.screen_manager.switch_to( self.current_screen, transition=self._slide_transition, @@ -170,15 +177,84 @@ def go_up(self: MenuWidget) -> None: self.current_application.go_up() return - if len(self.current_menu_items) == 0: + if self.pages <= 1: return self.page_index = (self.page_index - 1) % self.pages + self.render_items() self.screen_manager.switch_to( self.current_screen, transition=self._slide_transition, direction='down', ) + def open_menu(self: MenuWidget, menu: Menu | Callable[[], Menu]) -> None: + """Open a menu.""" + last_sub_menu: Menu | None = None + + def handle_menu_change(menu: Menu) -> None: + nonlocal last_sub_menu + logger.debug( + 'Handle `sub_menu` change...', + extra={ + 'new_sub_menu': menu, + 'old_sub_menu': last_sub_menu, + 'subscription_level': 'parent', + }, + ) + if last_sub_menu: + self.replace(menu) + else: + self.push(menu, transition=self._slide_transition, direction='left') + last_sub_menu = menu + + susbscription = process_subscribable_value( + menu, + handle_menu_change, + ) + + self.top.subscriptions.add(susbscription) + + 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): + application_instance = result(name=uuid.uuid4().hex) + self.open_application(application_instance) + else: + self.open_menu(result) + + 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(name=uuid.uuid4().hex) + self.open_application(application_instance) + + subscription = process_subscribable_value( + item.application, + handle_application_change, + ) + self.top.subscriptions.add(subscription) + + def select_submenu_item(self: MenuWidget, item: SubMenuItem) -> None: + """Select a submenu item.""" + self.open_menu(item.sub_menu) + def select(self: MenuWidget, index: int) -> None: """Select one of the items currently visible on the screen. @@ -187,6 +263,7 @@ def select(self: MenuWidget, index: int) -> None: 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) @@ -195,109 +272,149 @@ def select(self: MenuWidget, index: int) -> None: item = current_page.get_item(index) if isinstance(item, ActionItem): - item = item.action() - if not item: - return - if isinstance(item, type) and issubclass(item, PageWidget): - application_instance = item(name=uuid.uuid4().hex) - self.open_application(application_instance) - else: - self.push_menu() - self.current_menu = item - self.screen_manager.switch_to( - self.current_screen, - transition=self._slide_transition, - direction='left', - ) + self.select_action_item(item) if isinstance(item, ApplicationItem): - - def handle_application_change(application: type[PageWidget]) -> None: - application_instance = application(name=uuid.uuid4().hex) - self.open_application(application_instance) - - self._subscriptions.append( - process_subscribable_value(item.application, handle_application_change), - ) - + self.select_application_item(item) if isinstance(item, SubMenuItem): - self.push_menu() - - is_first_transition = True - - def handle_sub_menu_change(sub_menu: Menu) -> None: - nonlocal is_first_transition - self.current_menu = sub_menu - if self.current_screen: - self.screen_manager.switch_to( - self.current_screen, - transition=self._slide_transition - if is_first_transition - else self._no_transition, - direction='left', - ) - is_first_transition = False - - self._subscriptions.append( - process_subscribable_value(item.sub_menu, handle_sub_menu_change), - ) + self.select_submenu_item(item) def go_back(self: MenuWidget) -> None: """Go back to the previous menu.""" if self.current_application and self.current_application.go_back(): return HeadlessWidget.activate_high_fps_mode() - self.pop_menu() - - def get_current_screen(self: MenuWidget) -> Screen | None: - """Return the current screen page.""" - if self.current_application: - return self.current_application - - if not self.current_menu: - return None + self.pop() + def render_items(self: MenuWidget, *_: object) -> None: + """Render the items of the current menu.""" + self.clear_widget_subscriptions() if self.page_index == 0 and isinstance(self.current_menu, HeadedMenu): header_menu_page_widget = HeaderMenuPageWidget( self.current_menu_items[:1], name=f'Page {self.get_depth()} 0', ) - self._subscriptions.append( + + def handle_heading_change(heading: str) -> None: + logger.debug( + 'Handle `heading` change...', + extra={ + 'new_heading': heading, + 'old_heading': header_menu_page_widget.heading, + 'subscription_level': 'widget', + }, + ) + if heading != header_menu_page_widget.heading: + header_menu_page_widget.heading = heading + + self.widget_subscriptions.add( process_subscribable_value( self.current_menu.heading, - lambda value: setattr(header_menu_page_widget, 'heading', value), + handle_heading_change, ), ) - self._subscriptions.append( + + def handle_sub_heading_change(sub_heading: str) -> None: + logger.debug( + 'Handle `sub_heading` change...', + extra={ + 'new_sub_heading': sub_heading, + 'old_sub_heading': header_menu_page_widget.sub_heading, + 'subscription_level': 'widget', + }, + ) + if sub_heading != header_menu_page_widget.sub_heading: + header_menu_page_widget.sub_heading = sub_heading + + self.widget_subscriptions.add( process_subscribable_value( self.current_menu.sub_heading, - lambda value: setattr( - header_menu_page_widget, - 'sub_heading', - value, - ), + handle_sub_heading_change, ), ) - return header_menu_page_widget - - offset = -(PAGE_SIZE - 1) if isinstance(self.current_menu, HeadedMenu) else 0 - return NormalMenuPageWidget( - self.current_menu_items[ - self.page_index * 3 + offset : self.page_index * 3 + 3 + offset - ], - name=f'Page {self.get_depth()} 0', - ) - def _get_current_screen(self: MenuWidget) -> Screen | None: + self.current_screen = header_menu_page_widget + else: + offset = ( + -(PAGE_SIZE - 1) if isinstance(self.current_menu, HeadedMenu) else 0 + ) + self.current_screen = NormalMenuPageWidget( + self.current_menu_items[ + self.page_index * 3 + offset : self.page_index * 3 + 3 + offset + ], + name=f'Page {self.get_depth()} 0', + ) + + def render(self: MenuWidget, *_: object) -> None: """Return the current screen page.""" + self.clear_screen_subscriptions() + + if not self.stack: + return + + if isinstance(self.top, StackApplicationItem): + self.current_screen = self.top.application + if isinstance(self.top, StackMenuItem): + menu = self.top.menu + last_items = None + + def handle_items_change(items: Sequence[Item]) -> None: + nonlocal last_items + logger.debug( + 'Handle `items` change...', + extra={ + 'new_items': items, + 'old_items': last_items, + 'subscription_level': 'screen', + }, + ) + if items != last_items: + self.current_menu_items = items + self.render_items() + if last_items: + self.screen_manager.switch_to( + self.current_screen, + transition=self._no_transition, + ) + last_items = items + + self.screen_subscriptions.add( + process_subscribable_value(menu.items, handle_items_change), + ) + + def handle_title_change(title: str) -> None: + logger.debug( + 'Handle `title` change...', + extra={ + 'new_title': title, + 'old_title': self.title, + 'subscription_level': 'screen', + }, + ) + if self._title != title: + self.title = title + + self.screen_subscriptions.add( + process_subscribable_value(menu.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.""" HeadlessWidget.activate_high_fps_mode() - self.push_menu() - self.current_application = application - self.screen_manager.switch_to( - self.current_screen, + self.push( + application, transition=self._swap_transition, duration=0.2, direction='left', @@ -311,61 +428,163 @@ def clean_application(self: MenuWidget, application: PageWidget) -> None: def close_application(self: MenuWidget, application: PageWidget) -> None: """Close an application after its `on_close` event is fired.""" self.clean_application(application) - if application is self.current_application: - self.go_back() - elif application in self.stack: - self.stack.remove(application) + if application in self.stack: + while application in self.stack: + self.go_back() + + @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] + + def replace( + self: MenuWidget, + item: Menu | PageWidget, + ) -> None: + """Replace the current menu or application.""" + subscriptions = self.top.subscriptions.copy() + if isinstance(item, Menu): + self.stack[-1] = StackMenuItem( + menu=item, + page_index=0, + parent=self.top.parent, + ) + + elif isinstance(item, PageWidget): + self.stack[-1] = StackApplicationItem( + application=item, + parent=self.top.parent, + ) + self.top.subscriptions = subscriptions + self.screen_manager.switch_to( + self.current_screen, + transition=self._no_transition, + ) - def push_menu(self: MenuWidget) -> None: + def push( # noqa: PLR0913 + self: MenuWidget, + item: Menu | PageWidget, + /, + *, + transition: TransitionBase | None, + duration: float | None = None, + direction: str | None = None, + parent: StackItem | None = None, + ) -> None: """Go one level deeper in the menu stack.""" - if self.current_menu: - self.stack.append((self.current_menu, self.page_index)) - elif self.current_application: - self.stack.append(self.current_application) - self.page_index = 0 + if isinstance(item, Menu): + self.stack = [ + *self.stack, + StackMenuItem(menu=item, page_index=0, parent=parent), + ] + elif isinstance(item, PageWidget): + self.stack = [ + *self.stack, + StackApplicationItem(application=item, parent=parent), + ] + self.screen_manager.switch_to( + self.current_screen, + transition=transition, + **({'duration': duration} if duration else {}), + **({'direction': direction} if direction else {}), + ) - def pop_menu(self: MenuWidget) -> None: + 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 == 0: + if self.depth == 1: return - target = self.stack.pop() - transition = self._slide_transition + *self.stack, popped = self.stack + if not keep_subscriptions and isinstance(popped, StackMenuItem): + popped.clear_subscriptions() + target = self.top + transition_ = self._slide_transition if isinstance(target, PageWidget): - transition = self._swap_transition + transition_ = self._swap_transition if self.current_application: self.clean_application(self.current_application) - self.current_application = target - else: - if self.current_application: - transition = self._swap_transition - self.current_menu = target[0] - self.page_index = target[1] + elif self.current_application: + self.clean_application(self.current_application) + transition_ = self._swap_transition self.screen_manager.switch_to( self.current_screen, - transition=transition, - direction='right', + transition=transition or transition_, + **({'duration': duration} if duration else {}), + **({'direction': direction} if direction else {}), ) 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: Any) -> None: # noqa: ANN401 + """Activate low fps mode when transition is done.""" + _ = base_widget + self.screen_manager = cast(ScreenManager, self.ids.screen_manager) + self.slider = self.ids.slider + + def clear_widget_subscriptions(self: MenuWidget) -> None: + """Clear widget subscriptions.""" + with self.widget_subscriptions_lock: + subscriptions = self.widget_subscriptions.copy() + self.widget_subscriptions.clear() + for subscription in subscriptions: + subscription() + + 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 current application.""" - return self._current_application + """Return the current application.""" + if self.stack and isinstance(self.top, StackApplicationItem): + return self.top.application + return None - def set_current_application( - self: MenuWidget, - application: PageWidget | None, - ) -> bool: - """Set current application.""" - self._current_application = application - if application: - self.current_menu = None - self.clear_subscriptions() + 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 math.ceil((len(self.current_menu_items) + 2) / 3) + if isinstance(self.current_menu, HeadlessMenu): + return math.ceil(len(self.current_menu_items) / 3) + return 0 + def get_current_menu_items(self: MenuWidget) -> Sequence[Item] | None: """Return current menu items.""" return self._current_menu_items @@ -373,107 +592,47 @@ def get_current_menu_items(self: MenuWidget) -> Sequence[Item] | None: 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 return True - def get_current_menu(self: MenuWidget) -> Menu | None: - """Return the current menu.""" - return self._current_menu - - def set_current_menu(self: MenuWidget, menu: Menu | None) -> bool: - """Set the current menu.""" - self._current_menu = menu - - if not menu: - self._current_menu_items = [] - self.page_index = 0 - return True - - self.current_application = None - self.clear_subscriptions() - - is_first_transition = True - - def refresh_items(items: Sequence[Item]) -> None: - nonlocal is_first_transition - self.current_menu_items = items - if not is_first_transition: - self.screen_manager.switch_to( - self.current_screen, - transition=self._no_transition, - ) - is_first_transition = False - - self._subscriptions.append( - process_subscribable_value( - menu.items, - refresh_items, - ), - ) - - def handle_title_change(title: str) -> None: - if self._title != title: - self.title = title - - self._subscriptions.append( - process_subscribable_value( - menu.title, - handle_title_change, - ), - ) - - pages = self.get_pages() - if self.page_index >= pages: - self.page_index = max(self.pages - 1, 0) - self.slider.value = pages - 1 - self.page_index - return True - - def on_kv_post(self: MenuWidget, base_widget: Any) -> None: # noqa: ANN401 - """Activate low fps mode when transition is done.""" - _ = base_widget - self.screen_manager = cast(ScreenManager, self.ids.screen_manager) - self.slider = self.ids.slider - - def clear_subscriptions(self: MenuWidget) -> None: - """Clear the subscriptions.""" - for subscription in self._subscriptions: - subscription() - self._subscriptions.clear() - - page_index = NumericProperty(0) - stack: list[tuple[Menu, int] | PageWidget] = ListProperty() + stack: list[StackItem] = ListProperty() title = AliasProperty( getter=get_title, setter=set_title, - bind=['current_menu', 'current_application'], - cache=True, + bind=['stack'], ) - depth: int = AliasProperty( - getter=get_depth, - bind=['current_menu', 'current_application', 'stack'], + depth: int = AliasProperty(getter=get_depth, bind=['stack'], cache=True) + pages: int = AliasProperty( + getter=get_pages, + bind=['current_menu_items'], cache=True, ) - pages: int = AliasProperty(getter=get_pages, bind=['current_menu'], cache=True) - current_application: PageWidget | None = AliasProperty( - getter=get_current_application, - setter=set_current_application, + 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, - bind=['current_menu'], + ) + current_application: PageWidget | None = AliasProperty( + getter=get_current_application, + bind=['stack'], + cache=True, ) current_menu: Menu | None = AliasProperty( getter=get_current_menu, - setter=set_current_menu, + bind=['stack'], + cache=True, ) - current_screen: Menu | None = AliasProperty( + current_screen: Screen | None = AliasProperty( getter=_get_current_screen, - bind=['page_index', 'current_application', 'current_menu_items'], - cache=True, + setter=set_current_screen, ) is_scrollbar_visible = AliasProperty( getter=get_is_scrollbar_visible, - bind=['pages', 'current_application'], + bind=['pages'], cache=True, ) diff --git a/ubo_gui/menu/normal_menu_page_widget.py b/ubo_gui/menu/normal_menu_page_widget.py deleted file mode 100644 index 62c1f15..0000000 --- a/ubo_gui/menu/normal_menu_page_widget.py +++ /dev/null @@ -1,13 +0,0 @@ -import pathlib - -from kivy.app import Builder - -from ubo_gui.page import PageWidget - - -class NormalMenuPageWidget(PageWidget): - """renders a normal page of a `Menu`.""" - - -Builder.load_file(pathlib.Path( - __file__).parent.joinpath('normal_menu_page_widget.kv').resolve().as_posix()) diff --git a/ubo_gui/menu/transitions.py b/ubo_gui/menu/transitions.py new file mode 100644 index 0000000..7a711eb --- /dev/null +++ b/ubo_gui/menu/transitions.py @@ -0,0 +1,56 @@ +"""Provides easy access to different transitions.""" +from __future__ import annotations + +from functools import cached_property + +from headless_kivy_pi import HeadlessWidget +from kivy.uix.screenmanager import ( + NoTransition, + SlideTransition, + SwapTransition, + TransitionBase, +) + + +class TransitionsMixin: + """Provides easy access to different transitions.""" + + def _handle_transition_progress( + self: TransitionsMixin, + transition: TransitionBase, + progression: float, + ) -> None: + if progression is 0: # noqa: F632 - float 0.0 is not accepted, we are looking for int 0 + HeadlessWidget.activate_high_fps_mode() + transition.screen_out.opacity = 1 - progression + transition.screen_in.opacity = progression + + def _handle_transition_complete( + self: TransitionsMixin, + transition: TransitionBase, + ) -> None: + transition.screen_out.opacity = 0 + transition.screen_in.opacity = 1 + HeadlessWidget.activate_low_fps_mode() + + def _setup_transition(self: TransitionsMixin, transition: TransitionBase) -> None: + transition.bind(on_progress=self._handle_transition_progress) + transition.bind(on_complete=self._handle_transition_complete) + + @cached_property + def _no_transition(self: TransitionsMixin) -> NoTransition: + transition = NoTransition() + self._setup_transition(transition) + return transition + + @cached_property + def _slide_transition(self: TransitionsMixin) -> SlideTransition: + transition = SlideTransition() + self._setup_transition(transition) + return transition + + @cached_property + def _swap_transition(self: TransitionsMixin) -> SwapTransition: + transition = SwapTransition() + self._setup_transition(transition) + return transition diff --git a/ubo_gui/menu/types.py b/ubo_gui/menu/types.py index d66a962..f618dfa 100644 --- a/ubo_gui/menu/types.py +++ b/ubo_gui/menu/types.py @@ -37,6 +37,7 @@ class BaseMenu(Immutable): items: `list` of `Item` List of the items of the menu. Optionally it can be a callable returning the list of items + """ title: str | Callable[[], str] @@ -56,6 +57,7 @@ class HeadedMenu(BaseMenu): sub_heading: `str` Rendered beneath the heading in the first page of the menu with a smaller font. Optionally it can be a callable returning the sub heading. + """ heading: str | Callable[[], str] @@ -112,6 +114,7 @@ class Item(Immutable): Whether the item should be rendered in short form or not. In short form only the icon of the item is rendered and its label is hidden. Optionally it can be a callable returning the is_short value. + """ label: str | Callable[[], str] = '' @@ -128,9 +131,10 @@ class ActionItem(Item): ---------- action: `Function` If provided, activating this item will call this function. + """ - action: Callable[[], Menu | type[PageWidget] | None] + action: Callable[[], Menu | Callable[[], Menu] | type[PageWidget] | None] class ApplicationItem(Item): @@ -141,6 +145,7 @@ class ApplicationItem(Item): application: `PageWidget` If provided, activating this item will show this widget. Optionally it can be a callable returning the widget. + """ application: type[PageWidget] | Callable[[], type[PageWidget]] @@ -154,6 +159,7 @@ class SubMenuItem(Item): sub_menu: `Menu` If provided, activating this item will open another menu, the description described in this field. Optionally it can be a callable returning the menu. + """ sub_menu: Menu | Callable[[], Menu] diff --git a/ubo_gui/menu/widgets/__init__.py b/ubo_gui/menu/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ubo_gui/menu/header_menu_page_widget.kv b/ubo_gui/menu/widgets/header_menu_page_widget.kv similarity index 89% rename from ubo_gui/menu/header_menu_page_widget.kv rename to ubo_gui/menu/widgets/header_menu_page_widget.kv index 8bb3899..f912d8e 100644 --- a/ubo_gui/menu/header_menu_page_widget.kv +++ b/ubo_gui/menu/widgets/header_menu_page_widget.kv @@ -1,12 +1,6 @@ #:kivy 2.2.1 : - canvas: - Color: - rgba: 1, 0, 0, .2 - Rectangle: - pos: self.pos - size: self.size BoxLayout: id: layout pos: self.pos diff --git a/ubo_gui/menu/header_menu_page_widget.py b/ubo_gui/menu/widgets/header_menu_page_widget.py similarity index 89% rename from ubo_gui/menu/header_menu_page_widget.py rename to ubo_gui/menu/widgets/header_menu_page_widget.py index 9176ec8..5bd1690 100644 --- a/ubo_gui/menu/header_menu_page_widget.py +++ b/ubo_gui/menu/widgets/header_menu_page_widget.py @@ -7,12 +7,11 @@ from kivy.app import Builder, StringProperty +from ubo_gui.menu.constants import PAGE_SIZE from ubo_gui.page import PageWidget -from .constants import PAGE_SIZE - if TYPE_CHECKING: - from .types import Item + from ubo_gui.menu.types import Item class HeaderMenuPageWidget(PageWidget): @@ -44,6 +43,7 @@ def __init__( kwargs: Any Stuff that will get directly passed to the `__init__` method of Kivy's `Screen`. + """ if len(items) > 1: msg = '`HeaderMenuPageWidget` is initialized with more than one item' @@ -61,7 +61,10 @@ def get_item(self: HeaderMenuPageWidget, index: int) -> Item | None: stacklevel=1, ) return None - return self.items[index - PAGE_SIZE + 1] + try: + return self.items[index - PAGE_SIZE + 1] + except IndexError: + return None Builder.load_file( diff --git a/ubo_gui/menu/item_widget.kv b/ubo_gui/menu/widgets/item_widget.kv similarity index 100% rename from ubo_gui/menu/item_widget.kv rename to ubo_gui/menu/widgets/item_widget.kv diff --git a/ubo_gui/menu/item_widget.py b/ubo_gui/menu/widgets/item_widget.py similarity index 98% rename from ubo_gui/menu/item_widget.py rename to ubo_gui/menu/widgets/item_widget.py index 7ae6a9f..362502a 100644 --- a/ubo_gui/menu/item_widget.py +++ b/ubo_gui/menu/widgets/item_widget.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from kivy.graphics import Color - from . import Item + from ubo_gui.menu.types import Item class ItemWidget(BoxLayout): @@ -38,6 +38,7 @@ class ItemWidget(BoxLayout): icon: `str` Name of a Material Symbols icon. + """ _subscriptions: list[Callable[[], None]] diff --git a/ubo_gui/menu/normal_menu_page_widget.kv b/ubo_gui/menu/widgets/normal_menu_page_widget.kv similarity index 100% rename from ubo_gui/menu/normal_menu_page_widget.kv rename to ubo_gui/menu/widgets/normal_menu_page_widget.kv diff --git a/ubo_gui/menu/widgets/normal_menu_page_widget.py b/ubo_gui/menu/widgets/normal_menu_page_widget.py new file mode 100644 index 0000000..e088601 --- /dev/null +++ b/ubo_gui/menu/widgets/normal_menu_page_widget.py @@ -0,0 +1,18 @@ +"""Module for the `NormalMenuPageWidget` class.""" +import pathlib + +from kivy.app import Builder + +from ubo_gui.page import PageWidget + + +class NormalMenuPageWidget(PageWidget): + """renders a normal page of a `Menu`.""" + + +Builder.load_file( + pathlib.Path(__file__) + .parent.joinpath('normal_menu_page_widget.kv') + .resolve() + .as_posix(), +)