Repository: napari/napari

Issue:
Actions registered in `ActionManager` do not bind to buttons if they contain yield statements
## 🐛 Bug
If an action with a yield statement is registered in the action manager and bound to a button the function is never called when the button is pressed - a simple keybind with the same function works as expected

## To Reproduce
```python
import napari
from napari.layers.utils.layer_utils import register_layer_action
from napari.utils.action_manager import action_manager
from qtpy.QtWidgets import QPushButton
from skimage import data

viewer = napari.Viewer()
viewer.add_image(data.astronaut())

button = QPushButton('button')


@napari.Viewer.bind_key('p')
@register_layer_action(napari.layers.Image, 'button_press_callback')
def button_press_callback(layer: napari.layers.Image):
    print('cb called')
    yield


action_manager.bind_button('napari:button_press_callback', button)
viewer.window.add_dock_widget(button)
napari.run()
```

Press the button and nothing will print. Remove the yield statement and the statement will print. The keybinding works in both cases


## Expected behavior

If this is intended behaviour, fail loudly rather than silently. If unintended, do not fail

## Additional context
found when debugging oblique button not working in #3759 


Discussion:
this is easy enough to fix, but what should the behavior _be_?  The whole generator function trick worked ok for a keypress where we know there's a key-release coming (so a generator function with a single yield matches nicely).  But what should we do with a generator on a button click?

should it execute up to the first yield?  execute two yields?  execute until the generator is exhausted? (bad idea, cause it could be infinite). 
For context, I originally had one action instead of the code below. This action was supposed to be performed on both button press and key press

https://github.com/napari/napari/blob/86222aaaad0db99e37393dbd0f03b786554f8d10/napari/layers/image/_image_key_bindings.py#L36-L65

With this potential use case in mind (one action covering both key press/release and button press/release), I think it makes sense to mirror the keypress/release behaviour here. i.e. execute up to the yield on button press, after the yield on button release. In my mind only single yield actions would be appropriate

To be clear, I'm not sure that the code above *should* be one action, the behaviour is different enough and the callback shenanigans doesn't really make sense on button press.

What do you think?
> i.e. execute up to the yield on button press, after the yield on button release. In my mind only single yield actions would be appropriate

yeah, I was afraid you were going to suggest this extremely reasonable thing :joy:

here's why (it's a bit more complicated than at first it might appear, because of the ability to bind a button to the ID of a "yet-to-be-defined" action, because of the need for different signal connections, and because of the need to "manage" the generator):

The [`bind_button` code](https://github.com/napari/napari/blob/7aa7208d21b894c61d5a26dc71499e8f770e55ab/napari/utils/action_manager.py#L200-L201) basically just connects the button clicked signal to a function that executes the action _key_:

```python
    def bind_button(self, name: str, button: Button, extra_tooltip_text=''):
        self._validate_action_name(name)
        button.clicked.connect(lambda: self.trigger(name))
        ...
```
for a plain function type, `button.clicked` is preferable in terms of UX to `button.released`. So we'd need to change the connection type depending on the function type:  Note that we don't actually know anything about the function itself here (`name` is just a string).   We _could_ check to see if the action has been defined, and see what kind of function it is:

```python
if action := self._actions.get(name):
    if inspect.isgeneratorfunction(action):
        ...
```

but then the callbacks we have to connect are more complicated, and we need to introduce new logic to actually keep track of the generator that is created on button press while we wait for a button release.  (sidenote: our current `KeymapHandler` on layers actually [stores this generator _on_ the object itself](https://github.com/napari/napari/blob/7aa7208d21b894c61d5a26dc71499e8f770e55ab/napari/utils/key_bindings.py#L377)... but that's a pattern that's caused trouble for our models and I'd like to move away from that)

```python
button.pressed.connect(some_wrapper_that_starts_and_stores_the_generator(name))
button.pressed.connect(some_wrapper_that_retrieves_and_finishes_the_generator(name))
```

I guess my feelings are: I agree, given the current pattern of using a generator for keypress callbacks, it makes intuitive sense to have the button behave a similar way.  However, as nifty as the generator pattern was for keypresses from an enduser api perspective, it definitely adds a lot of complexity on the implementation side.  Buttons tend not to have as long of a "down" lifetime as keypresses can (i.e. would you be holding the button, doing something elsewhere, then releasing the button?).   Part of me says "just do it, it's a satisfying parity"... the other part says "buttons are different, either disallow it, or execute up to the first yield".  

thoughts?

ooft - here be dragons! Thanks for all of the details. I totally agree that the multiple wrappers and management sounds less than ideal, let's not overcomplicate things :)

> Buttons tend not to have as long of a "down" lifetime as keypresses.
> buttons are different, either disallow it, or execute up to the first yield

This is super important and is the reason I'm okay with the current separation - the two interactions feel qualitatively different. I'm supportive of explicitly disallowing generators. I don't think we should execute up to the first yield because keypress style actions may execute some setup here and initial state is usually re-established by code executed after the yield in these cases.

With that in mind, an alternative could be to execute up to the second yield (with the implicit expectation that actions will either be simple functions or keypress-style single yield generators)? 


> don't think we should execute up to the first yield because keypress style actions may execute some setup here and initial state is usually re-established by code executed after the yield in these cases.

I agree.  I think executing up to the second yield also has high "gotcha" potential.  Add to that the fact that this all won't work (or at least gets much harder) if someone happened to call `bind_button` _before_ having registered an action by that specific ID, and I think we should just explicitly raise for now. :)  Thanks for humoring me in the discussion
> Thanks for humoring me in the discussion

Pleasure was all mine 😆 

To confirm, the path forward here is to check whether the function is a generator in the `bind_button` method (using the snippet you posted above) and raise? (nice walrus!)

```python
if action := self._actions.get(name):
    if inspect.isgeneratorfunction(action):
        raise ValueError("no generators pls")
```


yep... but note that this still has error potential.  In your example at the top, if you had put `action_manager.bind_button('napari:button_press_callback', button)` above the call to `register_layer_action`, it wouldn't know what type of function to expect yet
oh no... and we can't check when registering the action because we're only disallowing generators for buttons, right?

Potential solution is to check during `ActionManager.trigger()`? but that also feels icky, action shouldn't know if it's bound to a button

hm, let me know if I'm missing something obvious - at the very least I'm more aware of the action manager internals than I was at the start of this :)
> Potential solution is to check during ActionManager.trigger()? but that also feels icky, action shouldn't know if it's bound to a button

yeah :/ and it also incurs a performance penalty at callback time, which is much worse than incurring it once at connection time. 

Ultimately, this points to a deeper design assumptions/issues with the generator pattern in general.  As elegant as it seemed to use a generator for the press/release event of a keypress, it does make for a lot of structural difficulties elsewhere.  It also makes an implicit assumption about the length of the generator and removes the ability to differentiate between press and release in the binding.  Even the [implementation of the "key release event"](https://github.com/napari/napari/blob/d41b2db71ec9b27a6869041dbb8f6a886f50cb45/napari/utils/key_bindings.py#L453-L465) makes the assumption that we have an in-process generator somewhere.  

So I think the generator for keys was put a bit too low-level and I think a lot of this would be easier if we bit the bullet and requested more explicit binding to `key_press` and `key_release`... We could probably add a wrapper that turns a generator into a two-part function

```python
def hello():
    print('hello world!')
    yield
    print('goodbye')


def break_in_two(genfunc):
    gen = '__unstarted__'

    def part1():
        nonlocal gen
        gen = genfunc()
        return next(gen)

    def part2():
        nonlocal gen
        if gen == '__unstarted__':
            raise ValueError("must call part1 first")
        v = next(gen, None)
        gen = '__unstarted__'
        return v

    return part1, part2

p1, p2 = break_in_two(hello)

```

but this is all getting rather far from the point at hand (though, it's partly responsible for the point at hand!).  I'm honestly not quite sure what to do here
love that wrapper! :)

I guess the only thing we can do before changing the generator pattern for key presses is to add a little warning to the docstring with reference to this issue for any poor soul who comes across issues in the near term, sound reasonable?
> I guess the only thing we can do before changing the generator pattern for key presses is to add a little warning to the docstring with reference to this issue for any poor soul who comes across issues in the near term, sound reasonable?

yeah, we can definitely also do the check in bind button.  But if we're binding to a button with no action registered, I think we should probably remain silent (i.e. don't warn "hey don't use a generator later!") 

Resolving patch:
diff --git a/napari/utils/action_manager.py b/napari/utils/action_manager.py
--- a/napari/utils/action_manager.py
+++ b/napari/utils/action_manager.py
@@ -4,6 +4,7 @@
 from collections import defaultdict
 from dataclasses import dataclass
 from functools import cached_property
+from inspect import isgeneratorfunction
 from typing import TYPE_CHECKING, Any, Callable, Dict, List, Set, Union
 
 from ..utils.events import EmitterGroup
@@ -196,13 +197,23 @@ def bind_button(
         calling `bind_button` can be done before an action with the
         corresponding name is registered, in which case the effect will be
         delayed until the corresponding action is registered.
+
+        Note: this method cannot be used with generator functions,
+        see https://github.com/napari/napari/issues/4164 for details.
         """
         self._validate_action_name(name)
+
+        if action := self._actions.get(name):
+            if isgeneratorfunction(action):
+                raise ValueError(
+                    'bind_button cannot be used with generator functions'
+                )
+
         button.clicked.connect(lambda: self.trigger(name))
 
         def _update_tt(event: ShortcutEvent):
             if event.name == name:
-                button.setToolTip(event.tooltip + ' ' + extra_tooltip_text)
+                button.setToolTip(f'{event.tooltip} {extra_tooltip_text}')
 
         # if it's a QPushbutton, we'll remove it when it gets destroyed
         until = getattr(button, 'destroyed', None)


Test patch: