# bpy: Show a blender popup_menu that waits until an external function returns and shows a spinner until then

I'm trying to write a script that shows a text information to a user while waiting for another process to finish. Ideally this also shows some kind of spinner, to show that it's still alive. I need this in my application since some of my functions are accessing external data via network, which can potentially take quite some time. I do need the user to wait for this process to finish (fire&forget is not an option).

My approach is to use a popup_menu, the threading module and a modal operator. The idea is to start longexternalfunction in a separate thread and while waiting for it to return, keep the user in the modal operator. The operator starts a second thread that generates a simple ascii spinner text and shows that text in the popup menu. I would imagine such a ui element to be quite useful in a number of cases if blocking processes potentially take some time to complete.

I found some examples to show function progress with the mouse cursor - which is a valid alternative for me, if this does not work, and looked into modal operators in general to come up with the code below. While I came quite a way I still have some issues though, that defy my understanding of blender ui programming (see also 'FIXME' tags in the code; code is running in 2.82 but with issues):

1. Biggest issue: I don't know how to update the text in the popup menu with the textspinner output
2. The textspinner runs longer than longexternalprocess of course, and I don't know how to stop the thread properly. Currently I get an error when the operator exits, event though I try to use __del__ to stop the spinner thread with a signal.
3. I guess there are some other (minor?) issues in my code, e.g. declaring and using class variables is not obvious to me in this context (bpy.props.Property... becomes an instance var?)
import bpy
from bpy.app.handlers import persistent
import time

def sleeper():
''' a long external process '''
duration = 5.0
steps = 10
for i in range(steps):
slpstep = duration / float(steps)
time.sleep(slpstep)
print('sleeping for: {}'.format(slpstep*i))

class My_OT_Info_progress(bpy.types.Operator):
''' Display text information in a popup on the screen, wait until process is finished '''
# See here:
# https://docs.blender.org/api/current/bpy.types.Operator.html
# https://blenderartists.org/t/showing-progress-of-your-script-running/647230/9

bl_idname = "my.info_progress"
bl_label = "Info window with progress"

# define some vars...
info_text : bpy.props.StringProperty(name="InfoText", description="Info text for popup")
icon : bpy.props.StringProperty(name="INFO", description="Icon for popup Info")
stop_spinner : bpy.props.BoolProperty(name="stop_spinner")

def __init__(self):
self.stop_spinner = False
print("Init Start")

def __del__(self):
self.stop_spinner = True # stop spinner before we exit
self.spin.join() # FIXME: throws after first run: AttributeError: 'My_OT_Info_progress' object has no attribute 'spin'??
print("Clean up - End")

def textspinner(self):
''' simple text spinner'''
wait = 0.1 # duration between updates s
timeout = 30 # max duration s
for i in range (int(timeout/wait)):
if self.stop_spinner: break # FIXME: throws on exit: ReferenceError: StructRNA of type My_OT_Info_progress has been removed
self.info_text = 'Text{}'.format('.' * int(i%5))
print(self.info_text)
time.sleep(0.1)

def execute(self, context):
# FIXME: where should the code for the popup live? How do I update the info_text attribute of the draw function?
#self.report({'INFO'}, info_text)
info_text=self.info_text

def draw(self,context):
self.layout.label(text=info_text)

print('exec')
return {'FINISHED'}

def modal(self, context, event):
print('modal start')
while self.func.is_alive(): # while the long external process is running
return{'RUNNING_MODAL'}
return {'FINISHED'}

def invoke(self, context, event):
''' this is executed when called by a user of with 'INVOKE_DEFAULT' '''

# start a long external process as separate thread
self.func.start()

# start a spinner as separate thread
self.spin.start()

self.execute(context)