An Introduction to the Event Loop in napari¶
Like most applications with a graphical user interface (GUI), napari operates within an event loop that waits for – and responds to – events triggered by the user interacting with the program. These events might be something like a mouse click, or a keypress, and usually correspond to some specific action taken by the user (e.g. “user moved the gamma slider”).
At its core, an event loop is rather simple. It amounts to something that looks like this (in pseudo-code):
event_queue = Queue()
while True: # infinite loop!
if not event_queue.is_empty():
event = get_next_event()
if event.value == 'Quit':
break
else:
process_event(event)
Actions taken by the user add events to the queue (“button pressed”, “slider moved”, etc…), and the event loop handles them one at a time.
The Qt Event Loop¶
Currently, napari uses Qt as its GUI backend, and the main loop handling events in napari is the Qt EventLoop. When you use the following syntax:
with napari.gui_qt():
viewer = napari.Viewer()
… you are starting up the Qt event loop. This also explains why the only
wait to get out of that gui_qt
context is to stop the Qt event loop
(usually by quitting the napari viewer). A deep dive into the Qt event loop is
beyond the scope of this document, but it’s worth being aware of the central role
that it plays in napari, and users interested in creating highly customized
events and actions are advised to gain at least a little familiarity with the
Qt event loop.
Hooking up your own events¶
If you’re coming from a background of scripting or working with python in an
interactive console, thinking in terms of the “event loop” can feel a bit
strange at time. Often we write code in a very procedural way: “do this …
then do that, etc…”. With napari and other GUI programs however, usually you
hook up a bunch of conditions and to callback functions (e.g. “If this event
happens, then call this function”) and then start the loop and hope you
hooked everything up correctly! Indeed, much of the napari
source code is
dedicated to creating and handling events: search the codebase for “.emit(
“
and “.connect(
” to find examples of creating and handling internal events,
respectively.
If you would like to setup a custom event listener then you need to hook into the napari event. We offer a couple of convenience decorators to easily connect functions to key and mouse events.
Listening for keypress events¶
One option is to use keybindings, that will listen for keypresses and then call
some callback whenever pressed, with the viewer instance passed as an argument
to that function. As a basic example, to add a random image to the viewer
every time the i
key is pressed, and delete the last layer when the k
key is pressed:
import numpy as np
import napari
with napari.gui_qt():
viewer = napari.Viewer()
@viewer.bind_key('i')
def add_layer(viewer):
viewer.add_image(np.random.random((512, 512)))
@viewer.bind_key('k')
def delete_layer(viewer):
try:
viewer.layers.pop(0)
except IndexError:
pass
See also this custom key bindings example.
Listening for mouse events¶
You can also listen for and react to mouse events, like a click or drag event, as show here where we update the image with random data every time it is clicked.
import numpy as np
import napari
with napari.gui_qt():
viewer = napari.Viewer()
layer = viewer.add_image(np.random.random((512, 512)))
@layer.mouse_drag_callbacks.append
def update_layer(layer, event):
layer.data = np.random.random((512, 512))
See also the custom mouse functions and mouse drag callback examples.
Connection functions to native napari events¶
If you want something to happen following some event that happens within
napari, then trick becomes knowing which native signals any given napari object
provides for you to “connect” to. Until we have centralized documentation for
all of the events offered by napari objects, the best way to find these is to
browse the source code. Take for instance, the base
Layer
class: you’ll find in the __init__
method a self.events
section that looks like this:
self.events = EmitterGroup(
...
data=Event,
name=Event,
...
)
That tells you that all layers are capable of emitting events called data
,
and name
(among many others) that will (presumably) be emitted when that
property changes. To provide your own response to that change, you can hook up
a callback function that accepts the event object:
def print_layer_name(event):
print(f"{event.source.name} changed its data!")
layer.events.data.connect(print_layer_name)
Long-running, blocking functions¶
An important detail here is that the napari event loop is running in a single thread. This works just fine if the handling of each event is very short, as is usually the case with moving sliders, and pressing buttons. However, if one of the events in the queue takes a long time to process, then every other event must wait!
Take this example in napari:
import napari
import numpy as np
with napari.gui_qt():
viewer = napari.Viewer()
# everything is fine so far... but if we trigger a long computation
image = np.random.rand(512, 1024, 1024).mean(0)
viewer.add_image(image)
# the entire interface freezes!
Here we have a long computation (np.random.rand(512, 1024, 1024).mean(0)
)
that “blocks” the main thread, meaning no button press, key press, or any
other event can be processed until it’s done. In this scenario, it’s best to
put your long-running function into another thread or process. napari
provides a convenience for that, described in Multithreading in napari.