zauberzeug / nicegui

Create web-based user interfaces with Python. The nice way.
https://nicegui.io
MIT License
9.8k stars 582 forks source link

Unable to create a context for a dialog #3915

Open beastGrendel opened 2 days ago

beastGrendel commented 2 days ago

Description

Good day,

I am trying to implement a small GUI for a ROS2 project. I am trying to send a service request to the GUI, so it can be confirmed or denied through a dialog pop up. I am generating a simple GUI in a function called from the constructor in which both the client as well as the dialog are assigned as class variables. However, once I am in the async function to await the dialog input no matter what I do (e.g. wait self.client: ... or simply await self.dialog), I always get back the error that "the current slot cannot be determined because the slot stack for this task is empty." I tried to reduce the example as much as possible:

import threading
from pathlib import Path

import rclpy
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node

from nicegui import Client, app, ui, ui_run

class OperatorGUI(Node):
    def __init__(self) -> None:
        super().__init__("nicegui")

        # Construct GUI
        self.construct_ui()

        # Confirm mission
        self.confirm_mission()

    def construct_ui(self):
        with Client.auto_index_client as self.gui_client:
            with ui.row().classes('items-stretch'):
                with ui.card().classes('w-44 text-center items-center'):
                    ui.label('Request Mission').classes('text-2xl')
                    ui.button('Request Mission!', on_click=lambda _:self.request_mission())
            with ui.dialog() as self.confirm_mission_dialog, ui.card():
                ui.label('Confirm mission?')
                with ui.row():
                    ui.button('Confirm', on_click=lambda _: self.confirm_mission_dialog.submit(True))
                    ui.button('Deny', on_click=lambda _: self.confirm_mission_dialog.submit(False))

    def request_mission(self):
        pass

    def confirm_mission(self):
        # function to respond to the service call
        result = self.show_confirm_dialog()
        if result is True:
            ui.notify('Mission confirmed', type='positive')
        else:
            ui.notify('Mission denied', type='negative')
        return result

    async def show_confirm_dialog(self):
        with self.gui_client:
            result = await self.confirm_mission_dialog

        return result

def main() -> None:
    # NOTE: This function is defined as the ROS entry point in setup.py, but it's empty to enable NiceGUI auto-reloading
    pass

def ros_main() -> None:
    rclpy.init()
    node = OperatorGUI()
    try:
        rclpy.spin(node)
    except ExternalShutdownException:
        pass

app.on_startup(lambda: threading.Thread(target=ros_main).start())
ui_run.APP_IMPORT_STRING = f'{__name__}:app'  # ROS2 uses a non-standard module name, so we need to specify it here
ui.run(uvicorn_reload_dirs=str(Path(__file__).parent.resolve()), favicon='🤖', reload=False)

I would expect the dialog to open so I could select either Deny or Confirm, however I get the following error:

[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [test_gui-1]: process started with pid [110431]
[test_gui-1] Exception in thread Thread-2 (ros_main):
[test_gui-1] Traceback (most recent call last):
[test_gui-1]   File "/usr/lib/python3.10/threading.py", line 1016, in _bootstrap_inner
[test_gui-1]     self.run()
[test_gui-1]   File "/usr/lib/python3.10/threading.py", line 953, in run
[test_gui-1]     self._target(*self._args, **self._kwargs)
[test_gui-1]   File "/home/user/Documents/proj/ws/install/local_gui/lib/python3.10/site-packages/local_gui/test_gui.py", line 59, in ros_main
[test_gui-1]     node = OperatorGUI()
[test_gui-1]   File "/home/user/Documents/proj/ws/install/local_gui/lib/python3.10/site-packages/local_gui/test_gui.py", line 20, in __init__
[test_gui-1]     self.confirm_mission()
[test_gui-1]   File "/home/user/Documents/proj/ws/install/local_gui/lib/python3.10/site-packages/local_gui/test_gui.py", line 43, in confirm_mission
[test_gui-1]     ui.notify('Mission denied', type='negative')
[test_gui-1]   File "/home/user/.local/lib/python3.10/site-packages/nicegui/functions/notify.py", line 52, in notify
[test_gui-1]     client = context.client
[test_gui-1]   File "/home/user/.local/lib/python3.10/site-packages/nicegui/context.py", line 31, in client
[test_gui-1]     return self.slot.parent.client
[test_gui-1]   File "/home/user/.local/lib/python3.10/site-packages/nicegui/context.py", line 23, in slot
[test_gui-1]     raise RuntimeError('The current slot cannot be determined because the slot stack for this task is empty.\n'
[test_gui-1] RuntimeError: The current slot cannot be determined because the slot stack for this task is empty.
[test_gui-1] This may happen if you try to create UI from a background task.
[test_gui-1] To fix this, enter the target slot explicitly using `with container_element:`.
[test_gui-1] /usr/lib/python3.10/threading.py:1018: RuntimeWarning: coroutine 'OperatorGUI.show_confirm_dialog' was never awaited
[test_gui-1]   self._invoke_excepthook(self)
[test_gui-1] RuntimeWarning: Enable tracemalloc to get the object allocation traceback

Interestingly enough, with the ROS2 functionalities implemented (not as minimal as this modification of the problematic code), I do not get the warning to enable tracemalloc, but only the runtime error regarding the empty slot stack. Any idea what I am doing wrong, or how I can work with opening a dialog and submitting values within such a class function?

Kind regards and thank you, Stefan

rodja commented 1 day ago

You seem not to use the .open method on the created dialog. Please compare to the example https://nicegui.io/documentation/dialog#awaitable_dialog.

falkoschindler commented 1 day ago

I think the problem is that confirm_mission is called without UI context so that ui.notify doesn't know on which client (browser tab) to show the notification. Try wrapping it with with Client.auto_index_client like in construct_ui above.

beastGrendel commented 1 day ago

You seem not to use the .open method on the created dialog. Please compare to the example https://nicegui.io/documentation/dialog#awaitable_dialog.

Thank you for answering. I do not see the .open method being used in the example for awaiting the dialog, especially since I have to submit values with it, no? Am I blind?

beastGrendel commented 1 day ago

I think the problem is that confirm_mission is called without UI context so that ui.notify doesn't know on which client (browser tab) to show the notification. Try wrapping it with with Client.auto_index_client like in construct_ui above.

Also thank you for replying. I have tried that as well, just retried it. Same error again, it cannot determine the current slot :/

rodja commented 15 hours ago

I do not see the .open method being used in the example for awaiting the dialog,...

You are right. awaiting the dialog internally opens it.

The tricky part is to make the result of the async show_confirm_dialog available in the synchronous confirm_mission. Here I demo it without a ROS2 node to reduce the dependencies. I assume you will use confirm_mission as a service callback in your real application:

from nicegui import Client, background_tasks, app, ui
from concurrent.futures import Future
from threading import Thread

class OperatorGUI:
    def __init__(self) -> None:
        self.construct_ui()

        # Start a thread to simulate a ROS2 service callback which runs on the thread pool
        Thread(target=self.confirm_mission).start()

    def construct_ui(self):
        with Client.auto_index_client as self.gui_client:
            with ui.dialog() as self.confirm_mission_dialog, ui.card():
                ui.label('Confirm mission?')
                with ui.row():
                    ui.button('Confirm', on_click=lambda _: self.confirm_mission_dialog.submit(True))
                    ui.button('Deny', on_click=lambda _: self.confirm_mission_dialog.submit(False))

    def confirm_mission(self):
        confirmation = Future()
        background_tasks.create(self.show_confirm_dialog(confirmation))
        return confirmation.result(timeout=30.0)

    async def show_confirm_dialog(self, confirmation: Future):
        with self.gui_client:
            result = await self.confirm_mission_dialog
            if result is True:
                ui.notify('Mission confirmed', type='positive')
            else:
                ui.notify('Mission denied', type='negative')
            confirmation.set_result(result)

def startup():
    OperatorGUI()

app.on_startup(startup)

ui.run()
falkoschindler commented 13 hours ago

I'm wondering if we need the Future at all. In a nutshell, it looks like we can boil it down to this:

class OperatorGUI:
    def __init__(self) -> None:
        with Client.auto_index_client:
            with ui.dialog() as self.dialog, ui.card():
                ui.label('Confirm mission?')
                ui.button('Confirm', on_click=lambda _: self.dialog.submit(True))

        Thread(target=lambda: background_tasks.create(self.show_dialog())).start()  # simulate ROS2 service callback

    async def show_dialog(self):
        with Client.auto_index_client:
            if await self.dialog:
                ui.notify('Mission confirmed', type='positive')

app.on_startup(OperatorGUI)