Repository: instamatic-dev/instamatic
Pull request #117: Add a click dispatcher and typing extensions
Author: login: Baharis
Labels: name: enhancement
name: dependencies
description: Pull requests that update a dependency file
Created: 2025-03-25T23:10:00Z | Merged: 2025-03-27T18:38:50Z

### Click dispatcher

Instamatic GUI is build out of multiple modules that are almost completely independent, yet can communicate thanks to the ingenuity of the `AppLoader`. This class does not only `load` modules but also allows them to access each other via `get_module()`. Currently, this functionality is used only in three places (to define GUI `self.module_io`, to `save_image`, to dynamically modify the `ExperimentalSED`). Here I suggest another use: to add clicking interactions with the `VideoStreamFrame`.

This PR adds a new `ClickDispatcher` to the `VideoStreamFrame`. By adding `self.panel.bind('<Button>', self.on_click)` and a few lines to a formerly-empty built-in tkinter `on_click()`, the `ClickDispatcher`can handle clicks on the decorated frame and call callbacks without any performance overhead. Since this might sound obscure, here are some examples. Firstly, adding the following to any frame makes the console print the coordinates of clicked pixel:
```python
print('Starting now, clicking on image prints click coordinates')
s = self.app.get_module('stream')                              # grab the running VideoStreamFrame instance
c = lambda e: print(e)                                         # define the callback function
v = s.click_dispatcher.add_listener(name='video', callback=c)  # makes and registers the "listener"
v.active = True                                                # start the "listener"
```

![Screenshot 2025-03-25 235121](https://github.com/user-attachments/assets/945247e8-182f-47d6-b8da-4fec0c5dcab8)

If we expect our listener to be active only briefly, instead of defining callback we can use it as a context to wait for clicks:
```python
print('Please click on the mesh grid bar to define background level')
with s.click_dispatcher.add_listener('temp') as tl:
    click = tl.get_click()
    image = self.app.get_module('stream').frame
    bg_level = image[click.y, click.x]
```

Using both callback and context at the same time is also possible for some cool interactive applications:
```python
print('Left-click on the image to move the beam; right-click to cancel.')
cancel = threading.Event()
bm = s.click_dispatcher.add_listener('beam_mover', callback=lambda e: cancel.set() if e.button != 1 else None) 
calib = CalibBeamShift.from_file()
while not cancel.is_set():
    with bm:
        click = bm.get_click()
        new_beamshift = calib.pixelcoord_to_beamshift(click.x, click.y)
        self.ctrl.beamshift.set(*new_beamshift)
```

As shown above, listeners can be accessed via `click_dispatcher.listeners` `NoOverwriteDict` (in new `instamatic.collections`). Here, I only introduce the class and showcase its potential uses - some of which I plan to include in the following PRs.

### Typing extensions

While adding new features is fun, improving the maintainability of existing ones it also important. For this reason, in this PR I also suggest adding `typing_extenstions` a new `instamatic` dependency. [`typing_extensions`](https://pypi.org/project/typing-extensions/) is Guido von Ross's light-weight dependency-less python library (i.e. [a single file](https://github.com/python/typing_extensions/blob/main/src/typing_extensions.py)) that back-ports new python `typing` types to old python versions. While typically I'd be against adding new dependencies, this one is literally in the [top](https://medium.com/%40kasperjuunge/20-most-downloaded-python-packages-explained-f5cded2eb70d) [ten](https://hugovk.github.io/top-pypi-packages/) most downloaded ones and has little drawbacks.

To exemplify the usefulness of `typing_extensions`, in this PR I improved the type hints for a bunch of constructors by specifying their return type using `Self`. [The `Self` type hint](https://typing.python.org/en/latest/spec/generics.html#self) is a python 3.11 addition that says the return type of a `cls.method` is the same as `cls`'s. The Python 3.7-3.10 way of defining `Self` is rarely done well. One great use by @viljarjf is actually present in `simulation.py`:
```python
Crystal_T = TypeVar('Crystal_T', bound='Crystal')
class Crystal:
    @classmethod
    def default(cls: Type[Crystal_T]) -> Crystal_T:
        return cls(1, 2, 3, 90, 100, 110)
```

While I did not modify this particular file to not cause potential merging issues for him, `Self` would simplify that to:
```python
class Crystal:
    @classmethod
    def default(cls) -> Self:
        return cls(1, 2, 3, 90, 100, 110)
```

### Major changes

- New `self.app.get_module('stream').click_dispatcher` can handle user clicking on the `VideoStreamFrame` image;

### Code maintanance

- Introduce `typing_extension` dependency to vastly expand typing capabilities, improving code maintenance;
- `instamatic._collections` is a new module for repeated design patterns, e.g. new `NoOverwriteDict`.

### Effect on the code base
The new `ClickDispatcher` can be used on `VideoStreamFrame` or other frames (if implemented) to vastly improve the level of interaction between the user and GUI. With the `typing_extension` added, the developers can now use `typing` type hints accessible to Python3.8+ which, otherwise, would break python 3.7 compatibility.


Diff:
diff --git a/pyproject.toml b/pyproject.toml
index a94e9055..6a9f98e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,6 +53,7 @@ dependencies = [
     "scipy >= 1.3.2",
     "tifffile >= 2019.7.26.2",
     "tqdm >= 4.41.1",
+    "typing_extensions >= 4.0.0",
     "virtualbox >= 2.0.0",
     "pyserialem >= 0.3.2",
     "diffpy.structure",
diff --git a/requirements.txt b/requirements.txt
index 442a135a..39c37332 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -14,5 +14,6 @@ scikit-image >= 0.17.1
 scipy >= 1.3.2
 tifffile >= 2019.7.26.2
 tqdm >= 4.41.1
+typing_extensions >= 4.0.0
 virtualbox >= 2.0.0
 pyserialem >= 0.3.2
diff --git a/src/instamatic/_collections.py b/src/instamatic/_collections.py
new file mode 100644
index 00000000..1af6992a
--- /dev/null
+++ b/src/instamatic/_collections.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+from collections import UserDict
+from typing import Any
+
+
+class NoOverwriteDict(UserDict):
+    """A dictionary that doesn't allow overwriting existing values."""
+
+    def __setitem__(self, key: Any, value: Any) -> None:
+        if key in self.data:
+            raise KeyError(f'Key "{key}" already exists and cannot be overwritten.')
+        super().__setitem__(key, value)
diff --git a/src/instamatic/_typing.py b/src/instamatic/_typing.py
index 32a7ec57..28872732 100644
--- a/src/instamatic/_typing.py
+++ b/src/instamatic/_typing.py
@@ -1,12 +1,6 @@
 from __future__ import annotations
 
-import sys
+from typing_extensions import Annotated
 
-if sys.version_info >= (3, 9):
-    from typing import Annotated
-
-    int_nm = Annotated[int, 'Length expressed in nanometers']
-    float_deg = Annotated[float, 'Angle expressed in degrees']
-else:
-    int_nm = int
-    float_deg = float
+int_nm = Annotated[int, 'Length expressed in nanometers']
+float_deg = Annotated[float, 'Angle expressed in degrees']
diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py
index 1fa69725..116a19cb 100644
--- a/src/instamatic/calibrate/calibrate_beamshift.py
+++ b/src/instamatic/calibrate/calibrate_beamshift.py
@@ -8,6 +8,7 @@
 import matplotlib.pyplot as plt
 import numpy as np
 from skimage.registration import phase_cross_correlation
+from typing_extensions import Self
 
 from instamatic import config
 from instamatic.image_utils import autoscale, imgscale
@@ -47,7 +48,7 @@ def pixelcoord_to_beamshift(self, pixelcoord):
         return beamshift.astype(int)
 
     @classmethod
-    def from_data(cls, shifts, beampos, reference_shift, reference_pixel, header=None):
+    def from_data(cls, shifts, beampos, reference_shift, reference_pixel, header=None) -> Self:
         fit_result = fit_affine_transformation(shifts, beampos)
         r = fit_result.r
         t = fit_result.t
@@ -61,7 +62,7 @@ def from_data(cls, shifts, beampos, reference_shift, reference_pixel, header=Non
         return c
 
     @classmethod
-    def from_file(cls, fn=CALIB_BEAMSHIFT):
+    def from_file(cls, fn=CALIB_BEAMSHIFT) -> Self:
         """Read calibration from file."""
         import pickle
 
@@ -72,7 +73,7 @@ def from_file(cls, fn=CALIB_BEAMSHIFT):
             raise OSError(f'{e.strerror}: {fn}. Please run {prog} first.')
 
     @classmethod
-    def live(cls, ctrl, outdir='.'):
+    def live(cls, ctrl, outdir='.') -> Self:
         while True:
             c = calibrate_beamshift(ctrl=ctrl, save_images=True, outdir=outdir)
             if input(' >> Accept? [y/n] ') == 'y':
diff --git a/src/instamatic/calibrate/calibrate_brightness.py b/src/instamatic/calibrate/calibrate_brightness.py
index 055ea5de..a95df3b9 100644
--- a/src/instamatic/calibrate/calibrate_brightness.py
+++ b/src/instamatic/calibrate/calibrate_brightness.py
@@ -6,6 +6,7 @@
 
 import matplotlib.pyplot as plt
 import numpy as np
+from typing_extensions import Self
 
 from instamatic.image_utils import autoscale
 from instamatic.processing.find_holes import find_holes
@@ -34,7 +35,7 @@ def pixelsize_to_brightness(self, val):
         return int((val - self.intercept) / self.slope)
 
     @classmethod
-    def from_data(cls, brightness, pixeldiameter, header=None):
+    def from_data(cls, brightness, pixeldiameter, header=None) -> Self:
         slope, intercept, r_value, p_value, std_err = linregress(brightness, pixeldiameter)
         print()
         print(f'r_value: {r_value:.4f}')
@@ -48,7 +49,7 @@ def from_data(cls, brightness, pixeldiameter, header=None):
         return c
 
     @classmethod
-    def from_file(cls, fn=CALIB_BRIGHTNESS):
+    def from_file(cls, fn=CALIB_BRIGHTNESS) -> Self:
         import pickle
 
         try:
diff --git a/src/instamatic/calibrate/calibrate_directbeam.py b/src/instamatic/calibrate/calibrate_directbeam.py
index 9ba8b8ff..7c0bd1ef 100644
--- a/src/instamatic/calibrate/calibrate_directbeam.py
+++ b/src/instamatic/calibrate/calibrate_directbeam.py
@@ -8,6 +8,7 @@
 import matplotlib.pyplot as plt
 import numpy as np
 from skimage.registration import phase_cross_correlation
+from typing_extensions import Self
 
 from instamatic import config
 from instamatic.image_utils import autoscale, imgscale
@@ -72,7 +73,7 @@ def __repr__(self):
         return ret
 
     @classmethod
-    def combine(cls, lst):
+    def combine(cls, lst) -> Self:
         return cls({k: v for c in lst for k, v in c._dct.items()})
 
     def any2pixelshift(self, shift, key):
@@ -117,7 +118,7 @@ def pixelshift2imagetilt(self, pixelshift):
         return self.pixelshift2any(pixelshift=pixelshift, key='ImageTilt')
 
     @classmethod
-    def from_data(cls, shifts, readout, key, header=None, **dct):
+    def from_data(cls, shifts, readout, key, header=None, **dct) -> Self:
         fit_result = fit_affine_transformation(shifts, readout, **dct)
 
         d = {
@@ -131,7 +132,7 @@ def from_data(cls, shifts, readout, key, header=None, **dct):
         return cls({key: d})
 
     @classmethod
-    def from_file(cls, fn=CALIB_DIRECTBEAM):
+    def from_file(cls, fn=CALIB_DIRECTBEAM) -> Self:
         import pickle
 
         try:
@@ -141,7 +142,7 @@ def from_file(cls, fn=CALIB_DIRECTBEAM):
             raise OSError(f'{e.strerror}: {fn}. Please run {prog} first.')
 
     @classmethod
-    def live(cls, ctrl, outdir='.'):
+    def live(cls, ctrl, outdir='.') -> Self:
         while True:
             c = calibrate_directbeam(ctrl=ctrl, save_images=True, outdir=outdir)
             if input(' >> Accept? [y/n] ') == 'y':
diff --git a/src/instamatic/calibrate/calibrate_stage_lowmag.py b/src/instamatic/calibrate/calibrate_stage_lowmag.py
index 9c1ae008..b5430870 100644
--- a/src/instamatic/calibrate/calibrate_stage_lowmag.py
+++ b/src/instamatic/calibrate/calibrate_stage_lowmag.py
@@ -7,6 +7,7 @@
 import matplotlib.pyplot as plt
 import numpy as np
 from skimage.registration import phase_cross_correlation
+from typing_extensions import Self
 
 from instamatic.formats import read_image
 from instamatic.image_utils import autoscale, imgscale
@@ -177,7 +178,7 @@ def pixelshift_to_stageshift(self, pixelshift, binsize=1):
     @classmethod
     def from_data(
         cls, shifts, stagepos, reference_position, camera_dimensions=None, header=None
-    ):
+    ) -> Self:
         fit_result = fit_affine_transformation(shifts, stagepos, verbose=True, translation=True)
         r = fit_result.r
         t = fit_result.t
@@ -202,7 +203,7 @@ def from_data(
         return c
 
     @classmethod
-    def from_file(cls, fn=CALIB_STAGE_LOWMAG):
+    def from_file(cls, fn=CALIB_STAGE_LOWMAG) -> Self:
         try:
             return pickle.load(open(fn, 'rb'))
         except OSError as e:
diff --git a/src/instamatic/camera/camera_base.py b/src/instamatic/camera/camera_base.py
index 17b8b98a..2dbb9999 100644
--- a/src/instamatic/camera/camera_base.py
+++ b/src/instamatic/camera/camera_base.py
@@ -4,6 +4,7 @@
 from typing import List, Optional, Tuple
 
 import numpy as np
+from typing_extensions import Self
 
 from instamatic import config
 
@@ -59,7 +60,7 @@ def get_movie(
             for _ in range(n_frames)
         ]
 
-    def __enter__(self) -> CameraBase:
+    def __enter__(self) -> Self:
         self.establish_connection()
         return self
 
diff --git a/src/instamatic/camera/merlin_io.py b/src/instamatic/camera/merlin_io.py
index 276a44a8..e000f189 100644
--- a/src/instamatic/camera/merlin_io.py
+++ b/src/instamatic/camera/merlin_io.py
@@ -4,6 +4,7 @@
 from typing import List
 
 import numpy as np
+from typing_extensions import Self
 
 
 class MIBProperties:
@@ -89,7 +90,7 @@ def show(self):
         print(f'\tNumber of frames to be read: {self.xy}')
 
     @classmethod
-    def from_buffer(cls, buffer: bytes):
+    def from_buffer(cls, buffer: bytes) -> Self:
         """Return MIB properties from buffer."""
         head = buffer[:384].decode().split(',')
         return cls(head)
diff --git a/src/instamatic/camera/videostream.py b/src/instamatic/camera/videostream.py
index 417728f7..0a94cc8f 100644
--- a/src/instamatic/camera/videostream.py
+++ b/src/instamatic/camera/videostream.py
@@ -95,20 +95,22 @@ def stop(self):
         self.thread.join()
 
 
-_VS = TypeVar('_VS', bound='VideoStream')
+VideoStream_T = TypeVar('VideoStream_T', bound='VideoStream')  # VideoStream or subclass
 
 
 class VideoStream(threading.Thread):
     """Abstract base interface for collecting preview & media from camera."""
 
-    def __new__(cls: Type[_VS], *args, **kwargs) -> _VS:
+    def __new__(cls: Type[VideoStream_T], *args, **kwargs) -> VideoStream_T:
         if cls is VideoStream:
             msg = 'Initialize a `VideoStream` via its subclasses or using `.from_any()`.'
             raise NotImplementedError(msg)
         return super(VideoStream, cls).__new__(cls)
 
     @classmethod
-    def from_any(cls: Type[_VS], cam: Union[CameraBase, str] = 'simulate') -> _VS:
+    def from_any(
+        cls: Type[VideoStream_T], cam: Union[CameraBase, str] = 'simulate'
+    ) -> VideoStream_T:
         """Create a subclass based on passed cam or cam-str stream-ability."""
         cam: CameraBase = Camera(name=cam) if isinstance(cam, str) else cam
         if cls is VideoStream:
diff --git a/src/instamatic/config/__init__.py b/src/instamatic/config/__init__.py
index 92f9c4aa..4b920f95 100644
--- a/src/instamatic/config/__init__.py
+++ b/src/instamatic/config/__init__.py
@@ -9,6 +9,7 @@
 from pathlib import Path
 
 import yaml
+from typing_extensions import Self
 
 from .config_updater import (
     check_defaults_yaml,
@@ -115,7 +116,7 @@ def __getitem__(self, item):
         return self.mapping[item]
 
     @classmethod
-    def from_file(cls, path: str):
+    def from_file(cls, path: str) -> Self:
         """Read configuration from yaml file, returns namespace."""
         name = Path(path).stem
         return cls(yaml.load(open(path), Loader=yaml.Loader), name=name, location=path)
diff --git a/src/instamatic/experiments/experiment_base.py b/src/instamatic/experiments/experiment_base.py
index fd13f375..fb56b267 100644
--- a/src/instamatic/experiments/experiment_base.py
+++ b/src/instamatic/experiments/experiment_base.py
@@ -2,6 +2,8 @@
 
 from abc import ABC, abstractmethod
 
+from typing_extensions import Self
+
 
 class ExperimentBase(ABC):
     """Experiment base class."""
@@ -16,7 +18,7 @@ def setup(self):
     def teardown(self):
         pass
 
-    def __enter__(self):
+    def __enter__(self) -> Self:
         self.setup()
         return self
 
diff --git a/src/instamatic/gui/click_dispatcher.py b/src/instamatic/gui/click_dispatcher.py
new file mode 100644
index 00000000..65c49b23
--- /dev/null
+++ b/src/instamatic/gui/click_dispatcher.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import enum
+import queue
+from typing import Callable, Optional, Union
+
+from typing_extensions import Self
+
+from instamatic._collections import NoOverwriteDict
+
+
+class MouseButton(enum.IntEnum):
+    LEFT = 1
+    MIDDLE = 2
+    RIGHT = 3
+
+
+class ClickEvent:
+    """Individual click event expected and handled by `ClickListener`s."""
+
+    def __init__(
+        self,
+        x: Optional[int] = None,
+        y: Optional[int] = None,
+        button: Optional[int] = None,
+    ) -> None:
+        self.x = x if x else 0
+        self.y = y if y else 0
+        self.button = MouseButton(button) if button else MouseButton.LEFT
+
+    def __repr__(self) -> str:
+        return f'{self.__class__.__name__}(x={self.x}, y={self.y}, button={self.button})'
+
+
+class ClickListener:
+    """Request clicks and call `callback` on each received."""
+
+    def __init__(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> None:
+        self.name = name
+        self.callback = callback
+        self.queue = queue.Queue()
+        self.active = False
+
+    def __enter__(self) -> Self:
+        self.queue.queue.clear()
+        self.active = True
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+        self.active = False
+
+    def handle_click(self, click_event: ClickEvent) -> None:
+        if self.active:
+            self.queue.put(click_event)
+            if self.callback is not None:
+                self.callback(click_event)
+
+    def get_click(self, timeout: float = None) -> Union[ClickEvent, None]:
+        """Get next `ClickEvent` from the queue or await it until `timeout`"""
+        try:
+            return self.queue.get(timeout=timeout)
+        except queue.Empty:
+            return None
+
+
+class ClickDispatcher:
+    """Manages `ClickEvent` distribution across `ClickListeners`."""
+
+    def __init__(self):
+        self.listeners: NoOverwriteDict[str, ClickListener] = NoOverwriteDict()
+
+    @property
+    def active(self) -> bool:
+        return any(listener.active for listener in self.listeners.values())
+
+    def add_listener(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> ClickListener:
+        """Convenience method that adds and returns a new `ClickListener`"""
+        listener = ClickListener(name, callback)
+        self.listeners[name] = listener
+        return listener
+
+    def handle_click(self, *args, **kwargs) -> None:
+        """Call `handle_click` method of every active `ClickListener`"""
+        event = ClickEvent(*args, **kwargs)
+        for listener in self.listeners.values():
+            if listener.active:
+                listener.handle_click(event)
diff --git a/src/instamatic/gui/videostream_frame.py b/src/instamatic/gui/videostream_frame.py
index f7486f8d..09a9ea38 100644
--- a/src/instamatic/gui/videostream_frame.py
+++ b/src/instamatic/gui/videostream_frame.py
@@ -11,6 +11,7 @@
 
 from instamatic.formats import read_tiff, write_tiff
 from instamatic.gui.base_module import BaseModule
+from instamatic.gui.click_dispatcher import ClickDispatcher
 from instamatic.processing.flatfield import apply_flatfield_correction
 from instamatic.utils.spinbox import Spinbox
 
@@ -66,6 +67,9 @@ def __init__(self, parent, stream, app=None):
 
         self.start_stream()
 
+        self.click_dispatcher = ClickDispatcher()
+        self.panel.bind('<Button>', self.on_click)
+
     def init_vars(self):
         self.var_fps = DoubleVar()
         self.var_interval = DoubleVar()
@@ -289,6 +293,20 @@ def update_frametimes(self):
         else:
             self.nframes += 1
 
+    def on_click(self, event: Event) -> None:
+        """Handle click events on the image via `self.click_service`"""
+        if not self.click_dispatcher.active:
+            return
+
+        # Convert window coordinates to image coordinates
+        panel_width: int = self.panel.winfo_width()
+        panel_height: int = self.panel.winfo_height()
+        array_shape = self.frame.shape
+        x = round(event.x * array_shape[1] / panel_width)
+        y = round(event.y * array_shape[0] / panel_height)
+
+        self.click_dispatcher.handle_click(x=x, y=y, button=event.num)
+
 
 module = BaseModule(
     name='stream', display_name='Stream', tk_frame=VideoStreamFrame, location='left'


Reviews:
user: login: stefsmeets
body: Looks good to me for the most part, just wondering if the `NoOverwriteDict` is necessary.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T14:14:15Z
user: login: Baharis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T14:59:01Z
user: login: Baharis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T15:00:07Z
user: login: stefsmeets
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T15:54:36Z
user: login: Baharis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T18:38:35Z

Review comments:
diff_hunk: @@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import enum
+import queue
+from typing import Callable, Optional, Union
+
+from typing_extensions import Self
+
+from instamatic._collections import NoOverwriteDict
+
+
+class MouseButton(enum.IntEnum):
+    LEFT = 1
+    MIDDLE = 2
+    RIGHT = 3
+
+
+class ClickEvent:
+    """Individual click event expected and handled by `ClickListener`s."""
+
+    def __init__(
+        self,
+        x: Optional[int] = None,
+        y: Optional[int] = None,
+        button: Optional[int] = None,
+    ) -> None:
+        self.x = x if x else 0
+        self.y = y if y else 0
+        self.button = MouseButton(button) if button else MouseButton.LEFT
+
+    def __repr__(self) -> str:
+        return f'{self.__class__.__name__}(x={self.x}, y={self.y}, button={self.button})'
+
+
+class ClickListener:
+    """Request clicks and call `callback` on each received."""
+
+    def __init__(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> None:
+        self.name = name
+        self.callback = callback
+        self.queue = queue.Queue()
+        self.active = False
+
+    def __enter__(self) -> Self:
+        self.queue.queue.clear()
+        self.active = True
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+        self.active = False
+
+    def handle_click(self, click_event: ClickEvent) -> None:
+        if self.active:
+            self.queue.put(click_event)
+            if self.callback is not None:
+                self.callback(click_event)
+
+    def get_click(self, timeout: float = None) -> Union[ClickEvent, None]:
+        """Get next `ClickEvent` from the queue or await it until `timeout`"""
+        try:
+            return self.queue.get(timeout=timeout)
+        except queue.Empty:
+            return None
+
+
+class ClickDispatcher:
+    """Manages `ClickEvent` distribution across `ClickListeners`."""
+
+    def __init__(self):
+        self.listeners: NoOverwriteDict[str, ClickListener] = NoOverwriteDict()
path: src/instamatic/gui/click_dispatcher.py
user: login: stefsmeets
body: Is the `NoOverwriteDict()` necessary?
created_at: 2025-03-27T14:11:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import enum
+import queue
+from typing import Callable, Optional, Union
+
+from typing_extensions import Self
+
+from instamatic._collections import NoOverwriteDict
+
+
+class MouseButton(enum.IntEnum):
+    LEFT = 1
+    MIDDLE = 2
+    RIGHT = 3
+
+
+class ClickEvent:
+    """Individual click event expected and handled by `ClickListener`s."""
+
+    def __init__(
+        self,
+        x: Optional[int] = None,
+        y: Optional[int] = None,
+        button: Optional[int] = None,
+    ) -> None:
+        self.x = x if x else 0
+        self.y = y if y else 0
+        self.button = MouseButton(button) if button else MouseButton.LEFT
+
+    def __repr__(self) -> str:
+        return f'{self.__class__.__name__}(x={self.x}, y={self.y}, button={self.button})'
+
+
+class ClickListener:
+    """Request clicks and call `callback` on each received."""
+
+    def __init__(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> None:
+        self.name = name
+        self.callback = callback
+        self.queue = queue.Queue()
+        self.active = False
+
+    def __enter__(self) -> Self:
+        self.queue.queue.clear()
+        self.active = True
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+        self.active = False
+
+    def handle_click(self, click_event: ClickEvent) -> None:
+        if self.active:
+            self.queue.put(click_event)
+            if self.callback is not None:
+                self.callback(click_event)
+
+    def get_click(self, timeout: float = None) -> Union[ClickEvent, None]:
+        """Get next `ClickEvent` from the queue or await it until `timeout`"""
+        try:
+            return self.queue.get(timeout=timeout)
+        except queue.Empty:
+            return None
+
+
+class ClickDispatcher:
+    """Manages `ClickEvent` distribution across `ClickListeners`."""
+
+    def __init__(self):
+        self.listeners: NoOverwriteDict[str, ClickListener] = NoOverwriteDict()
+
+    @property
+    def active(self) -> bool:
+        return any(listener.active for listener in self.listeners.values())
+
+    def add_listener(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> ClickListener:
+        """Convenience method that adds and returns a new `ClickListener`"""
+        listener = ClickListener(name, callback)
+        self.listeners[name] = listener
path: src/instamatic/gui/click_dispatcher.py
user: login: stefsmeets
body: Seems like the logic for `NoOverwriteDict` could instead be here:

```suggestion
        if name in self.listeners:
            raise KeyError(f'Listener "{name}" already exists and cannot be overwritten.')
        self.listeners[name] = listener
```
created_at: 2025-03-27T14:12:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import enum
+import queue
+from typing import Callable, Optional, Union
+
+from typing_extensions import Self
+
+from instamatic._collections import NoOverwriteDict
+
+
+class MouseButton(enum.IntEnum):
+    LEFT = 1
+    MIDDLE = 2
+    RIGHT = 3
+
+
+class ClickEvent:
+    """Individual click event expected and handled by `ClickListener`s."""
+
+    def __init__(
+        self,
+        x: Optional[int] = None,
+        y: Optional[int] = None,
+        button: Optional[int] = None,
+    ) -> None:
+        self.x = x if x else 0
+        self.y = y if y else 0
+        self.button = MouseButton(button) if button else MouseButton.LEFT
+
+    def __repr__(self) -> str:
+        return f'{self.__class__.__name__}(x={self.x}, y={self.y}, button={self.button})'
+
+
+class ClickListener:
+    """Request clicks and call `callback` on each received."""
+
+    def __init__(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> None:
+        self.name = name
+        self.callback = callback
+        self.queue = queue.Queue()
+        self.active = False
+
+    def __enter__(self) -> Self:
+        self.queue.queue.clear()
+        self.active = True
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+        self.active = False
+
+    def handle_click(self, click_event: ClickEvent) -> None:
+        if self.active:
+            self.queue.put(click_event)
+            if self.callback is not None:
+                self.callback(click_event)
+
+    def get_click(self, timeout: float = None) -> Union[ClickEvent, None]:
+        """Get next `ClickEvent` from the queue or await it until `timeout`"""
+        try:
+            return self.queue.get(timeout=timeout)
+        except queue.Empty:
+            return None
+
+
+class ClickDispatcher:
+    """Manages `ClickEvent` distribution across `ClickListeners`."""
+
+    def __init__(self):
+        self.listeners: NoOverwriteDict[str, ClickListener] = NoOverwriteDict()
+
+    @property
+    def active(self) -> bool:
+        return any(listener.active for listener in self.listeners.values())
+
+    def add_listener(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> ClickListener:
+        """Convenience method that adds and returns a new `ClickListener`"""
+        listener = ClickListener(name, callback)
+        self.listeners[name] = listener
path: src/instamatic/gui/click_dispatcher.py
user: login: Baharis
body: It absolutely could, but then let me put the question the other way around - why would you write:
```python
if name in self.listeners:
    raise KeyError(f'Listener "{name}" already exists and cannot be overwritten.')
self.listeners[name] = listener
```
each time if you can just compartmentalize that don't-overwrite feature into a simple class and use anywhere you don't want overwriting, simply by writing:
```python
self.listeners[name] = listener  # and the class assets key is not overwritten
```

That's kind of the principle of OOP. I personally think a few of existing instamatic dicts (i.e. module, frame, camera) should never allow overwriting and should be replaced with a `NoOverwriteDict` to avoid potential issues.

All in all its a matter of preference, and in my opinion this makes the logic more clear, as the "don't overwrite keys" is not very relevant to the listener, so why focus on that there?
created_at: 2025-03-27T14:59:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import enum
+import queue
+from typing import Callable, Optional, Union
+
+from typing_extensions import Self
+
+from instamatic._collections import NoOverwriteDict
+
+
+class MouseButton(enum.IntEnum):
+    LEFT = 1
+    MIDDLE = 2
+    RIGHT = 3
+
+
+class ClickEvent:
+    """Individual click event expected and handled by `ClickListener`s."""
+
+    def __init__(
+        self,
+        x: Optional[int] = None,
+        y: Optional[int] = None,
+        button: Optional[int] = None,
+    ) -> None:
+        self.x = x if x else 0
+        self.y = y if y else 0
+        self.button = MouseButton(button) if button else MouseButton.LEFT
+
+    def __repr__(self) -> str:
+        return f'{self.__class__.__name__}(x={self.x}, y={self.y}, button={self.button})'
+
+
+class ClickListener:
+    """Request clicks and call `callback` on each received."""
+
+    def __init__(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> None:
+        self.name = name
+        self.callback = callback
+        self.queue = queue.Queue()
+        self.active = False
+
+    def __enter__(self) -> Self:
+        self.queue.queue.clear()
+        self.active = True
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+        self.active = False
+
+    def handle_click(self, click_event: ClickEvent) -> None:
+        if self.active:
+            self.queue.put(click_event)
+            if self.callback is not None:
+                self.callback(click_event)
+
+    def get_click(self, timeout: float = None) -> Union[ClickEvent, None]:
+        """Get next `ClickEvent` from the queue or await it until `timeout`"""
+        try:
+            return self.queue.get(timeout=timeout)
+        except queue.Empty:
+            return None
+
+
+class ClickDispatcher:
+    """Manages `ClickEvent` distribution across `ClickListeners`."""
+
+    def __init__(self):
+        self.listeners: NoOverwriteDict[str, ClickListener] = NoOverwriteDict()
path: src/instamatic/gui/click_dispatcher.py
user: login: Baharis
body: It is not but in my opinion it makes things more clear, see comment below.
created_at: 2025-03-27T15:00:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import enum
+import queue
+from typing import Callable, Optional, Union
+
+from typing_extensions import Self
+
+from instamatic._collections import NoOverwriteDict
+
+
+class MouseButton(enum.IntEnum):
+    LEFT = 1
+    MIDDLE = 2
+    RIGHT = 3
+
+
+class ClickEvent:
+    """Individual click event expected and handled by `ClickListener`s."""
+
+    def __init__(
+        self,
+        x: Optional[int] = None,
+        y: Optional[int] = None,
+        button: Optional[int] = None,
+    ) -> None:
+        self.x = x if x else 0
+        self.y = y if y else 0
+        self.button = MouseButton(button) if button else MouseButton.LEFT
+
+    def __repr__(self) -> str:
+        return f'{self.__class__.__name__}(x={self.x}, y={self.y}, button={self.button})'
+
+
+class ClickListener:
+    """Request clicks and call `callback` on each received."""
+
+    def __init__(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> None:
+        self.name = name
+        self.callback = callback
+        self.queue = queue.Queue()
+        self.active = False
+
+    def __enter__(self) -> Self:
+        self.queue.queue.clear()
+        self.active = True
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+        self.active = False
+
+    def handle_click(self, click_event: ClickEvent) -> None:
+        if self.active:
+            self.queue.put(click_event)
+            if self.callback is not None:
+                self.callback(click_event)
+
+    def get_click(self, timeout: float = None) -> Union[ClickEvent, None]:
+        """Get next `ClickEvent` from the queue or await it until `timeout`"""
+        try:
+            return self.queue.get(timeout=timeout)
+        except queue.Empty:
+            return None
+
+
+class ClickDispatcher:
+    """Manages `ClickEvent` distribution across `ClickListeners`."""
+
+    def __init__(self):
+        self.listeners: NoOverwriteDict[str, ClickListener] = NoOverwriteDict()
+
+    @property
+    def active(self) -> bool:
+        return any(listener.active for listener in self.listeners.values())
+
+    def add_listener(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> ClickListener:
+        """Convenience method that adds and returns a new `ClickListener`"""
+        listener = ClickListener(name, callback)
+        self.listeners[name] = listener
path: src/instamatic/gui/click_dispatcher.py
user: login: stefsmeets
body: Yeah, I agree. I'm just wondering if this isn't a case of premature optimization.
created_at: 2025-03-27T15:54:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import enum
+import queue
+from typing import Callable, Optional, Union
+
+from typing_extensions import Self
+
+from instamatic._collections import NoOverwriteDict
+
+
+class MouseButton(enum.IntEnum):
+    LEFT = 1
+    MIDDLE = 2
+    RIGHT = 3
+
+
+class ClickEvent:
+    """Individual click event expected and handled by `ClickListener`s."""
+
+    def __init__(
+        self,
+        x: Optional[int] = None,
+        y: Optional[int] = None,
+        button: Optional[int] = None,
+    ) -> None:
+        self.x = x if x else 0
+        self.y = y if y else 0
+        self.button = MouseButton(button) if button else MouseButton.LEFT
+
+    def __repr__(self) -> str:
+        return f'{self.__class__.__name__}(x={self.x}, y={self.y}, button={self.button})'
+
+
+class ClickListener:
+    """Request clicks and call `callback` on each received."""
+
+    def __init__(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> None:
+        self.name = name
+        self.callback = callback
+        self.queue = queue.Queue()
+        self.active = False
+
+    def __enter__(self) -> Self:
+        self.queue.queue.clear()
+        self.active = True
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+        self.active = False
+
+    def handle_click(self, click_event: ClickEvent) -> None:
+        if self.active:
+            self.queue.put(click_event)
+            if self.callback is not None:
+                self.callback(click_event)
+
+    def get_click(self, timeout: float = None) -> Union[ClickEvent, None]:
+        """Get next `ClickEvent` from the queue or await it until `timeout`"""
+        try:
+            return self.queue.get(timeout=timeout)
+        except queue.Empty:
+            return None
+
+
+class ClickDispatcher:
+    """Manages `ClickEvent` distribution across `ClickListeners`."""
+
+    def __init__(self):
+        self.listeners: NoOverwriteDict[str, ClickListener] = NoOverwriteDict()
+
+    @property
+    def active(self) -> bool:
+        return any(listener.active for listener in self.listeners.values())
+
+    def add_listener(
+        self,
+        name: str,
+        callback: Optional[Callable[[ClickEvent], None]] = None,
+    ) -> ClickListener:
+        """Convenience method that adds and returns a new `ClickListener`"""
+        listener = ClickListener(name, callback)
+        self.listeners[name] = listener
path: src/instamatic/gui/click_dispatcher.py
user: login: Baharis
body: I will merge with the `NoOverwriteDict` for the time being, I have useful objects in mind for the `instamatic._collections` planned. And if they fail, it can be drop-in replaced with a (more error prone) standard dict.
created_at: 2025-03-27T18:38:34Z
author_association: MEMBER

PR comments:
user: login: Baharis
created_at: 2025-03-25T23:12:29Z
body: Pinging @viljarjf because I think he will appreciate addition of `typing_extensions` ☺️ 
author_association: MEMBER
user: login: Baharis
created_at: 2025-03-25T23:52:52Z
body: ### Disambiguation on `Self` vs `TypeVar`
For nerds like me and potential time travelers, `Self` suggests that a method returns exactly the same type as class. Furthermore, for any class that inherits from `CameraBase`, `Self` becomes the type of said child class, and then `__enter__` returns indeed `self` of the subclass type. So because `CameraBase.__enter__` would always return `CameraBase`-type object and `CameraSubclass.__enter__` a `CameraSubclass`-type object, the following is correct:
```python
class CameraBase(ABC):
    def __enter__(self) -> Self:
        self.establish_connection()
        return self
```
However, whenever one class might return another, the `Self` syntax is no longer correct. For example, in the `VideoStream`, it is better to say that `VideoStream.from_any` returns `TypeVar('VideoStream_T', bound='VideoStream')` (i.e. any subclass of `VideoStream`) because there `VideoStream.from_any` might produce `FakeVideoStream` - a different class:
```python
VideoStream_T = TypeVar('VideoStream_T', bound='VideoStream')  # VideoStream or subclass
class VideoStream(threading.Thread):
    @classmethod
    def from_any(cls: Type[VideoStream_T], cam: Union[CameraBase, str] = 'simulate') -> VideoStream_T:
        """Create a subclass based on passed cam or cam-str stream-ability."""
        cam: CameraBase = Camera(name=cam) if isinstance(cam, str) else cam
        if cls is VideoStream:
            return (LiveVideoStream if cam.streamable else FakeVideoStream)(cam)
        return cls(cam)
```

Now for the most fascinating case: `VideoStream.__new__` seemingly ALWAYS produces the same type as class that calls it: `VideoStream('any_cam')` will raise `NotImplementedError` and `FakeVideoStream('any_cam')` will always produce `FakeVideoStream`. However, because `__new__` is a _class_ method (!) specifically defined for `VideoStream` _class_, not an _instance_ method like `__enter__` that interprets `Self` type based on passed _instance_ `self`, it should use a generic `VideoStream_T` return type!
```python
VideoStream_T = TypeVar('VideoStream_T', bound='VideoStream')  # VideoStream or subclass
class VideoStream(threading.Thread):
    def __new__(cls: Type[VideoStream_T], *args, **kwargs) -> VideoStream_T:
        if cls is VideoStream:
            msg = 'Initialize a `VideoStream` via its subclasses or using `.from_any()`.'
            raise NotImplementedError(msg)
        return super(VideoStream, cls).__new__(cls)
```
Does this matter? Unless you have OCD or ASD, probably not, but I love exploring typing details like this.
author_association: MEMBER
user: login: stefsmeets
created_at: 2025-03-27T14:04:36Z
body: > Unless you have OCD or ASD, probably not, but I love exploring typing details like this.

There are 2 type[people] in this world, those who enjoy types and those who mypy 😅
author_association: MEMBER