Repository: napari/napari

Issue:
`AttributeError` when changing `loop_mode` preference with 3D+ image open
## 🐛 Bug

<!-- A clear and concise description of what the bug is. -->

A `AttributeError: 'NoneType' object has no attribute 'loop_mode'` error message appears when changing the loop mode preference

## To Reproduce

Steps to reproduce the behavior:

1. Open the `Cell (3D+2Ch)` example
2. Go to `File > Preferences > Application > Playback loop mode` and change the preference value
3. An error appears

<!-- If you have a code sample, error messages, stack traces, please provide it here as well -->

Traceback:

<details>

```python-traceback
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
E:\Acer\Documentos\Quansight\Napari\napari\napari\_vendor\qt_json_builder\qt_jsonschema_form\widgets.py in <lambda>(_=0)
    771
    772         self.currentIndexChanged.connect(
--> 773             lambda _: self.on_changed.emit(self.state)
        _ = 0
        global self.on_changed.emit = undefined
        global self.state = undefined
    774         )
    775

E:\Acer\Documentos\Quansight\Napari\napari\napari\_vendor\qt_json_builder\qt_jsonschema_form\signal.py in emit(self=<napari._vendor.qt_json_builder.qt_jsonschema_form.signal.BoundSignal object>, *args=('once',))
     20     def emit(self, *args):
     21         for sub in self._subscribers:
---> 22             sub(*args)
        sub = functools.partial(<bound method ObjectSchemaWidgetMinix.widget_on_changed of <napari._vendor.qt_json_builder.qt_jsonschema_form.widgets.ObjectSchemaWidget object at 0x000001E3EADCC040>>, 'playback_mode')
        args = ('once',)
     23
     24     def connect(self, listener):

E:\Acer\Documentos\Quansight\Napari\napari\napari\_vendor\qt_json_builder\qt_jsonschema_form\widgets.py in widget_on_changed(self=<napari._vendor.qt_json_builder.qt_jsonschema_form.widgets.ObjectSchemaWidget object>, name='playback_mode', value='once')
    673     def widget_on_changed(self, name: str, value):
    674         self.state[name] = value
--> 675         self.on_changed.emit(self.state)
        self.on_changed.emit = <bound method BoundSignal.emit of <napari._vendor.qt_json_builder.qt_jsonschema_form.signal.BoundSignal object at 0x000001E3EADB0C40>>
        self.state = {'language': 'en', 'save_window_geometry': True, 'save_window_state': False, 'gui_notification_level': 'info', 'console_notification_level': 'none', 'playback_fps': 10, 'playback_mode': 'once', 'grid_stride': 1, 'grid_width': -1, 'grid_height': -1, 'confirm_close_window': False, 'hold_button_delay': 0.5, 'dask': {'enabled': True, 'cache': 3.19}}
    676
    677     def setDescription(self, description: str):

E:\Acer\Documentos\Quansight\Napari\napari\napari\_vendor\qt_json_builder\qt_jsonschema_form\signal.py in emit(self=<napari._vendor.qt_json_builder.qt_jsonschema_form.signal.BoundSignal object>, *args=({'confirm_close_window': False, 'console_notification_level': 'none', 'dask': {'cache': 3.19, 'enabled': True}, 'grid_height': -1, 'grid_stride': 1, 'grid_width': -1, 'gui_notification_level': 'info', 'hold_button_delay': 0.5, 'language': 'en', 'playback_fps': 10, ...},))
     20     def emit(self, *args):
     21         for sub in self._subscribers:
---> 22             sub(*args)
        sub = <function PreferencesDialog._add_page.<locals>.<lambda> at 0x000001E3EAEB8CA0>
        args = ({'language': 'en', 'save_window_geometry': True, 'save_window_state': False, 'gui_notification_level': 'info', 'console_notification_level': 'none', 'playback_fps': 10, 'playback_mode': 'once', 'grid_stride': 1, 'grid_width': -1, 'grid_height': -1, 'confirm_close_window': False, 'hold_button_delay': 0.5, 'dask': {'enabled': True, 'cache': 3.19}},)
     23
     24     def connect(self, listener):

E:\Acer\Documentos\Quansight\Napari\napari\napari\_qt\dialogs\preferences_dialog.py in <lambda>(d={'confirm_close_window': False, 'console_notification_level': 'none', 'dask': {'cache': 3.19, 'enabled': True}, 'grid_height': -1, 'grid_stride': 1, 'grid_width': -1, 'gui_notification_level': 'info', 'hold_button_delay': 0.5, 'language': 'en', 'playback_fps': 10, ...})
    125         # make settings follow state of the form widget
    126         form.widget.on_changed.connect(
--> 127             lambda d: getattr(self._settings, name.lower()).update(d)
        d = {'language': 'en', 'save_window_geometry': True, 'save_window_state': False, 'gui_notification_level': 'info', 'console_notification_level': 'none', 'playback_fps': 10, 'playback_mode': 'once', 'grid_stride': 1, 'grid_width': -1, 'grid_height': -1, 'confirm_close_window': False, 'hold_button_delay': 0.5, 'dask': {'enabled': True, 'cache': 3.19}}
        global getattr = undefined
        global self._settings = undefined
        global name.lower.update = undefined
    128         )
    129

E:\Acer\Documentos\Quansight\Napari\napari\napari\utils\events\evented_model.py in update(self=ApplicationSettings(first_time=False, ipy_intera...0.5, dask=DaskSettings(enabled=True, cache=3.19)), values={'confirm_close_window': False, 'console_notification_level': 'none', 'dask': {'cache': 3.19, 'enabled': True}, 'grid_height': -1, 'grid_stride': 1, 'grid_width': -1, 'gui_notification_level': 'info', 'hold_button_delay': 0.5, 'language': 'en', 'playback_fps': 10, ...}, recurse=True)
    374                     field.update(value, recurse=recurse)
    375                 else:
--> 376                     setattr(self, key, value)
        global setattr = undefined
        self = ApplicationSettings(first_time=False, ipy_interactive=True, language='en', save_window_geometry=True, save_window_state=False, window_position=(188, 782), window_size=(1056, 847), window_maximized=False, window_fullscreen=False, window_state=None, window_statusbar=True, preferences_size=(965, 512), gui_notification_level=<NotificationSeverity.INFO: 'info'>, console_notification_level=<NotificationSeverity.NONE: 'none'>, open_history=[], save_history=[], playback_fps=10, playback_mode=<LoopMode.ONCE: 'once'>, grid_stride=1, grid_width=-1, grid_height=-1, confirm_close_window=False, hold_button_delay=0.5, dask=DaskSettings(enabled=True, cache=3.19))
        key = 'playback_mode'
        value = 'once'
    377
    378         if block.count:

E:\Acer\Documentos\Quansight\Napari\napari\napari\utils\events\evented_model.py in __setattr__(self=ApplicationSettings(first_time=False, ipy_intera...0.5, dask=DaskSettings(enabled=True, cache=3.19)), name='playback_mode', value='once')
    294         are_equal = self.__eq_operators__.get(name, operator.eq)
    295         if not are_equal(after, before):
--> 296             getattr(self.events, name)(value=after)  # emit event
        global getattr = undefined
        self.events = <napari.utils.events.event.EmitterGroup object at 0x000001E3D6F1F910>
        name = 'playback_mode'
        value = 'once'
        after = <LoopMode.ONCE: 'once'>
    297
    298             # emit events for any dependent computed properties as well

E:\Acer\Documentos\Quansight\Napari\napari\napari\utils\events\event.py in __call__(self=<napari.utils.events.event.EventEmitter object>, *args=(), **kwargs={'value': <LoopMode.ONCE: 'once'>})
    761                     continue
    762
--> 763                 self._invoke_callback(cb, event if pass_event else None)
        self._invoke_callback = <bound method EventEmitter._invoke_callback of <napari.utils.events.event.EventEmitter object at 0x000001E3D72E7C10>>
        cb = <function connect_setattr_value.<locals>._cb at 0x000001E3DAC930A0>
        event = <Event blocked=False handled=False native=None source=None sources=[] type='playback_mode'>
        pass_event = True
    764                 if event.blocked:
    765                     break

E:\Acer\Documentos\Quansight\Napari\napari\napari\utils\events\event.py in _invoke_callback(self=<napari.utils.events.event.EventEmitter object>, cb=<function connect_setattr_value.<locals>._cb>, event=<Event blocked=False handled=False native=None source=None sources=[] type='playback_mode'>)
    799                 self.disconnect(cb)
    800                 return
--> 801             _handle_exception(
        global _handle_exception = <function _handle_exception at 0x000001E3CFE1B760>
        self.ignore_callback_errors = False
        self.print_callback_errors = 'reminders'
        self = <napari.utils.events.event.EventEmitter object at 0x000001E3D72E7C10>
        global cb_event = undefined
        cb = <function connect_setattr_value.<locals>._cb at 0x000001E3DAC930A0>
        event = <Event blocked=False handled=False native=None source=None sources=[] type='playback_mode'>
    802                 self.ignore_callback_errors,
    803                 self.print_callback_errors,

E:\Acer\Documentos\Quansight\Napari\napari\napari\utils\events\event.py in _invoke_callback(self=<napari.utils.events.event.EventEmitter object>, cb=<function connect_setattr_value.<locals>._cb>, event=<Event blocked=False handled=False native=None source=None sources=[] type='playback_mode'>)
    786         try:
    787             if event is not None:
--> 788                 cb(event)
        cb = <function connect_setattr_value.<locals>._cb at 0x000001E3DAC930A0>
        event = <Event blocked=False handled=False native=None source=None sources=[] type='playback_mode'>
    789             else:
    790                 cb()

E:\Acer\Documentos\Quansight\Napari\napari\napari\utils\events\event_utils.py in _cb(value=<Event blocked=False handled=False native=None source=None sources=[] type='playback_mode'>)
     59
     60     def _cb(value):
---> 61         setattr(ref(), attr, value.value)
        global setattr = undefined
        global ref = undefined
        global attr = undefined
        value.value = <LoopMode.ONCE: 'once'>
     62
     63     emitter.connect(_cb)

AttributeError: 'NoneType' object has no attribute 'loop_mode'
```

</details>

## Expected behavior

<!-- A clear and concise description of what you expected to happen. -->

No error should be shown

## Environment

```
napari: 0.5.0a2.dev105+g49fee1d9
Platform: Windows-10-10.0.19045-SP0
Python: 3.10.9 | packaged by conda-forge | (main, Jan 11 2023, 15:15:40) [MSC v.1916 64 bit (AMD64)]
Qt: 5.15.2
PyQt5: 5.15.9
NumPy: 1.24.3
SciPy: 1.9.3
Dask: 2022.10.2
VisPy: 0.12.1
magicgui: 0.6.0
superqt: unknown
in-n-out: 0.1.5
app-model: 0.1.0
npe2: 0.6.1

OpenGL:
- GL version: 4.5.0 - Build 25.20.100.6444
- MAX_TEXTURE_SIZE: 16384

Screens:
- screen 1: resolution 1366x768, scale 1.0
- screen 2: resolution 1920x1080, scale 1.0

Settings path:
- C:\Users\dalth\AppData\Local\napari\napari-dev_4454fdbf170edefe23827a4bf512389a6806af69\settings.yaml
```

## Additional context

<!-- Add any other context about the problem here. -->
Encountered while trying to reproduce https://github.com/napari/napari/issues/5668



Discussion:
I can reproduce this on macOS. 
Trying to change the loop mode setting while having a 3D+ image open triggers the error.
With no image or 2D it's fine.
I think it's pretty normal to want to change the playback mode with a playback-capable image—because you may see that it's not what you want—so this is definitely an annoying bug. 

Resolving patch:
diff --git a/napari/utils/events/event.py b/napari/utils/events/event.py
--- a/napari/utils/events/event.py
+++ b/napari/utils/events/event.py
@@ -272,7 +272,7 @@ class EventEmitter:
     source : object
         The object that the generated events apply to. All emitted Events will
         have their .source property set to this value.
-    type : str or None
+    type_name: str or None
         String indicating the event type (e.g. mouse_press, key_release)
     event_class : subclass of Event
         The class of events that this emitter will generate.
diff --git a/napari/utils/events/event_utils.py b/napari/utils/events/event_utils.py
--- a/napari/utils/events/event_utils.py
+++ b/napari/utils/events/event_utils.py
@@ -32,13 +32,19 @@ def connect_setattr(emitter: Emitter, obj, attr: str):
     ref = weakref.ref(obj)
 
     def _cb(*value):
-        setattr(ref(), attr, value[0] if len(value) == 1 else value)
+        if (ob := ref()) is None:
+            emitter.disconnect(_cb)
+            return
+        setattr(ob, attr, value[0] if len(value) == 1 else value)
 
     emitter.connect(_cb)
     # There are scenarios where emitter is deleted before obj.
     # Also there is no option to create weakref to QT Signal
     # but even if keep reference to base object and signal name it is possible to meet
     # problem with C++ "wrapped C/C++ object has been deleted"
+
+    # In all of these 3 functions, this should be uncommented instead of using
+    # the if clause in _cb but that causes a segmentation fault in tests
     # weakref.finalize(obj, emitter.disconnect, _cb)
 
 
@@ -46,7 +52,10 @@ def connect_no_arg(emitter: Emitter, obj, attr: str):
     ref = weakref.ref(obj)
 
     def _cb(*_value):
-        getattr(ref(), attr)()
+        if (ob := ref()) is None:
+            emitter.disconnect(_cb)
+            return
+        getattr(ob, attr)()
 
     emitter.connect(_cb)
     # as in connect_setattr
@@ -58,6 +67,10 @@ def connect_setattr_value(emitter: Emitter, obj, attr: str):
     ref = weakref.ref(obj)
 
     def _cb(value):
-        setattr(ref(), attr, value.value)
+        if (ob := ref()) is None:
+            emitter.disconnect(_cb)
+            return
+        setattr(ob, attr, value.value)
 
     emitter.connect(_cb)
+    # weakref.finalize(obj, emitter.disconnect, _cb)


Test patch:
diff --git a/napari/utils/events/_tests/test_event_utils.py b/napari/utils/events/_tests/test_event_utils.py
new file mode 100644
--- /dev/null
+++ b/napari/utils/events/_tests/test_event_utils.py
@@ -0,0 +1,52 @@
+import gc
+from unittest.mock import Mock
+
+from napari.utils.events.event import Event, EventEmitter
+from napari.utils.events.event_utils import (
+    connect_no_arg,
+    connect_setattr,
+    connect_setattr_value,
+)
+
+
+def test_connect_no_arg():
+    mock = Mock(["meth"])
+    emiter = EventEmitter()
+    connect_no_arg(emiter, mock, "meth")
+    emiter(type_name="a", value=1)
+    mock.meth.assert_called_once_with()
+    assert len(emiter.callbacks) == 1
+    del mock
+    gc.collect()
+    assert len(emiter.callbacks) == 1
+    emiter(type_name="a", value=1)
+    assert len(emiter.callbacks) == 0
+
+
+def test_connect_setattr_value():
+    mock = Mock()
+    emiter = EventEmitter()
+    connect_setattr_value(emiter, mock, "meth")
+    emiter(type_name="a", value=1)
+    assert mock.meth == 1
+    assert len(emiter.callbacks) == 1
+    del mock
+    gc.collect()
+    assert len(emiter.callbacks) == 1
+    emiter(type_name="a", value=1)
+    assert len(emiter.callbacks) == 0
+
+
+def test_connect_setattr():
+    mock = Mock()
+    emiter = EventEmitter()
+    connect_setattr(emiter, mock, "meth")
+    emiter(type_name="a", value=1)
+    assert isinstance(mock.meth, Event)
+    assert mock.meth.value == 1
+    assert len(emiter.callbacks) == 1
+    del mock
+    gc.collect()
+    assert len(emiter.callbacks) == 1
+    emiter(type_name="a", value=1)
+    assert len(emiter.callbacks) == 0