hoffstadt / DearPyGui

Dear PyGui: A fast and powerful Graphical User Interface Toolkit for Python with minimal dependencies
https://dearpygui.readthedocs.io/en/latest/
MIT License
13.38k stars 696 forks source link

pythonic messagebox (popup) #2188

Open spectereye opened 1 year ago

spectereye commented 1 year ago

Hi, any pythonic way to use messagebox (popup) as below?

do_sth_step1(...)
user_response = msgbox('Confirm?')  # Yes/No
if user_response == 'Yes':
    do_sth_step2(...)

Currently i have to move do_sth_step2 to callback to msgbox (Yes/No buttons on popup window), like callback chain or "callback hell" if nest popups ... Thanks for any guide!

v-ein commented 1 year ago

You can do that via asyncio, but that would require you to set up asyncio in your program. If it's only one message box, I'd say go with callbacks.

BTW "nested popus" are not that easy in DPG. When you open a popup, earlier popups close. See #2183, #1393, #1019, #990. As far as I understand, it is possible to "fix" DPG so that a new popup does not close the old one; however, this is quite a bit of work, and requires certain architectural changes. Don't expect that to happen any time soon - or happen at all, for that matter. Currently, DPG renders all windows at the same level, whereas for popups to keep open, one popup needs to be nested into another as seen by Dear ImGui. That's quite a change.

spectereye commented 1 year ago

Thanks @v-ein, i tried asyncio below, seems still not work? (I have little experience of asyncio)

import asyncio
import dearpygui.dearpygui as dpg
dpg.create_context()

async def msgbox():

    def on_msgbox_btn_click(sender, data, user_data):
        nonlocal resp
        resp = dpg.get_item_label(sender)
        # dpg.hide_item(popup)
        dpg.delete_item(popup)

    with dpg.popup(parent=dpg.last_item(), modal=True) as popup:
        dpg.add_text('goto step 2?')
        with dpg.group(horizontal=True):
            dpg.add_button(label='Yes', callback=on_msgbox_btn_click)
            dpg.add_button(label='No', callback=on_msgbox_btn_click)

    resp = None
    dpg.show_item(popup)
    while not resp:
        print('waiting for response ...')
        await asyncio.sleep(0.5)
    return resp

async def on_btn_click(sender, data, user_data):
    print('do sth step 1 ...')
    # resp = asyncio.run(msgbox()) # use this without `async def` doesn't work either, no action from Yes/No button click
    resp = await msgbox()
    if resp == 'Yes':
        print('do sth step 2 ...')

async def main():
    with dpg.window(tag='prim'):
        dpg.add_button(label='Submit', callback=on_btn_click)
    dpg.create_viewport(title='test msgbox', width=600, height=300)

    # start_dpg()
    dpg.setup_dearpygui()
    dpg.set_primary_window('prim', True)
    dpg.show_viewport()
    dpg.start_dearpygui()
    dpg.destroy_context()

if __name__ == '__main__':
    asyncio.run(main())
v-ein commented 1 year ago

The problem here is that dpg.start_dearpygui() is not async. Moreover, on_btn_click is called as a regular (synchronous) function, and not even in asyncio thread. To do that properly, you'd need to implement your own rendering loop, set manual_callback_management, and handle callbacks within that loop.

More about manual_callback_management: https://dearpygui.readthedocs.io/en/latest/documentation/item-callbacks.html?highlight=manual_callbacks#debugging-callbacks-new-in-1-2

Unfortunately there's no ready-to-use asyncio example in DPG docs (yet). This topic has seen some discussions on Discord so you might want to have a look - here's a link to Discord (copied from DPG readme): https://discord.gg/tyE7Gu4

spectereye commented 1 year ago

@v-ein thanks for your great guide, finally i work out the solution below, hope it can help somebody looking for this too.

target: pythonic popup messagebox in dearpygui

do_sth_step1(...)
user_response = msgbox('Confirm?')  # Yes/No
if user_response == 'Yes':
    do_sth_step2(...)

solution:

import time
import inspect
import dearpygui.dearpygui as dpg
dpg.create_context()

dpg_callback_queue = []

def msgbox():

    def on_msgbox_btn_click(sender, data, user_data):
        nonlocal resp
        resp = dpg.get_item_label(sender)
        dpg.hide_item(popup)
        # dpg.delete_item(popup)

    with dpg.popup(parent=dpg.last_item(), modal=True) as popup:
        dpg.add_text('goto step 2?')
        with dpg.group(horizontal=True):
            dpg.add_button(label='Yes', callback=on_msgbox_btn_click)
            dpg.add_button(label='No', callback=on_msgbox_btn_click)
    dpg.show_item(popup)

    resp = None
    while not resp:
        # print('waiting for response ...')
        # time.sleep(0.01)  # NOT needed
        handle_callbacks_and_render_one_frame()  # <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
    return resp

def on_btn_click(sender, data, user_data):
    print('do sth step 1 ...')
    resp = msgbox()
    if resp == 'Yes':
        print('do sth step 2 ...')
    print('done!')

def main():
    with dpg.window(tag='prim'):
        dpg.add_button(label='Submit', callback=on_btn_click)
    dpg.create_viewport(title='test msgbox', width=600, height=300)

    # start_dpg()
    dpg.setup_dearpygui()
    dpg.set_primary_window('prim', True)
    dpg.show_viewport()
    dpg.configure_app(manual_callback_management=True)
    while dpg.is_dearpygui_running():
        handle_callbacks_and_render_one_frame()
    dpg.destroy_context()

def run_callbacks():
    global dpg_callback_queue
    while dpg_callback_queue:
        job = dpg_callback_queue.pop(0)
        if job[0] is None:
            continue
        sig = inspect.signature(job[0])
        args = []
        for i in range(len(sig.parameters)):
            args.append(job[i+1])
        result = job[0](*args)

def handle_callbacks_and_render_one_frame():
    global dpg_callback_queue
    dpg_callback_queue += dpg.get_callback_queue() or []  # retrieves and clears queue
    # print(f'jobs: {jobs}')
    run_callbacks()
    dpg.render_dearpygui_frame()

if __name__ == '__main__':
    main()

and asyncio way: (revised acc. to @v-ein's guide below)

import asyncio
import inspect
import dearpygui.dearpygui as dpg

dpg.create_context()

async def msgbox():

    def on_msgbox_btn_click(sender, data, user_data):
        # print('clicked msgbox button')
        nonlocal resp
        resp = dpg.get_item_label(sender)
        dpg.hide_item(popup)
        # dpg.delete_item(popup)
        ev.set()

    with dpg.popup(parent=dpg.last_item(), modal=True) as popup:
        dpg.add_text('goto step 2?')
        with dpg.group(horizontal=True):
            dpg.add_button(label='Yes', callback=on_msgbox_btn_click)
            dpg.add_button(label='No', callback=on_msgbox_btn_click)
    dpg.show_item(popup)

    resp = None
    # while not resp:  # use `asyncio.Event` instead
    #     print('waiting for response ...')
    #     await asyncio.sleep(0.01)
    ev = asyncio.Event()
    await ev.wait()

    return resp

async def on_btn_click_(sender, data, user_data):  # IMPORTANT this callback endswith "_"
    print('do sth step 1 ...')
    resp = await msgbox()
    if resp == 'Yes':
        print('do sth step 2 ...')
    print('done')

def on_input_change_sync(sender, data, user_data):
    dpg.set_value(user_data, data)

async def on_input_change(sender, data, user_data):
    dpg.set_value(user_data, data)

async def on_input_change_(sender, data, user_data):
    dpg.set_value(user_data, data)

async def main():
    with dpg.window(tag='prim'):
        dpg.add_button(label='Submit', callback=on_btn_click_)
        dpg.add_input_text(callback=on_input_change_sync, user_data='lable0', label='sync')
        dpg.add_text(tag='lable0')
        dpg.add_input_text(callback=on_input_change_, user_data='lable1', label='create_task')
        dpg.add_text(tag='lable1')
        dpg.add_input_text(callback=on_input_change, user_data='lable2', label='await')
        dpg.add_text(tag='lable2')
    dpg.create_viewport(title='test msgbox', width=600, height=300)

    # start_dpg()
    dpg.setup_dearpygui()
    dpg.set_primary_window('prim', True)
    dpg.show_viewport()
    dpg.configure_app(manual_callback_management=True)
    while dpg.is_dearpygui_running():
        dpg.render_dearpygui_frame()  # placed before or after handle callback? seem same result
        jobs = dpg.get_callback_queue()  # retrieves and clears queue
        await run_callbacks(jobs)
        await asyncio.sleep(0.01)
    dpg.destroy_context()

async def run_callbacks(jobs):
    if jobs is None:
        pass
    else:
        for job in jobs:
            if job[0] is None:
                continue
            sig = inspect.signature(job[0])
            args = []
            for i in range(len(sig.parameters)):
                args.append(job[i+1])
            result = job[0](*args)
            # diff. callback function naming style for diff. handle method
            if inspect.isawaitable(result):
                if result.__name__.endswith('_'):
                    print(f'creat task for: {result.__name__}')
                    # start corotine without waiting for its result - not follow register sequence
                    asyncio.create_task(result)
                else:
                    print(f'await for: {result.__name__}')
                    await result  # run callback in sequence of registered

if __name__ == '__main__':
    asyncio.run(main())
v-ein commented 1 year ago

Good job! A couple of notes here.

Regarding your synchronous solution: it might be better to call run_callbacks after dpg.render_dearpygui_frame(), not before it. The reason is, dpg.render_dearpygui_frame() is what generates callback events. By handling them after the frame, you'll be exiting handle_callbacks_and_render_one_frame with the callbacks queue empty. Otherwise (as in the current implementation) you're leaving some events in the queue without guarantees as to when they will be handled... which might as well make UI elements unresponsive.

Also, why do you need time.sleep(0.01)? DPG should be able to do some sleeping inside of dpg.render_dearpygui_frame() according to FPS and input events. That is, the while-loop should not be eating 100% of CPU even without that time.sleep.

Re asyncio version:

(1) Instead of a while-loop on asyncio.sleep, create an asyncio.Event and do await event.wait(). After you've assigned the desired value to resp, do event.set() - this will unblock event.wait() and let it process resp.

(2) By doing asyncio.create_task for every callback job and not waiting for it to end, you're allowing callbacks to be scheduled concurrently. That is, callbacks can be executed in a different order than they originated in dpg.render_dearpygui_frame(). If a callback contains await inside, it even gives another callback a chance to run interleaved with it.

While this might not be a problem to your code, DPG generally expects callbacks to be run in the order in which they were born. Take an input_text callback for example. As you type text in the input field, callback is invoked for every typed character. If these callbacks are run out-of-order, your program might get wrong understanding of what was really typed into the field (depending on what you actually do in the callback). Like this:

You type:    Your callback gets in app_data:
  a            "a"
  b            "abc"
  c            "ab"
spectereye commented 1 year ago

thanks again @v-ein, i revised code above: besides remove time.sleep from sync way, for aysncio version: (1) asyncio.Event looks more pythnoic' (2) create_task or await in handleing callbacks - depends on naming style of the callback :-) easy way for me before see a formal implmentation from dearpygui. BTW rare change to see the input_text issue you described, i guess because of too slow manual interactive on GUI like typing some words in inputbox (3) place render_dearpygui_frame before or after callback handle, seems same effect as in a loop, orignal codes follow example below: https://dearpygui.readthedocs.io/en/latest/documentation/item-callbacks.html#debugging-callbacks-new-in-1-2

AND issue found is that it's obvious (though still works) slower after use manual_callback_management when quickly tying in inputbox

Va1b0rt commented 9 months ago

Does it make sense to have:

async def run_callbacks(jobs):
    if jobs is None:
        pass
    else:

since we could remove if jobs is None: and just keep if jobs: and write what's in the else block inside the if block? If there's some unknown significance to this that I'm missing, I'd like to know it.

Va1b0rt commented 9 months ago

Overall, I think the implementations described above are overly complicated. There's a simpler way, like this:

class Popup:
    def __init__(self, parent: str, message: str, task):
        self.parent = parent
        self.message = message
        self.task = task
        self.dpg_callback_queue = []

        self.confirm: Optional[bool] = None

        with dpg.mutex():
            viewport_width = dpg.get_viewport_client_width()
            viewport_height = dpg.get_viewport_client_height()

            with dpg.window(label=self.message, modal=True, no_close=True) as modal_id:
                dpg.add_text(message)
                with dpg.group(horizontal=True):
                    dpg.add_button(label="Да", width=75, user_data=(modal_id, True), callback=self.confirmation)
                    dpg.add_button(label="Отмена", width=75, user_data=(modal_id, False), callback=self.cancellation)

        dpg.split_frame()
        width = dpg.get_item_width(modal_id)
        height = dpg.get_item_height(modal_id)
        dpg.set_item_pos(modal_id, [viewport_width // 2 - width // 2, viewport_height // 2 - height // 2])

    def confirmation(self, sender, unused, user_data):
        self.task()
        dpg.delete_item(user_data[0])

    def cancellation(self, sender, unused, user_data):
        dpg.delete_item(user_data[0])

Here we execute the provided function upon confirmation, which should be sufficient in most cases.

v-ein commented 9 months ago

Here we execute the provided function upon confirmation, which should be sufficient in most cases.

Yeah, but (1) it's not what the OP asked for (it's not going to be a linear piece of code), and (2) synchronization (and general understanding of thread contexts in this case) is all yours ;).

v-ein commented 9 months ago

Oops, I was a bit wrong about synchronization. It's sufficient to understand that the callback will be run in the handlers thread, as usual in DPG. The first point still stands true though - it's not going to be a regular if msgbox=="yes" then...

Va1b0rt commented 9 months ago

Here we execute the provided function upon confirmation, which should be sufficient in most cases.

Yeah, but (1) it's not what the OP asked for (it's not going to be a linear piece of code), and (2) synchronization (and general understanding of thread contexts in this case) is all yours ;).

Yep, not exactly that, but as much simpler)