From 9fcf4feaae44467de6ac873f594f3846a5832f78 Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Wed, 17 Jul 2024 03:16:42 +0400 Subject: [PATCH] refactor: just change the items of the menu when items are changed, instead of creating a whole new menu widget --- CHANGELOG.md | 91 +++------- pyproject.toml | 2 +- ubo_gui/app/__init__.py | 13 +- ubo_gui/menu/__init__.py | 160 ++++++++---------- .../menu/widgets/header_menu_page_widget.py | 31 ++-- ubo_gui/menu/widgets/item_widget.py | 2 +- .../menu/widgets/normal_menu_page_widget.kv | 3 +- .../menu/widgets/normal_menu_page_widget.py | 25 ++- 8 files changed, 145 insertions(+), 182 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6628dcb..36847a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Version 0.11.18 + +- refactor: just change the items of the menu when items are changed, instead of creating a whole new menu widget + ## Version 0.11.17 - feat: add `_visual_snapshot` to visualize the state of stack for debugging purposes @@ -17,8 +21,7 @@ ## Version 0.11.14 -- fix: pass duration with value of `0` to `Screen`'s transition runner to avoid - flickering of the screen +- fix: pass duration with value of `0` to `Screen`'s transition runner to avoid flickering of the screen ## Version 0.11.13 @@ -30,17 +33,12 @@ ## Version 0.11.11 -- fix(MenuWidget): the menu doesn't override the top-most item whenever an arbitrary - sub-menu's subscription reports a change, instead, it updates the item in the stack - for which the change was reported -- refactor(PromptWidget): `PromptWidget` now exposes an `items` list property to - be compatible with other subclasses of `PageWidget` so that tests can interact - with it easier +- fix(MenuWidget): the menu doesn't override the top-most item whenever an arbitrary sub-menu's subscription reports a change, instead, it updates the item in the stack for which the change was reported +- refactor(PromptWidget): `PromptWidget` now exposes an `items` list property to be compatible with other subclasses of `PageWidget` so that tests can interact with it easier ## Version 0.11.10 -- fix(MenuWidget): render placeholder when the menu is empty and `render_surroundings` - is `True` +- fix(MenuWidget): render placeholder when the menu is empty and `render_surroundings` is `True` ## Version 0.11.9 @@ -52,23 +50,19 @@ ## Version 0.11.7 -- feat(MenuWidget): add `go_home` method, resetting the stack to a single element - root menu +- feat(MenuWidget): add `go_home` method, resetting the stack to a single element root menu ## Version 0.11.6 -- refactor(MenuWidget): dispatch `on_close` event on the `PageWidget` instance when - it is closed, to close a `PageWidget` instance, one should call `close_application` +- refactor(MenuWidget): dispatch `on_close` event on the `PageWidget` instance when it is closed, to close a `PageWidget` instance, one should call `close_application` ## Version 0.11.5 -- fix(MenuWidget): avoid opening an `ActionItem`'s `action` return value twice if - it is a `PageWidget` class +- fix(MenuWidget): avoid opening an `ActionItem`'s `action` return value twice if it is a `PageWidget` class - refactor(MenuWidget): improve opening/closing application logic to avoid race conditions - refactor(PageWidget): add `__repr__` to help debugging and logging `PageWidget`s - refactor(MenuWidget): use `mainthread` decorator in transitions only when necessary -- refactor(ItemWidget): increase the length of non-short items (decrease the right - margin from 30 to 6) +- refactor(ItemWidget): increase the length of non-short items (decrease the right margin from 30 to 6) ## Version 0.11.4 @@ -76,8 +70,7 @@ ## Version 0.11.3 -- feat(Menu): action items can now return not only the application class, but also - instances of applications too +- feat(Menu): action items can now return not only the application class, but also instances of applications too - fix(QRCodeWidget): set default value of `fit_mode` to `contain` ## Version 0.11.2 @@ -91,11 +84,9 @@ ## Version 0.11.0 -- feat(Menu): render faded next and previous menu items to induce there are more - items in the menu and it can be scrolled +- feat(Menu): render faded next and previous menu items to induce there are more items in the menu and it can be scrolled - refactor(core): add colors to list of global constants to avoid hardcoding -- refactor(AnimatedSlider): not using the default look, replaced with the one designed - in figma +- refactor(AnimatedSlider): not using the default look, replaced with the one designed in figma - refactor(ItemWidget): add `opacity` field - ci(github): add changelog to the release notes @@ -106,8 +97,7 @@ ## Version 0.10.7 -- fix(menu): not assume the return value of the action in `ActionItem` is a `Menu` - if it is not an `application` +- fix(menu): not assume the return value of the action in `ActionItem` is a `Menu` if it is not an `application` ## Version 0.10.6 @@ -135,8 +125,7 @@ ## Version 0.10.0 -- refactor: drop material symbols font and use `ArimoNerdFont` instead to bring - all the icons of fa, md, mdi, etc +- refactor: drop material symbols font and use `ArimoNerdFont` instead to bring all the icons of fa, md, mdi, etc ## Version 0.9.9 @@ -172,8 +161,7 @@ ## Version 0.9.2 -- fix: queue transitions instead of letting the last transition interrupt the active - one +- fix: queue transitions instead of letting the last transition interrupt the active one ## Version 0.9.1 @@ -181,29 +169,14 @@ ## 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: 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). - applied for these properties: - - `MenuWidget`: - 1. `application` - 1. `sub_menu` - 1. `heading` of headed menu - 1. `sub_heading` of headed menu - 1. `items` of menu - 1. `title` of menu - - `ItemWidget`: - 1. `label` - 1. `is_short` - 1. `color` - 1. `background_color` - 1. `icon` +- feat: add a mechanism to sync properties with subscribable values (defined in python-redux). applied for these properties: - `MenuWidget`: 1. `application` 1. `sub_menu` 1. `heading` of headed menu 1. `sub_heading` of headed menu 1. `items` of menu 1. `title` of menu - `ItemWidget`: 1. `label` 1. `is_short` 1. `color` 1. `background_color` 1. `icon` ## Version 0.7.9 @@ -228,8 +201,7 @@ ## Version 0.7.4 -- fix: avoid unnecessary property caching causing temporary inaccurate state for - `MenuWidget` +- fix: avoid unnecessary property caching causing temporary inaccurate state for `MenuWidget` ## Version 0.7.2 @@ -244,8 +216,7 @@ ## Version 0.7.0 -- refactor: migrate from `TypedDict` to `Immutable` of python-immutable for the - sake of better compatibility with python-redux +- refactor: migrate from `TypedDict` to `Immutable` of python-immutable for the sake of better compatibility with python-redux - refactor: remove `NotificationManager` as it is beyond the scope of this package - refactor: minor improvements in typehints @@ -255,8 +226,7 @@ ## Version 0.6.4 -- feat: make `MenuWidget` subscribe to the items of the current menu if it provides - a `subscribe` property +- feat: make `MenuWidget` subscribe to the items of the current menu if it provides a `subscribe` property ## Version 0.6.3 @@ -340,15 +310,13 @@ ## Version 0.2.3 -- refactor: update code structure so that all packages are sub-packages of a single - package named ubo +- refactor: update code structure so that all packages are sub-packages of a single package named ubo ## Version 0.2.2 - fix: use dp for the radius of rounded rectangles - feat: implement notifications and add sample usage to menu demo app -- feat: implement WifiPrompt in demo/menu.py as an example of PromptWidget usage - and as an example of application launcher menu item +- feat: implement WifiPrompt in demo/menu.py as an example of PromptWidget usage and as an example of application launcher menu item - refactor: change Widgets with nested BoxLayouts to simple BoxLayouts - feat: implement application launcher menu items - feat: add prompt widget @@ -360,14 +328,12 @@ - fix: change header.text only when the default header label is in use - chore: add poethepoet to dependencies - docs: add basic information in README.md -- feat: replace old icon_path, etc with new icon field for menu items, it uses material - symbols icon font to render icons +- feat: replace old icon_path, etc with new icon field for menu items, it uses material symbols icon font to render icons - fix: don't render non-existing item widgets in a menu page - docs: update menu demo to use latest features - chore: add lint script entry to pyproject.toml - feat: allow setting is_short property of menu items from Item TypedDict class -- feat: add HeadlessMenu for rendering menus without a heading in the first page, - it completes HeadedMenu which is the old Menu class +- feat: add HeadlessMenu for rendering menus without a heading in the first page, it completes HeadedMenu which is the old Menu class - feat: support function values for items field of a menu - style: change default color of menu items to ubo blue - feat: add is_short property to items @@ -383,8 +349,7 @@ ## Version 0.2.0 - feat: add app class providing a general layout for ubo gui applications -- refactor: decouple demo application from the core functionality and use its provided - api instead +- refactor: decouple demo application from the core functionality and use its provided api instead ## Version 0.1.0 diff --git a/pyproject.toml b/pyproject.toml index 9106dcc..47dd14b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ubo-gui" -version = "0.11.17" +version = "0.11.18" description = "GUI sdk for Ubo Pod" authors = ["Sassan Haradji "] license = "Apache-2.0" diff --git a/ubo_gui/app/__init__.py b/ubo_gui/app/__init__.py index c2140d5..5fe2198 100644 --- a/ubo_gui/app/__init__.py +++ b/ubo_gui/app/__init__.py @@ -51,9 +51,6 @@ def build(self: UboApp) -> Widget | None: ), ) - if self.root is None: - return None - central_layout: BoxLayout = self.root.ids.central_layout if self.central: central_layout.add_widget(self.central) @@ -73,17 +70,17 @@ def central(self: UboApp) -> Widget | None: """The central section of the app.""" return None - def title_callback(self: UboApp, _: RootWidget, title: str) -> None: + def title_callback(self: UboApp, _: RootWidget, title: str | None) -> None: """Update the header label when the title changes.""" if not self: return header_layout: BoxLayout = self.root.ids.header_layout - if title is not None: - self.header_label.text = title - header_layout.opacity = 1 - else: + if title is None: self.header_label.text = '' header_layout.opacity = 0 + else: + self.header_label.text = title + header_layout.opacity = 1 @cached_property def header(self: UboApp) -> Widget | None: diff --git a/ubo_gui/menu/__init__.py b/ubo_gui/menu/__init__.py index f4f8bb6..bdae4ae 100644 --- a/ubo_gui/menu/__init__.py +++ b/ubo_gui/menu/__init__.py @@ -217,7 +217,7 @@ def go_down(self: MenuWidget) -> None: if self.pages == 1: return self.page_index = (self.page_index + 1) % self.pages - self._render_items() + self._render_menu() self._switch_to( self.current_screen, transition=self._slide_transition, @@ -236,7 +236,7 @@ def go_up(self: MenuWidget) -> None: if self.pages == 1: return self.page_index = (self.page_index - 1) % self.pages - self._render_items() + self._render_menu() self._switch_to( self.current_screen, transition=self._slide_transition, @@ -377,69 +377,10 @@ def go_home(self: MenuWidget) -> None: transition=self._rise_in_transition, ) - def _render_header_menu(self: MenuWidget, menu: HeadedMenu) -> HeaderMenuPageWidget: - """Render a header menu.""" - next_item = ( - None - if self.page_index == self.pages - 1 - else (padding_item := self.current_menu_items[PAGE_SIZE - 2]) - 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, - ) - ) - list_widget = HeaderMenuPageWidget( - [*self.current_menu_items[: PAGE_SIZE - 2], next_item], - name=f'Page {self.get_depth()} 0', - count=PAGE_SIZE + 1 if self.render_surroundings else PAGE_SIZE, - offset=1 if self.render_surroundings else 0, - 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.widget_subscriptions.add( - process_subscribable_value( - 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.widget_subscriptions.add( - process_subscribable_value( - menu.sub_heading, - handle_sub_heading_change, - ), - ) - - return list_widget - - def _render_normal_menu(self: MenuWidget, menu: Menu) -> NormalMenuPageWidget: + 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( @@ -483,27 +424,75 @@ def _render_normal_menu(self: MenuWidget, menu: Menu) -> NormalMenuPageWidget: ) ) items = [previous_item, *items, next_item] - return NormalMenuPageWidget( - items, - name=f'Page {self.get_depth()} 0', - count=PAGE_SIZE + 2 if self.render_surroundings else PAGE_SIZE, - offset=1 if self.render_surroundings else 0, - render_surroundings=self.render_surroundings, - padding_bottom=self.padding_bottom, - padding_top=self.padding_top, - ) + return items - def _render_items(self: MenuWidget, *_: object) -> None: + def _render_menu( + self: MenuWidget, + *_: object, + ) -> HeaderMenuPageWidget | NormalMenuPageWidget | None: """Render the items of the current menu.""" self._clear_widget_subscriptions() if self.page_index >= self.pages: self.page_index = self.pages - 1 if not self.current_menu: - return + return None + items = self._menu_items(self.current_menu) if self.page_index == 0 and isinstance(self.current_menu, HeadedMenu): - list_widget = self._render_header_menu(self.current_menu) + list_widget = HeaderMenuPageWidget( + items, + name=f'Page {self.get_depth()} 0', + count=PAGE_SIZE + 1 if self.render_surroundings else PAGE_SIZE, + offset=1 if self.render_surroundings else 0, + 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.widget_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.widget_subscriptions.add( + process_subscribable_value( + self.current_menu.sub_heading, + handle_sub_heading_change, + ), + ) else: - list_widget = self._render_normal_menu(self.current_menu) + list_widget = NormalMenuPageWidget( + items, + name=f'Page {self.get_depth()} 0', + count=PAGE_SIZE + 2 if self.render_surroundings else PAGE_SIZE, + offset=1 if self.render_surroundings else 0, + render_surroundings=self.render_surroundings, + padding_bottom=self.padding_bottom, + padding_top=self.padding_top, + ) self.current_screen = list_widget @@ -525,6 +514,8 @@ def handle_placeholder_change(placeholder: str | None) -> None: ), ) + return list_widget + def _render(self: MenuWidget, *_: object) -> None: """Return the current screen page.""" self._clear_screen_subscriptions() @@ -542,9 +533,10 @@ def _render(self: MenuWidget, *_: object) -> None: if isinstance(self.top, StackMenuItem): menu = self.top.menu last_items = None + menu_widget = None def handle_items_change(items: Sequence[Item]) -> None: - nonlocal last_items + nonlocal last_items, menu_widget logger.debug( 'Handle `items` change...', extra={ @@ -554,12 +546,10 @@ def handle_items_change(items: Sequence[Item]) -> None: }, ) self.current_menu_items = items - self._render_items() - if last_items is not None: - self._switch_to( - self.current_screen, - transition=self._no_transition, - ) + if menu_widget is None: + menu_widget = self._render_menu() + else: + menu_widget.items = self._menu_items(menu) last_items = items self.screen_subscriptions.add( diff --git a/ubo_gui/menu/widgets/header_menu_page_widget.py b/ubo_gui/menu/widgets/header_menu_page_widget.py index 2cf5657..73e1ca9 100644 --- a/ubo_gui/menu/widgets/header_menu_page_widget.py +++ b/ubo_gui/menu/widgets/header_menu_page_widget.py @@ -25,7 +25,7 @@ class HeaderMenuPageWidget(PageWidget): def __init__( self: HeaderMenuPageWidget, - items: Sequence[Item | None], + items: Sequence[Item | None] | None = None, **kwargs: Any, # noqa: ANN401 ) -> None: """Initialize a `HeaderMenuPageWidget`. @@ -40,26 +40,27 @@ def __init__( `Screen`. """ - self.bind(on_kv_post=self.render) + self.bind(on_count=self.adjust_item_widgets) super().__init__(items, **kwargs) - if len(items) > self.count - 2: - msg = ( - '`HeaderMenuPageWidget` is initialized with more than ' - f'`{self.count - 2}` items' - ) - raise ValueError(msg) + self.item_widgets: list[ItemWidget] = [] 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 - 2): + self.item_widgets.append(ItemWidget()) + self.ids.layout.add_widget(self.item_widgets[-1]) + for _ in range(self.count - 2, len(self.item_widgets)): + self.ids.layout.remove_widgeT(self.item_widgets[-1]) + del self.item_widgets[-1] + def render(self: HeaderMenuPageWidget, *_: object) -> None: """Render the widget.""" - self.ids.layout.clear_widgets() + if not self.item_widgets: + return for i in range(self.count - 2): - self.ids.layout.add_widget( - ItemWidget( - item=self.items[i] if i < len(self.items) else None, - size_hint=(1, None), - ), - ) + self.item_widgets[i].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.""" diff --git a/ubo_gui/menu/widgets/item_widget.py b/ubo_gui/menu/widgets/item_widget.py index 9e3cbc3..11a3b7d 100644 --- a/ubo_gui/menu/widgets/item_widget.py +++ b/ubo_gui/menu/widgets/item_widget.py @@ -51,7 +51,7 @@ class ItemWidget(BoxLayout): background_color: Color = ColorProperty(PRIMARY_COLOR) icon: str = StringProperty(defaultvalue='') is_short: bool = BooleanProperty(defaultvalue=False) - item: Item = ObjectProperty(allownone=True) + item: Item | None = ObjectProperty(allownone=True) opacity: float = NumericProperty(default=1, min=0, max=1) def __init__(self: ItemWidget, item: Item | None = None, **kwargs: Any) -> None: # noqa: ANN401 diff --git a/ubo_gui/menu/widgets/normal_menu_page_widget.kv b/ubo_gui/menu/widgets/normal_menu_page_widget.kv index 05aa01f..7871bc0 100644 --- a/ubo_gui/menu/widgets/normal_menu_page_widget.kv +++ b/ubo_gui/menu/widgets/normal_menu_page_widget.kv @@ -9,7 +9,8 @@ Label: text: ('Nothing here yet' if root.placeholder is None else root.placeholder) if root.is_empty else '' - text_size: self.size + text_size: self.texture_size if root.is_empty else (0, 0) + size_hint_y: self.texture_size[1] if root.is_empty else 0 font_size: dp(20) halign: 'center' valign: 'middle' diff --git a/ubo_gui/menu/widgets/normal_menu_page_widget.py b/ubo_gui/menu/widgets/normal_menu_page_widget.py index a76da21..e715d17 100644 --- a/ubo_gui/menu/widgets/normal_menu_page_widget.py +++ b/ubo_gui/menu/widgets/normal_menu_page_widget.py @@ -25,20 +25,29 @@ def __init__( **kwargs: object, ) -> None: """Initialize `NormalMenuPageWidget`.""" - self.bind(on_kv_post=self.render) + 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.""" - self.ids.layout.clear_widgets() + if not self.item_widgets: + return for i in range(self.count): - self.ids.layout.add_widget( - ItemWidget( - item=self.items[i] if i < len(self.items) else None, - size_hint=(1, None), - ), - ) + self.item_widgets[i].item = self.items[i] if i < len(self.items) else None Builder.load_file(