diff --git a/ovos_gui/extensions.py b/ovos_gui/extensions.py index c8ebf1d..150d7fa 100644 --- a/ovos_gui/extensions.py +++ b/ovos_gui/extensions.py @@ -1,24 +1,23 @@ from ovos_bus_client import Message, MessageBusClient from ovos_config.config import Configuration -from ovos_gui.namespace import NamespaceManager from ovos_utils.log import LOG from ovos_plugin_manager.gui import OVOSGuiFactory +from ovos_gui.homescreen import HomescreenManager class ExtensionsManager: - def __init__(self, name: str, bus: MessageBusClient, gui: NamespaceManager): + def __init__(self, name: str, bus: MessageBusClient): """ Constructor for the Extension Manager. The Extension Manager is responsible for managing the extensions that define additional GUI behaviours for specific platforms. @param name: Name of the extension manager @param bus: MessageBus instance - @param gui: GUI instance """ self.name = name self.bus = bus - self.gui = gui + self.homescreen_manager = HomescreenManager(self.bus) core_config = Configuration() enclosure_config = core_config.get("gui") or {} self.active_extension = enclosure_config.get("extension", "generic") @@ -53,10 +52,11 @@ def activate_extension(self, extension_id: str): f"falling back to 'generic'") cfg["module"] = "generic" self.extension = OVOSGuiFactory.create(cfg, bus=self.bus) - self.extension.bind_homescreen() - LOG.info(f"Extensions Manager: Activated Extension {extension_id} " - f"({self.extension.__class__})") + self.extension.bind_homescreen(self.homescreen_manager) + + LOG.info(f"Extensions Manager - Activated: {extension_id} " + f"({self.extension.__class__.__name__})") self.bus.emit( Message("extension.manager.activated", {"id": extension_id})) diff --git a/ovos_gui/homescreen.py b/ovos_gui/homescreen.py index 6512530..247914c 100644 --- a/ovos_gui/homescreen.py +++ b/ovos_gui/homescreen.py @@ -1,35 +1,26 @@ +from threading import Thread from typing import List, Optional -from ovos_bus_client import Message, MessageBusClient -from ovos_bus_client.message import dig_for_message from ovos_config.config import Configuration, update_mycroft_config +from ovos_utils.log import LOG, log_deprecation -from ovos_utils.log import LOG, deprecated, log_deprecation - -from ovos_gui.namespace import NamespaceManager -from threading import Thread +from ovos_bus_client import Message, MessageBusClient +from ovos_bus_client.message import dig_for_message class HomescreenManager(Thread): - def __init__(self, bus: MessageBusClient, gui: NamespaceManager): + def __init__(self, bus: MessageBusClient): super().__init__() self.bus = bus - self.gui = gui self.homescreens: List[dict] = [] self.mycroft_ready = False - # TODO: If service starts after `mycroft_ready`, - # homescreen is never shown + self.bus.on('homescreen.manager.add', self.add_homescreen) self.bus.on('homescreen.manager.remove', self.remove_homescreen) self.bus.on('homescreen.manager.list', self.get_homescreens) - self.bus.on("homescreen.manager.get_active", - self.handle_get_active_homescreen) - self.bus.on("homescreen.manager.set_active", - self.handle_set_active_homescreen) - self.bus.on("homescreen.manager.disable_active", - self.disable_active_homescreen) - self.bus.on("mycroft.mark2.register_idle", - self.register_old_style_homescreen) + self.bus.on("homescreen.manager.get_active", self.handle_get_active_homescreen) + self.bus.on("homescreen.manager.set_active", self.handle_set_active_homescreen) + self.bus.on("homescreen.manager.disable_active", self.disable_active_homescreen) self.bus.on("homescreen.manager.show_active", self.show_homescreen) self.bus.on("mycroft.ready", self.set_mycroft_ready) @@ -43,10 +34,9 @@ def add_homescreen(self, message: Message): """ Handle `homescreen.manager.add` and add the requested homescreen if it has not yet been added. - @param message: Message containing homescreen id/class to add + @param message: Message containing homescreen id to add """ homescreen_id = message.data["id"] - homescreen_class = message.data["class"] if any((homescreen['id'] == homescreen_id for homescreen in self.homescreens)): @@ -55,7 +45,7 @@ def add_homescreen(self, message: Message): LOG.info(f"Homescreen Manager: Adding Homescreen {homescreen_id}") self.homescreens.append(message.data) - self.show_homescreen_on_add(homescreen_id, homescreen_class) + self.show_homescreen_on_add(homescreen_id) def remove_homescreen(self, message: Message): """ @@ -103,10 +93,14 @@ def get_active_homescreen(self) -> Optional[dict]: """ gui_config = Configuration().get("gui") or {} active_homescreen = gui_config.get("idle_display_skill") - LOG.debug(f"Homescreen Manager: Active Homescreen {active_homescreen}") + if not active_homescreen: + LOG.info("No homescreen enabled in mycroft.conf") + return + LOG.info(f"Active Homescreen: {active_homescreen}") for h in self.homescreens: if h["id"] == active_homescreen: return active_homescreen + LOG.error(f"{active_homescreen} not loaded!") def set_active_homescreen(self, homescreen_id: str): """ @@ -126,35 +120,24 @@ def reload_homescreens_list(self): Emit a request for homescreens to register via the Messagebus """ LOG.info("Homescreen Manager: Reloading Homescreen List") - self.collect_old_style_homescreens() self.bus.emit(Message("homescreen.manager.reload.list")) - def show_homescreen_on_add(self, homescreen_id: str, homescreen_class: str): + def show_homescreen_on_add(self, homescreen_id: str): """ Check if a homescreen should be displayed immediately upon addition @param homescreen_id: ID of added homescreen - @param homescreen_class: "class" (IdleDisplaySkill, MycroftSkill) - of homescreen """ if not self.mycroft_ready: - LOG.debug("Not ready yet, don't display") + LOG.debug("Not ready yet, don't display homescreen") return LOG.debug(f"Checking {homescreen_id}") if self.get_active_homescreen() != homescreen_id: # Added homescreen isn't the configured one, do nothing return - if homescreen_class == "IdleDisplaySkill": - LOG.debug(f"Displaying Homescreen {homescreen_id}") - self.bus.emit(Message("homescreen.manager.activate.display", - {"homescreen_id": homescreen_id})) - elif homescreen_class == "MycroftSkill": - log_deprecation(f"Homescreen skills should register listeners for " - f"`homescreen.manager.activate.display`. " - f"`{homescreen_id}.idle` messages will be removed.", - "0.1.0") - LOG.debug(f"Displaying Homescreen {homescreen_id}") - self.bus.emit(Message(f"{homescreen_id}.idle")) + LOG.info(f"Displaying Homescreen {homescreen_id}") + self.bus.emit(Message("homescreen.manager.activate.display", + {"homescreen_id": homescreen_id})) def disable_active_homescreen(self, message: Message): """ @@ -162,7 +145,6 @@ def disable_active_homescreen(self, message: Message): `idle_display_skill` as None. @param message: Message requesting homescreen disable """ - # TODO: Is this valid behavior? if Configuration().get("gui", {}).get("idle_display_skill"): LOG.info(f"Disabling idle_display_skill!") new_config = {"gui": {"idle_display_skill": None}} @@ -174,24 +156,22 @@ def show_homescreen(self, message: Optional[Message] = None): @param message: Optional `homescreen.manager.show_active` Message """ active_homescreen = self.get_active_homescreen() - LOG.debug(f"Requesting activation of {active_homescreen}") + if not active_homescreen: + LOG.info("No active homescreen to display") + return + LOG.info(f"Requesting activation of {active_homescreen}") for h in self.homescreens: if h.get("id") == active_homescreen: LOG.debug(f"matched homescreen skill: {h}") message = message or dig_for_message() or Message("") - if h["class"] == "IdleDisplaySkill": - LOG.debug(f"Displaying Homescreen {active_homescreen}") - self.bus.emit(message.forward( - "homescreen.manager.activate.display", - {"homescreen_id": active_homescreen})) - elif h["class"] == "MycroftSkill": - LOG.debug(f"Displaying Homescreen {active_homescreen}") - self.bus.emit(message.forward(f"{active_homescreen}.idle")) - else: - LOG.error(f"Requested homescreen has an invalid class: {h}") - return - LOG.warning(f"Requested {active_homescreen} not found in: " - f"{self.homescreens}") + LOG.debug(f"Displaying Homescreen {active_homescreen}") + self.bus.emit(message.forward( + "homescreen.manager.activate.display", + {"homescreen_id": active_homescreen})) + break + else: + LOG.warning(f"Requested {active_homescreen} not found in: " + f"{self.homescreens}") def set_mycroft_ready(self, message: Message): """ @@ -199,26 +179,5 @@ def set_mycroft_ready(self, message: Message): @param message: `mycroft.ready` Message """ self.mycroft_ready = True + self.reload_homescreens_list() self.show_homescreen() - - # Add compabitility with older versions of the Resting Screen Class - - def collect_old_style_homescreens(self): - """Trigger collection of older resting screens.""" - # TODO: Deprecate in 0.1.0 - self.bus.emit(Message("mycroft.mark2.collect_idle")) - - @deprecated("`mycroft.mark2.collect_idle` responses are deprecated", - "0.1.0") - def register_old_style_homescreen(self, message): - if "name" in message.data and "id" in message.data: - super_class_name = "MycroftSkill" - super_class_object = message.data["name"] - skill_id = message.data["id"] - _homescreen_entry = {"class": super_class_name, - "name": super_class_object, "id": skill_id} - LOG.debug(f"Homescreen Manager: Adding OLD Homescreen {skill_id}") - self.add_homescreen( - Message("homescreen.manager.add", _homescreen_entry)) - else: - LOG.error("Malformed idle screen registration received") diff --git a/ovos_gui/namespace.py b/ovos_gui/namespace.py index 3cde48f..b06cc10 100644 --- a/ovos_gui/namespace.py +++ b/ovos_gui/namespace.py @@ -42,23 +42,21 @@ import shutil from os import makedirs from os.path import join, dirname, isfile, exists -from threading import Event -from threading import Lock, Timer -from time import sleep +from threading import Event, Lock, Timer from typing import List, Union, Optional, Dict -from ovos_bus_client import Message, MessageBusClient from ovos_config.config import Configuration from ovos_utils.log import LOG, log_deprecation +from ovos_bus_client import Message, MessageBusClient from ovos_gui.bus import ( create_gui_service, determine_if_gui_connected, get_gui_websocket_config, send_message_to_gui, GUIWebsocketHandler ) -from ovos_gui.page import GuiPage from ovos_gui.gui_file_server import start_gui_http_server +from ovos_gui.page import GuiPage namespace_lock = Lock() @@ -84,7 +82,7 @@ def _validate_page_message(message: Message) -> bool: else: action = "removed" LOG.error(f"Page will not be {action} due to malformed data in the" - f"{message.msg_type} message") + f" {message.msg_type} message") return valid @@ -96,7 +94,7 @@ def _get_idle_display_config() -> str: config = Configuration() enclosure_config = config.get("gui") or {} idle_display_skill = enclosure_config.get("idle_display_skill") - LOG.info(f"Got idle_display_skill from config: {idle_display_skill}") + LOG.info(f"Configured homescreen: {idle_display_skill}") return idle_display_skill @@ -108,7 +106,7 @@ def _get_active_gui_extension() -> str: config = Configuration() enclosure_config = config.get("gui") or {} gui_extension = enclosure_config.get("extension", "generic") - LOG.info(f"Got extension from config: {gui_extension}") + LOG.info(f"Configured GUI extension: {gui_extension}") return gui_extension.lower() @@ -130,6 +128,7 @@ class Namespace: displayed at the same time data: a key/value pair representing the data used to populate the GUI """ + def __init__(self, skill_id: str): self.skill_id = skill_id self.persistent = False @@ -139,11 +138,23 @@ def __init__(self, skill_id: str): self.page_number = 0 self.session_set = False + @property + def page_names(self): + return [page.name for page in self.pages] + + @property + def active_page(self): + if len(self.pages): + if self.page_number >= len(self.pages): + return None # TODO - error ? + return self.pages[self.page_number] + return None + def add(self): """ Adds this namespace to the list of active namespaces. """ - LOG.info(f"Adding \"{self.skill_id}\" to active GUI namespaces") + LOG.info(f"GUI PROTOCOL - Adding \"{self.skill_id}\" to active namespaces") message = dict( type="mycroft.session.list.insert", namespace="mycroft.system.active_skills", @@ -157,7 +168,11 @@ def activate(self, position: int): Activate this namespace if its already in the list of active namespaces. @param position: position to move this namespace FROM """ - LOG.info(f"Activating GUI namespace \"{self.skill_id}\"") + if not len(self.pages): + LOG.error(f"Tried to activate namespace without loaded pages: \"{self.skill_id}\"") + return + + LOG.info(f"GUI PROTOCOL - Activating namespace \"{self.skill_id}\"") message = { "type": "mycroft.session.list.move", "namespace": "mycroft.system.active_skills", @@ -173,8 +188,7 @@ def remove(self, position: int): any session data. @param position: position to remove this namespace FROM """ - LOG.info(f"Removing {self.skill_id} from active GUI namespaces") - + LOG.info(f"GUI PROTOCOL - Removing \"{self.skill_id}\" from active namespaces") # unload the data first before removing the namespace # use the keys of the data to unload the data for key in self.data: @@ -199,13 +213,12 @@ def load_data(self, name: str, value: str): name: The name of the attribute value: The attribute's value """ + LOG.info(f"GUI PROTOCOL - Sending \"{self.skill_id}\" data -- {name} : {value} ") message = dict( type="mycroft.session.set", namespace=self.skill_id, data={name: value} ) - - # LOG.info(f"Setting data {message} in GUI namespace {self.skill_id}") send_message_to_gui(message) def unload_data(self, name: str): @@ -213,12 +226,12 @@ def unload_data(self, name: str): Delete data from the namespace @param name: name of property to delete """ + LOG.info(f"GUI PROTOCOL - Deleting namespace \"{self.skill_id}\" key: {name}") message = dict( type="mycroft.session.delete", property=name, namespace=self.skill_id ) - # LOG.info(f"Deleting data {message} from GUI namespace {self.skill_id}") send_message_to_gui(message) def get_position_of_last_item_in_data(self) -> int: @@ -242,8 +255,7 @@ def set_persistence(self, skill_type: str): else: # get the active page in the namespace - active_page = self.get_active_page() - + active_page = self.active_page # if type(persistence) == int: # Get the duration of the active page if it is not persistent if active_page is not None and not active_page.persistent: @@ -258,6 +270,7 @@ def set_persistence(self, skill_type: str): # else use the default duration of 30 seconds else: + LOG.warning(f"No active page, reset persistence for {self.skill_id}") self.persistent = False self.duration = 30 @@ -273,10 +286,14 @@ def load_pages(self, pages: List[GuiPage], show_index: int = 0): @param pages: list of pages to be displayed @param show_index: index of page to display (default 0) """ + if not pages: + LOG.error("No pages to load ?") + return if show_index is None: LOG.warning(f"Expected int show_index but got `None`. Default to 0") show_index = 0 new_pages = list() + target_page = pages[show_index] for page in pages: if page.id not in [p.id for p in self.pages]: @@ -285,15 +302,19 @@ def load_pages(self, pages: List[GuiPage], show_index: int = 0): self.pages.extend(new_pages) if new_pages: self._add_pages(new_pages) - - self._activate_page(pages[show_index]) + if show_index >= len(pages): + LOG.error( + f"Invalid page index requested: {show_index} , only {len(pages)} pages available for \"{self.skill_id}\"") + else: + LOG.info(f"Activating page {show_index} from: {[p.name for p in pages]} for \"{self.skill_id}\"") + self._activate_page(target_page) def _add_pages(self, new_pages: List[GuiPage]): """ Adds one or more pages to the active page list. @param new_pages: pages to add to the active page list """ - LOG.debug(f"namespace {self.skill_id} current pages: {self.pages}") + LOG.debug(f"namespace \"{self.skill_id}\" current pages: {self.pages}") LOG.debug(f"new_pages={new_pages}") # Find position of new page in self.pages @@ -305,41 +326,49 @@ def _add_pages(self, new_pages: List[GuiPage]): except Exception as e: LOG.exception(f"Error updating {client.framework} client: {e}") - def _activate_page(self, page: GuiPage): + def focus_page(self, page): """ Returns focus to a page already in the active page list. @param page: the page that will gain focus """ - LOG.info(f"Activating page {page.name} in GUI namespace {self.skill_id}") - LOG.debug(f"Current pages from _activate_page: {self.pages}") - # TODO: Simplify two loops into one (with unit test) - # get the index of the page in the self.pages list - page_index = 0 + # set the index of the page in the self.pages list + page_index = None for i, p in enumerate(self.pages): if p.id == page.id: + # save page index page_index = i break - self.page_number = page_index + # handle missing page (TODO, can this happen?) + if page_index is None: + LOG.warning("tried to activate page missing from pages list, inserting it at index 0") + page_index = 0 + self.pages.insert(0, page) + # update page data + else: + self.pages[page_index] = page - # set the page active attribute to True and update the self.pages list, - # mark all other pages as inactive - page.active = True + if page_index != self.page_number: + self.page_number = page_index + LOG.info(f"Focusing page {page.name} -- namespace \"{self.skill_id}\"") - for p in self.pages: - if p != page: - p.active = False - # update the self.pages list with the page active status changes - self.pages[self.pages.index(p)] = p + def _activate_page(self, page: GuiPage): + """ + Tells mycroft-gui to returns focus to a page - self.pages[page_index] = page + @param page: the page that will gain focus + """ + LOG.debug(f"Current pages from _activate_page: {self.pages}") + self.focus_page(page) + LOG.info( + f"GUI PROTOCOL - Sending event 'page_gained_focus' -- page: {page.name} -- namespace: \"{self.skill_id}\"") message = dict( type="mycroft.events.triggered", namespace=self.skill_id, event_name="page_gained_focus", - data=dict(number=page_index) + data={"number": self.page_number} ) send_message_to_gui(message) @@ -349,11 +378,10 @@ def remove_pages(self, positions: List[int]): @param positions: list of int page positions to remove """ - LOG.info(f"Removing pages from GUI namespace {self.skill_id}: {positions}") positions.sort(reverse=True) for position in positions: page = self.pages.pop(position) - LOG.info(f"Deleting {page} from GUI namespace {self.skill_id}") + LOG.info(f"GUI PROTOCOL - Deleting {page.name} -- namespace: \"{self.skill_id}\"") message = dict( type="mycroft.gui.list.remove", namespace=self.skill_id, @@ -367,68 +395,15 @@ def page_gained_focus(self, page_number: int): Updates the active page in `self.pages`. @param page_number: the index of the page that will gain focus """ - LOG.info( - f"Page {page_number} gained focus in GUI namespace {self.skill_id}") - self._activate_page(self.pages[page_number]) - - def page_update_interaction(self, page_number: int): - """ - Update the interaction of the page_number. - @param page_number: the index of the page to update - """ - - LOG.info(f"Page {page_number} update interaction in GUI namespace " - f"{self.skill_id}") - - page = self.pages[page_number] - if not page.persistent and page.duration > 0: - page.duration = page.duration / 2 - - # update the self.pages list with the page interaction status changes - self.pages[page_number] = page - self.set_persistence(skill_type="genericSkill") - - def get_page_at_position(self, position: int) -> GuiPage: - """ - Returns the page at the requested position in the active page list. - Requesting a position out of range will raise an IndexError. - @param position: index of the page to get - """ - return self.pages[position] - - def get_active_page(self) -> Optional[GuiPage]: - """ - Returns the currently active page from `self.pages` where the page - attribute `active` is true. - @returns: Active GuiPage if any, else None - """ - for page in self.pages: - if page.active: - return page - return None - - def get_active_page_index(self) -> Optional[int]: - """ - Get the active page index in `self.pages`. - @return: index of the active page if any, else None - """ - active_page = self.get_active_page() - if active_page is not None: - return self.pages.index(active_page) - - def index_in_pages_list(self, index: int) -> bool: - """ - Check if the active index is in the pages list - @param index: index to check - @return: True if index is valid in `self.pages - """ - return 0 < index < len(self.pages) + LOG.info(f"Page {page_number} gained focus -- namespace \"{self.skill_id}\"") + self.page_number = page_number + self._activate_page(self.active_page) def global_back(self): """ Returns to the previous page in the active page list. """ - if self.page_number > 0: + if self.page_number > 0: # go back 1 page self.remove_pages([self.page_number]) self.page_gained_focus(self.page_number - 1) @@ -485,7 +460,7 @@ def _init_gui_file_share(self): "Files will always be saved to " "XDG_CACHE_HOME/ovos_gui_file_server", "0.1.0") self.gui_file_path = config.get("server_path") or \ - join(xdg_cache_home(), "ovos_gui_file_server") + join(xdg_cache_home(), "ovos_gui_file_server") if config.get("gui_file_server"): self.gui_file_server = start_gui_http_server(self.gui_file_path) self._upload_system_resources() @@ -497,6 +472,7 @@ def _define_message_handlers(self): self.core_bus.on("gui.clear.namespace", self.handle_clear_namespace) self.core_bus.on("gui.event.send", self.handle_send_event) self.core_bus.on("gui.page.delete", self.handle_delete_page) + self.core_bus.on("gui.page.delete.all", self.handle_delete_all_pages) self.core_bus.on("gui.page.show", self.handle_show_page) self.core_bus.on("gui.page.upload", self.handle_receive_gui_pages) self.core_bus.on("gui.status.request", self.handle_status_request) @@ -505,6 +481,7 @@ def _define_message_handlers(self): self.core_bus.on("gui.page_interaction", self.handle_page_interaction) self.core_bus.on("gui.page_gained_focus", self.handle_page_gained_focus) self.core_bus.on("mycroft.skills.trained", self.handle_ready) + self.core_bus.on("mycroft.gui.screen.close", self.handle_namespace_global_back) def handle_ready(self, message): self._ready_event.set() @@ -569,8 +546,7 @@ def handle_receive_gui_pages(self, message: Message): LOG.exception(f"Failed to write {page}: {e}") if message.data["__from"] == self._active_homescreen: # Configured home screen skill just uploaded pages, show it again - self.core_bus.emit( - message.forward("homescreen.manager.show_active")) + self.core_bus.emit(message.forward("homescreen.manager.show_active")) def handle_clear_namespace(self, message: Message): """ @@ -584,8 +560,9 @@ def handle_clear_namespace(self, message: Message): "Request to delete namespace failed: no namespace specified" ) else: - with namespace_lock: - self._remove_namespace(namespace_name) + if self.loaded_namespaces.get(namespace_name): + with namespace_lock: + self._remove_namespace(namespace_name) @staticmethod def handle_send_event(message: Message): @@ -595,16 +572,38 @@ def handle_send_event(message: Message): message bus. """ try: + skill_id = message.data.get('__from') + event = message.data.get('event_name') + LOG.info(f"GUI PROTOCOL - Sending event '{event}' for namespace: {skill_id}") message = dict( type='mycroft.events.triggered', - namespace=message.data.get('__from'), - event_name=message.data.get('event_name'), + namespace=skill_id, + event_name=event, data=message.data.get('params') ) send_message_to_gui(message) except Exception: LOG.exception('Could not send event trigger') + def handle_delete_all_pages(self, message: Message): + """ + Handles request to remove all current pages from a namespace. + @param message: the message requesting page removal + """ + namespace_name = message.data["__from"] + except_pages = message.data.get("except") or [] + + if except_pages: + LOG.info(f"Got {namespace_name} request to delete all pages except: {except_pages}") + else: + LOG.info(f"Got {namespace_name} request to delete all pages") + + with namespace_lock: + namespace = self.loaded_namespaces.get(namespace_name) + if namespace: + to_rm = [p.name for p in namespace.pages if p.name not in except_pages] + self._remove_pages(namespace_name, to_rm) + def handle_delete_page(self, message: Message): """ Handles request to remove one or more pages from a namespace. @@ -613,7 +612,9 @@ def handle_delete_page(self, message: Message): message_is_valid = _validate_page_message(message) if message_is_valid: namespace_name = message.data["__from"] - pages_to_remove = message.data["page"] + pages_to_remove = message.data.get("page_names") or \ + message.data.get("page") # backwards compat + LOG.debug(f"Got {namespace_name} request to delete: {pages_to_remove}") with namespace_lock: self._remove_pages(namespace_name, pages_to_remove) @@ -627,12 +628,13 @@ def _remove_pages(self, namespace_name: str, pages_to_remove: List[str]): namespace = self.loaded_namespaces.get(namespace_name) if namespace is not None and namespace in self.active_namespaces: page_positions = [] - for index, page in enumerate(pages_to_remove): - if page == namespace.pages[index].id: + for index, page in enumerate(namespace.pages): + if page.name in pages_to_remove: page_positions.append(index) - page_positions.sort(reverse=True) - namespace.remove_pages(page_positions) + if page_positions: + page_positions.sort(reverse=True) + namespace.remove_pages(page_positions) @staticmethod def _parse_persistence(persistence: Optional[Union[int, bool]]) -> \ @@ -649,7 +651,7 @@ def _parse_persistence(persistence: Optional[Union[int, bool]]) -> \ elif isinstance(persistence, int): if persistence < 0: raise ValueError("Requested negative persistence") - return False, persistence + return False, persistence else: # Defines default behavior as displaying for 30 seconds return False, 30 @@ -688,12 +690,14 @@ def handle_show_page(self, message: Message): persistence = message.data["__idle"] show_index = message.data.get("index", None) + LOG.debug(f"Got {namespace_name} request to show: {page_ids_to_show} at index: {show_index}") + if not page_resource_dirs and page_ids_to_show and \ all((x.startswith("SYSTEM") for x in page_ids_to_show)): page_resource_dirs = {"all": self._system_res_dir} if not all((page_ids_to_show, page_resource_dirs)): - LOG.info(f"Handling legacy page request: data={message.data}") + LOG.warning(f"GUI resources have not yet been uploaded for namespace: {namespace_name}") pages = self._legacy_show_page(message) else: pages = list() @@ -712,8 +716,19 @@ def handle_show_page(self, message: Message): pages.append(GuiPage(url, name, persist, duration, page, namespace_name, page_resource_dirs)) + if not pages: + LOG.error(f"Activated namespace '{namespace_name}' has no pages! " + f"Did you provide 'ui_directories' ?") + LOG.error(f"Can't show page, bad message: {message.data}") + return + with namespace_lock: - self._activate_namespace(namespace_name) + if not self.active_namespaces: + self._activate_namespace(namespace_name) + else: + active_namespace = self.active_namespaces[0] + if active_namespace.skill_id != namespace_name: + self._activate_namespace(namespace_name) self._load_pages(pages, show_index) self._update_namespace_persistence(persistence) @@ -724,19 +739,22 @@ def _activate_namespace(self, namespace_name: str): @param namespace_name: the name of the namespace to load """ namespace = self._ensure_namespace_exists(namespace_name) - LOG.debug(f"Activating namespace: {namespace_name}") if namespace in self.active_namespaces: namespace_position = self.active_namespaces.index(namespace) namespace.activate(namespace_position) - self.active_namespaces.insert( - 0, self.active_namespaces.pop(namespace_position) - ) + if namespace_position != 0: + LOG.info(f"Activating namespace: {namespace_name}") + self.active_namespaces.insert( + 0, self.active_namespaces.pop(namespace_position) + ) else: + LOG.info(f"New namespace: {namespace_name}") namespace.add() self.active_namespaces.insert(0, namespace) - for key, value in namespace.data.items(): - namespace.load_data(key, value) + # sync initial state + for key, value in namespace.data.items(): + namespace.load_data(key, value) self._emit_namespace_displayed_event() @@ -760,8 +778,18 @@ def _load_pages(self, pages_to_show: List[GuiPage], show_index: int): @param pages_to_show: list of pages to be loaded @param show_index: index to load pages at """ + if not len(pages_to_show) or show_index >= len(pages_to_show): + LOG.error(f"requested invalid page index: {show_index}, defaulting to last page") + show_index = len(pages_to_show) - 1 + active_namespace = self.active_namespaces[0] + oldp = [p.name for p in active_namespace.pages] active_namespace.load_pages(pages_to_show, show_index) + # LOG only on change + if oldp != [p.name for p in active_namespace.pages]: + pn = active_namespace.page_number + LOG.info(f"Loaded {active_namespace.skill_id} at index: {pn} " + f"pages: {[p.name for p in active_namespace.pages]}") def _update_namespace_persistence(self, persistence: Union[bool, int]): """ @@ -774,17 +802,19 @@ def _update_namespace_persistence(self, persistence: Union[bool, int]): the skill is showing the pages. @param persistence: length of time the namespace should be displayed """ - LOG.debug(f"Setting namespace persistence to {persistence}") - for position, namespace in enumerate(self.active_namespaces): - if position: + for idx, namespace in enumerate(self.active_namespaces): + if idx: if not namespace.persistent: self._remove_namespace(namespace.skill_id) else: + if namespace.persistent != persistence: + LOG.info(f"Setting namespace '{namespace.skill_id}' persistence to: {persistence}") + namespace.persistent = persistence + if namespace.skill_id == self.idle_display_skill: namespace.set_persistence(skill_type="idleDisplaySkill") else: namespace.set_persistence(skill_type="genericSkill") - # check if there is a scheduled remove_namespace_timer # and cancel it if namespace.persistent and namespace.skill_id in \ @@ -793,9 +823,10 @@ def _update_namespace_persistence(self, persistence: Union[bool, int]): self._del_namespace_in_remove_timers(namespace.skill_id) if not namespace.persistent: - LOG.info("It is being scheduled here") self._schedule_namespace_removal(namespace) + self.active_namespaces[idx] = namespace + def _schedule_namespace_removal(self, namespace: Namespace): """ Uses a timer thread to remove the namespace. @@ -810,8 +841,8 @@ def _schedule_namespace_removal(self, namespace: Namespace): self._remove_namespace_via_timer, args=(namespace.skill_id,) ) - LOG.debug(f"Scheduled removal of namespace {namespace.skill_id} in " - f"duration {namespace.duration}") + LOG.info(f"Removal of namespace {namespace.skill_id} in " + f"{namespace.duration} seconds") remove_namespace_timer.start() self.remove_namespace_timers[namespace.skill_id] = remove_namespace_timer @@ -828,8 +859,6 @@ def _remove_namespace(self, namespace_name: str): Removes a namespace from the active namespace stack. @param namespace_name: name of namespace to remove """ - LOG.debug(f"Removing namespace {namespace_name}") - # Remove all timers associated with the namespace if namespace_name in self.remove_namespace_timers: self.remove_namespace_timers[namespace_name].cancel() @@ -837,12 +866,9 @@ def _remove_namespace(self, namespace_name: str): namespace: Namespace = self.loaded_namespaces.get(namespace_name) if namespace is not None and namespace in self.active_namespaces: + LOG.info(f"Removing namespace {namespace_name}") self.core_bus.emit(Message("gui.namespace.removed", data={"skill_id": namespace.skill_id})) - if self.active_extension == "Bigscreen": - # TODO: Define callback or event instead of arbitrary sleep - # wait for window management in bigscreen extension to finish - sleep(1) namespace_position = self.active_namespaces.index(namespace) namespace.remove(namespace_position) self.active_namespaces.remove(namespace) @@ -856,6 +882,7 @@ def _emit_namespace_displayed_event(self): if self.active_namespaces: displaying_namespace = self.active_namespaces[0] message_data = dict(skill_id=displaying_namespace.skill_id) + # TODO - no known listeners ? self.core_bus.emit( Message("gui.namespace.displayed", data=message_data) ) @@ -896,8 +923,6 @@ def _update_namespace_data(self, namespace_name: str, data: dict): namespace = self._ensure_namespace_exists(namespace_name) for key, value in data.items(): if key not in RESERVED_KEYS and namespace.data.get(key) != value: - LOG.debug( - f"Setting {key} to {value} in namespace {namespace.skill_id}") namespace.data[key] = value if namespace in self.active_namespaces: namespace.load_data(key, value) @@ -948,16 +973,21 @@ def handle_page_interaction(self, message: Message): # GUI has interacted with a page # Update and increase the namespace duration and reset the remove timer namespace_name = message.data.get("skill_id") - LOG.debug(f"GUI interacted with page in namespace {namespace_name}") - if namespace_name == self.idle_display_skill: - return - else: - namespace = self.loaded_namespaces.get(namespace_name) - if not namespace.persistent: - if self.remove_namespace_timers[namespace.skill_id]: - self.remove_namespace_timers[namespace.skill_id].cancel() - self._del_namespace_in_remove_timers(namespace.skill_id) - self._schedule_namespace_removal(namespace) + pidx = message.data.get('page_number') + LOG.info(f"GUI interacted with page in namespace {namespace_name}") + namespace = self.loaded_namespaces.get(namespace_name) + + if namespace and pidx is not None and pidx != namespace.page_number: + # update focused page + namespace.page_gained_focus(pidx) + + # reschedule namespace timeout + if namespace_name != self.idle_display_skill and \ + not namespace.persistent and \ + self.remove_namespace_timers[namespace.skill_id]: + self.remove_namespace_timers[namespace.skill_id].cancel() + self._del_namespace_in_remove_timers(namespace.skill_id) + self._schedule_namespace_removal(namespace) def handle_page_gained_focus(self, message: Message): """ @@ -984,7 +1014,12 @@ def handle_namespace_global_back(self, message: Optional[Message]): namespace_name = self.active_namespaces[0].skill_id namespace = self.loaded_namespaces.get(namespace_name) if namespace in self.active_namespaces: - namespace.global_back() + # prev page + if namespace.page_number > 0: + namespace.global_back() + # homescreen + else: + self.core_bus.emit(Message("homescreen.manager.show_active")) def _del_namespace_in_remove_timers(self, namespace_name: str): """ diff --git a/ovos_gui/page.py b/ovos_gui/page.py index 641f867..b6eeef7 100644 --- a/ovos_gui/page.py +++ b/ovos_gui/page.py @@ -26,8 +26,6 @@ class GuiPage: namespace: Optional[str] = None resource_dirs: Optional[dict] = None - active: bool = False - @property def id(self): """ diff --git a/ovos_gui/service.py b/ovos_gui/service.py index c30e1e3..ac34451 100644 --- a/ovos_gui/service.py +++ b/ovos_gui/service.py @@ -32,7 +32,7 @@ def __init__(self, alive_hook=on_alive, started_hook=on_started, stopping_hook=on_stopping): self.bus = MessageBusClient() self.extension_manager = None - self.gui = NamespaceManager(self.bus) + self.namespace_manager = None callbacks = StatusCallbackMap(on_started=started_hook, on_alive=alive_hook, on_ready=ready_hook, @@ -61,16 +61,18 @@ def run(self): self.status.set_alive() self._init_bus_client() + self.extension_manager = ExtensionsManager("EXTENSION_SERVICE", self.bus) + self.namespace_manager = NamespaceManager(self.bus) + # Bus is connected, check if the skills service is ready resp = self.bus.wait_for_response( Message("mycroft.skills.is_ready", context={"source": "gui", "destination": ["skills"]})) if resp and resp.data.get("status"): LOG.debug("Skills service already running") - self.gui.handle_ready(resp) + self.namespace_manager.handle_ready(resp) + self.extension_manager.homescreen_manager.set_mycroft_ready(resp) - self.extension_manager = ExtensionsManager("EXTENSION_SERVICE", - self.bus, self.gui) self.status.set_ready() LOG.info(f"GUI Service Ready") diff --git a/ovos_gui/version.py b/ovos_gui/version.py index 4883eff..be3eeb2 100644 --- a/ovos_gui/version.py +++ b/ovos_gui/version.py @@ -1,6 +1,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 -VERSION_MINOR = 0 -VERSION_BUILD = 4 -VERSION_ALPHA = 5 +VERSION_MINOR = 1 +VERSION_BUILD = 0 +VERSION_ALPHA = 1 # END_VERSION_BLOCK diff --git a/test/unittests/test_extensions.py b/test/unittests/test_extensions.py index 907ac65..ea2ba9a 100644 --- a/test/unittests/test_extensions.py +++ b/test/unittests/test_extensions.py @@ -3,6 +3,8 @@ import ovos_gui.extensions from ovos_utils.messagebus import FakeBus +from ovos_gui.homescreen import HomescreenManager +from ovos_gui.extensions import ExtensionsManager from .mocks import base_config PATCH_MODULE = "ovos_gui.extensions" @@ -24,23 +26,17 @@ class TestExtensionManager(unittest.TestCase): name = "TestManager" @classmethod - @patch("ovos_gui.namespace.create_gui_service") - def setUpClass(cls, create_gui) -> None: - from ovos_gui.extensions import ExtensionsManager - from ovos_gui.namespace import NamespaceManager + def setUpClass(cls) -> None: ovos_gui.extensions.Configuration = Mock(return_value=_MOCK_CONFIG) - cls.extension_manager = ExtensionsManager(cls.name, cls.bus, - NamespaceManager(cls.bus)) - create_gui.assert_called_once_with(cls.extension_manager.gui) + cls.extension_manager = ExtensionsManager(cls.name, cls.bus) def test_00_extensions_manager_init(self): - from ovos_gui.namespace import NamespaceManager self.assertEqual(self.extension_manager.name, self.name) self.assertEqual(self.extension_manager.bus, self.bus) - self.assertIsInstance(self.extension_manager.gui, NamespaceManager) - self.assertEqual(self.extension_manager.gui.core_bus, self.bus) + self.assertIsInstance(self.extension_manager.homescreen_manager, HomescreenManager) + self.assertEqual(self.extension_manager.homescreen_manager.bus, self.bus) self.assertIsInstance(self.extension_manager.active_extension, str) @patch("ovos_gui.extensions.OVOSGuiFactory.create") diff --git a/test/unittests/test_homescreen.py b/test/unittests/test_homescreen.py index 5660260..7752844 100644 --- a/test/unittests/test_homescreen.py +++ b/test/unittests/test_homescreen.py @@ -9,12 +9,10 @@ class TestHomescreenManager(unittest.TestCase): from ovos_gui.homescreen import HomescreenManager bus = FakeBus() - gui = NamespaceManager(bus) - homescreen_manager = HomescreenManager(bus, gui) + homescreen_manager = HomescreenManager(bus) def test_00_homescreen_manager_init(self): self.assertEqual(self.homescreen_manager.bus, self.bus) - self.assertEqual(self.homescreen_manager.gui, self.gui) self.assertFalse(self.homescreen_manager.mycroft_ready) self.assertIsInstance(self.homescreen_manager.homescreens, list) # TODO: Test messagebus handlers diff --git a/test/unittests/test_namespace.py b/test/unittests/test_namespace.py index 44e5055..a4a33a4 100644 --- a/test/unittests/test_namespace.py +++ b/test/unittests/test_namespace.py @@ -14,29 +14,31 @@ # """Tests for the GUI namespace helper class.""" from os import makedirs +from os.path import join, dirname, isdir, isfile from shutil import rmtree from unittest import TestCase, mock from unittest.mock import Mock -from os.path import join, dirname, isdir, isfile + from ovos_bus_client.message import Message from ovos_utils.messagebus import FakeBus -from ovos_gui.page import GuiPage + from ovos_gui.namespace import Namespace +from ovos_gui.page import GuiPage PATCH_MODULE = "ovos_gui.namespace" class TestNamespaceFunctions(TestCase): def test_validate_page_message(self): - from ovos_gui.namespace import _validate_page_message + pass # TODO def test_get_idle_display_config(self): - from ovos_gui.namespace import _get_idle_display_config + pass # TODO def test_get_active_gui_extension(self): - from ovos_gui.namespace import _get_active_gui_extension + pass # TODO @@ -61,6 +63,13 @@ def test_add(self): send_message_mock.assert_called_with(add_namespace_message) def test_activate(self): + self.namespace.load_pages([ + GuiPage(name="foo", url="", persistent=False, duration=False), + GuiPage(name="bar", url="", persistent=False, duration=False), + GuiPage(name="foobar", url="", persistent=False, duration=False), + GuiPage(name="baz", url="", persistent=False, duration=False), + GuiPage(name="foobaz", url="", persistent=False, duration=False) + ]) activate_namespace_message = { "type": "mycroft.session.list.move", "namespace": "mycroft.system.active_skills", @@ -120,9 +129,9 @@ def test_set_persistence_boolean(self): self.assertTrue(self.namespace.persistent) def test_load_pages_new(self): - self.namespace.pages = [GuiPage("foo", "foo.qml", True, 0), - GuiPage("bar", "bar.qml", False, 30)] - new_pages = [GuiPage("foobar", "foobar.qml", False, 30)] + self.namespace.pages = [GuiPage(name="foo", url="foo.qml", persistent=True, duration=0), + GuiPage(name="bar", url="bar.qml", persistent=False, duration=30)] + new_pages = [GuiPage(name="foobar", url="foobar.qml", persistent=False, duration=30)] load_page_message = dict( type="mycroft.events.triggered", namespace="foo", @@ -137,9 +146,9 @@ def test_load_pages_new(self): self.assertListEqual(self.namespace.pages, self.namespace.pages) def test_load_pages_existing(self): - self.namespace.pages = [GuiPage("foo", "foo.qml", True, 0), - GuiPage("bar", "bar.qml", False, 30)] - new_pages = [GuiPage("foo", "foo.qml", True, 0)] + self.namespace.pages = [GuiPage(name="foo", url="foo.qml", persistent=True, duration=0), + GuiPage(name="bar", url="bar.qml", persistent=False, duration=30)] + new_pages = [GuiPage(name="foo", url="foo.qml", persistent=True, duration=0)] load_page_message = dict( type="mycroft.events.triggered", namespace="foo", @@ -162,7 +171,9 @@ def test_activate_page(self): pass def test_remove_pages(self): - self.namespace.pages = ["foo", "bar", "foobar"] + self.namespace.pages = [GuiPage(name="foo", url="", persistent=False, duration=False), + GuiPage(name="bar", url="", persistent=False, duration=False), + GuiPage(name="foobar", url="", persistent=False, duration=False)] remove_page_message = dict( type="mycroft.gui.list.remove", namespace="foo", @@ -173,7 +184,7 @@ def test_remove_pages(self): with mock.patch(patch_function) as send_message_mock: self.namespace.remove_pages([2]) send_message_mock.assert_called_with(remove_page_message) - self.assertListEqual(["foo", "bar"], self.namespace.pages) + self.assertListEqual(["foo", "bar"], self.namespace.page_names) def test_page_gained_focus(self): # TODO @@ -262,7 +273,7 @@ def test_handle_send_event(self): def test_handle_delete_page_active_namespace(self): namespace = Namespace("foo") - namespace.pages = [GuiPage("bar", "bar.qml", True, 0)] + namespace.pages = [GuiPage(name="bar", url="bar.qml", persistent=True, duration=0)] namespace.remove_pages = mock.Mock() self.namespace_manager.loaded_namespaces = dict(foo=namespace) self.namespace_manager.active_namespaces = [namespace] @@ -326,7 +337,7 @@ def test_handle_show_page(self): self.namespace_manager._legacy_show_page.assert_called_once_with(message) self.namespace_manager._activate_namespace.assert_called_with("foo") self.namespace_manager._load_pages.assert_called_with(["pages"], None) - self.namespace_manager._update_namespace_persistence.\ + self.namespace_manager._update_namespace_persistence. \ assert_called_with(10) # With resource info @@ -347,7 +358,7 @@ def test_handle_show_page(self): self.namespace_manager._load_pages.assert_called_with([expected_page1, expected_page2], 1) - self.namespace_manager._update_namespace_persistence.\ + self.namespace_manager._update_namespace_persistence. \ assert_called_with(False) # System resources @@ -365,7 +376,7 @@ def test_handle_show_page(self): "skill_no_res") self.namespace_manager._load_pages.assert_called_with([expected_page], 2) - self.namespace_manager._update_namespace_persistence.\ + self.namespace_manager._update_namespace_persistence. \ assert_called_with(True) # TODO: Test page_names with files and URIs diff --git a/test/unittests/test_page.py b/test/unittests/test_page.py index b7756c9..fe3abec 100644 --- a/test/unittests/test_page.py +++ b/test/unittests/test_page.py @@ -14,7 +14,6 @@ def test_gui_page_legacy(self): self.assertEqual(page.name, name) self.assertEqual(page.persistent, persistent) self.assertEqual(page.duration, 0) - self.assertFalse(page.active) self.assertEqual(page.id, page.url) self.assertEqual(page.get_uri(), page.url) self.assertEqual(page.get_uri("qt6", "http://0.0.0.0:80"), page.url)