Repository: pyvista/pyvista
Pull request #7497: Add ability to use custom interactor styles
Author: login: MatthewFlamm
Labels: name: enhancement
description: Changes that enhance the library
Created: 2025-05-06T15:56:46Z | Merged: 2025-05-22T15:05:09Z

### Overview

This PR refactors the way InteractorStyle's are created and used.  This will ease implementation of, although is not required for, #7359.  An added bonus is that it now becomes easier for users to implement custom interactors.  See #7485.

This PR also will make it easier to address #7500.

### Details

Instead of using a style factory to generate wrapped vtk classes, use an inheritance strategy.  This enables users to pass in a subclass of `vtk.vtkInteractorStyle` and hook into the machinery in pyvista's `RenderWindowInteractor`

See also https://github.com/pyvista/pyvista/pull/4798.

Interactors are hard to write good tests for, it would useful to live test this.

### TODO
- [x] add tests for built in interactor styles
  - some may already be covered by existing tests, but more comprehensive
- [x] add tests for new setter functionality
- [x] add test for custom interactor styles
- [x] add better documentation for setting custom style
  - I don't fully understand the reasoning behind the events that are added for the built-in functionality, but we may want to warn users about this in some form.
- [x] run manual/live tests using built-in interactors
- [x] run manual/live tests using custom interactor styles (not tested, but built-in interactors use same machinery)
- [x] run manual/live tests using charts interaction (adjusts interactor style)
- [x] run manual/live tests using affine widget (adjusts interactor style)



Diff:
diff --git a/doc/source/api/plotting/index.rst b/doc/source/api/plotting/index.rst
index 84d71bf6e25..f5af17421fb 100644
--- a/doc/source/api/plotting/index.rst
+++ b/doc/source/api/plotting/index.rst
@@ -106,6 +106,28 @@ class.
    plotting.picking.PickingHelper
 
 
+Interactor Style API
+--------------------
+An interactor style sets mouse and key bindings to interact with
+the plot. Most often methods like :func:`pyvista.Plotter.enable_trackball_style`
+would be used, but this API can be used as a starting point for customizing the
+interaction styles.
+
+.. autosummary::
+   :toctree: _autosummary
+
+   plotting.render_window_interactor.InteractorStyleCaptureMixin
+   plotting.render_window_interactor.InteractorStyleImage
+   plotting.render_window_interactor.InteractorStyleJoystickActor
+   plotting.render_window_interactor.InteractorStyleJoystickCamera
+   plotting.render_window_interactor.InteractorStyleRubberBand2D
+   plotting.render_window_interactor.InteractorStyleRubberBandPick
+   plotting.render_window_interactor.InteractorStyleTrackballActor
+   plotting.render_window_interactor.InteractorStyleTrackballCamera
+   plotting.render_window_interactor.InteractorStyleTerrain
+   plotting.render_window_interactor.InteractorStyleZoom
+
+
 Convenience Functions
 ~~~~~~~~~~~~~~~~~~~~~
 These functions provide a simplified interface to the various plotting
diff --git a/doc/styles/Vocab/pyvista/accept.txt b/doc/styles/Vocab/pyvista/accept.txt
index bb2280f4398..2aada542017 100644
--- a/doc/styles/Vocab/pyvista/accept.txt
+++ b/doc/styles/Vocab/pyvista/accept.txt
@@ -36,6 +36,7 @@ Gmsh
 HTML
 Helens
 Hotfixes
+Interactor
 Isoluminant
 Isort
 Jupyter
@@ -117,6 +118,7 @@ https
 imageio
 img
 initializer
+interactor
 itkwidgets
 jupyter
 kubernetes
diff --git a/pyvista/plotting/_vtk.py b/pyvista/plotting/_vtk.py
index 485c5620d5a..e9056ef8893 100644
--- a/pyvista/plotting/_vtk.py
+++ b/pyvista/plotting/_vtk.py
@@ -26,6 +26,29 @@
 from vtkmodules.vtkChartsCore import vtkPlotStacked as vtkPlotStacked
 from vtkmodules.vtkChartsCore import vtkPlotSurface as vtkPlotSurface
 from vtkmodules.vtkCommonColor import vtkColorSeries as vtkColorSeries
+from vtkmodules.vtkInteractionStyle import vtkInteractorStyleImage as vtkInteractorStyleImage
+from vtkmodules.vtkInteractionStyle import (
+    vtkInteractorStyleJoystickActor as vtkInteractorStyleJoystickActor,
+)
+from vtkmodules.vtkInteractionStyle import (
+    vtkInteractorStyleJoystickCamera as vtkInteractorStyleJoystickCamera,
+)
+from vtkmodules.vtkInteractionStyle import (
+    vtkInteractorStyleRubberBand2D as vtkInteractorStyleRubberBand2D,
+)
+from vtkmodules.vtkInteractionStyle import (
+    vtkInteractorStyleRubberBandPick as vtkInteractorStyleRubberBandPick,
+)
+from vtkmodules.vtkInteractionStyle import (
+    vtkInteractorStyleRubberBandZoom as vtkInteractorStyleRubberBandZoom,
+)
+from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTerrain as vtkInteractorStyleTerrain
+from vtkmodules.vtkInteractionStyle import (
+    vtkInteractorStyleTrackballActor as vtkInteractorStyleTrackballActor,
+)
+from vtkmodules.vtkInteractionStyle import (
+    vtkInteractorStyleTrackballCamera as vtkInteractorStyleTrackballCamera,
+)
 from vtkmodules.vtkInteractionWidgets import vtkBoxWidget as vtkBoxWidget
 from vtkmodules.vtkInteractionWidgets import vtkButtonWidget as vtkButtonWidget
 from vtkmodules.vtkInteractionWidgets import (
@@ -91,6 +114,7 @@
 from vtkmodules.vtkRenderingCore import vtkCoordinate as vtkCoordinate
 from vtkmodules.vtkRenderingCore import vtkDataSetMapper as vtkDataSetMapper
 from vtkmodules.vtkRenderingCore import vtkImageActor as vtkImageActor
+from vtkmodules.vtkRenderingCore import vtkInteractorStyle as vtkInteractorStyle
 from vtkmodules.vtkRenderingCore import vtkLight as vtkLight
 from vtkmodules.vtkRenderingCore import vtkLightActor as vtkLightActor
 from vtkmodules.vtkRenderingCore import vtkLightKit as vtkLightKit
diff --git a/pyvista/plotting/render_window_interactor.py b/pyvista/plotting/render_window_interactor.py
index a9465018197..cf2807f1902 100644
--- a/pyvista/plotting/render_window_interactor.py
+++ b/pyvista/plotting/render_window_interactor.py
@@ -8,13 +8,16 @@
 from inspect import signature
 import logging
 import time
+from typing import Literal
 import warnings
 import weakref
 
 import numpy as np
 
 from pyvista import vtk_version_info
+from pyvista.core._vtk_core import DisableVtkSnakeCase
 from pyvista.core.errors import PyVistaDeprecationWarning
+from pyvista.core.utilities.misc import abstract_class
 from pyvista.core.utilities.misc import try_callback
 
 from . import _vtk
@@ -24,8 +27,6 @@
 log.setLevel('CRITICAL')
 log.addHandler(logging.StreamHandler())
 
-_CLASSES = {}
-
 
 class Timer:
     """Timer class.
@@ -108,8 +109,9 @@ def __init__(self, plotter, desired_update_rate=30, light_follow_camera=True, in
         self._MAX_CLICK_DELTA = 40  # squared => ~6 pixels
 
         # Set default style
-        self._style = 'RubberBandPick'
-        self._style_class = None
+        self._style_class: _vtk.vtkInteractorStyle | None = None
+        self._style: Literal['Interactor', 'Context'] = 'Interactor'
+        self.style = InteractorStyleRubberBandPick(self)
         self.__plotter = weakref.ref(plotter)
 
         # Toggle interaction style when clicked on a visible chart (to
@@ -231,10 +233,16 @@ def add_observer(self, event, call, interactor_style_fallback=True):
         """
         call = partial(try_callback, call)
         event = self._get_event_str(event)
-        if interactor_style_fallback and event in [
-            'LeftButtonReleaseEvent',
-            'RightButtonReleaseEvent',
-        ]:
+
+        if (
+            isinstance(self.style, InteractorStyleCaptureMixin)
+            and interactor_style_fallback
+            and event
+            in [
+                'LeftButtonReleaseEvent',
+                'RightButtonReleaseEvent',
+            ]
+        ):
             # Release events are swallowed by the interactor, but registering
             # on the interactor style seems to work.
             # See https://github.com/pyvista/pyvista/issues/4976
@@ -433,26 +441,61 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting :meth:`style` attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
-        """Return the current interactor style.
+    def style(
+        self,
+    ) -> (
+        _vtk.vtkContextInteractorStyle
+        | _vtk.vtkInteractorStyle
+        | InteractorStyleCaptureMixin
+        | None
+    ):
+        """Get/set the current interactor style.
+
+        .. warning::
+
+            Setting an interactor style needs careful control of events handling.
+            See :class:`~plotting.render_window_interactor.InteractorStyleCaptureMixin` and its implementation as an example.
 
         Returns
         -------
-        :vtk:`vtkInteractorStyle`
+        :vtk:`vtkInteractorStyle` | :vtk:`vtkContextInteractorStyle` | None
             The current interactor style.
 
+        Examples
+        --------
+        Set interactor style with a customized vtk interactor
+
+        >>> import pyvista as pv
+        >>> from vtkmodules.vtkInteractionStyle import (
+        ...     vtkInteractorStyleTrackballCamera,
+        ... )
+
+        >>> class MyCustomInteractorStyle(vtkInteractorStyleTrackballCamera):
+        ...     # Implement custom functionality
+        ...     def __repr__(self):
+        ...         return 'A custom interactor style.'
+
+        >>> plotter = pv.Plotter()
+        >>> plotter.iren.style = MyCustomInteractorStyle()
+        >>> plotter.iren.style
+        A custom interactor style.
+
         """
-        if self._style_class is None:
-            self.update_style()
         return self._style_class
 
+    @style.setter
+    def style(self, style: _vtk.vtkInteractorStyle | InteractorStyleCaptureMixin | None):
+        self._style = 'Interactor'
+        self._style_class = style
+        self.update_style()
+
     def _toggle_chart_interaction(self, mouse_pos):
         """Toggle interaction with indicated charts.
 
@@ -515,13 +558,13 @@ def _set_context_style(self, scene):
         if scene is None and self._style == 'Context':
             # Switch back to previous interactor style
             self._style = self._prev_style  # type: ignore[has-type]
-            self._style_class = self._prev_style_class  # type: ignore[has-type]
+            self.style = self._prev_style_class  # type: ignore[has-type]
             self._prev_style = None
             self._prev_style_class = None
         elif scene is not None and self._style != 'Context':
             # Enable context interactor style
             self._prev_style = self._style
-            self._prev_style_class = self._style_class
+            self._prev_style_class = self.style
             self._style = 'Context'
             self._style_class = self._context_style
         self.update_style()
@@ -557,9 +600,7 @@ def enable_trackball_style(self):
         >>> plotter.show()  # doctest:+SKIP
 
         """
-        self._style = 'TrackballCamera'
-        self._style_class = None
-        self.update_style()
+        self.style = InteractorStyleTrackballCamera(self)
 
     def enable_custom_trackball_style(
         self,
@@ -637,24 +678,22 @@ def enable_custom_trackball_style(
         >>> plotter.show()  # doctest:+SKIP
 
         """
-        self._style = 'TrackballCamera'
-        self._style_class = None
-        self.update_style()
+        self.style = InteractorStyleTrackballCamera(self)
 
         start_action_map = {
-            'environment_rotate': self._style_class.StartEnvRotate,  # type: ignore[attr-defined]
-            'rotate': self._style_class.StartRotate,  # type: ignore[attr-defined]
-            'pan': self._style_class.StartPan,  # type: ignore[attr-defined]
-            'spin': self._style_class.StartSpin,  # type: ignore[attr-defined]
-            'dolly': self._style_class.StartDolly,  # type: ignore[attr-defined]
+            'environment_rotate': self.style.StartEnvRotate,
+            'rotate': self.style.StartRotate,
+            'pan': self.style.StartPan,
+            'spin': self.style.StartSpin,
+            'dolly': self.style.StartDolly,
         }
 
         end_action_map = {
-            'environment_rotate': self._style_class.EndEnvRotate,  # type: ignore[attr-defined]
-            'rotate': self._style_class.EndRotate,  # type: ignore[attr-defined]
-            'pan': self._style_class.EndPan,  # type: ignore[attr-defined]
-            'spin': self._style_class.EndSpin,  # type: ignore[attr-defined]
-            'dolly': self._style_class.EndDolly,  # type: ignore[attr-defined]
+            'environment_rotate': self.style.EndEnvRotate,
+            'rotate': self.style.EndRotate,
+            'pan': self.style.EndPan,
+            'spin': self.style.EndSpin,
+            'dolly': self.style.EndDolly,
         }
 
         for p in [
@@ -673,14 +712,14 @@ def enable_custom_trackball_style(
                 raise ValueError(msg)
 
         button_press_map = {
-            'left': self._style_class.OnLeftButtonDown,  # type: ignore[attr-defined]
-            'middle': self._style_class.OnMiddleButtonDown,  # type: ignore[attr-defined]
-            'right': self._style_class.OnRightButtonDown,  # type: ignore[attr-defined]
+            'left': self.style.OnLeftButtonDown,
+            'middle': self.style.OnMiddleButtonDown,
+            'right': self.style.OnRightButtonDown,
         }
         button_release_map = {
-            'left': self._style_class.OnLeftButtonUp,  # type: ignore[attr-defined]
-            'middle': self._style_class.OnMiddleButtonUp,  # type: ignore[attr-defined]
-            'right': self._style_class.OnRightButtonUp,  # type: ignore[attr-defined]
+            'left': self.style.OnLeftButtonUp,
+            'middle': self.style.OnMiddleButtonUp,
+            'right': self.style.OnRightButtonUp,
         }
 
         def _setup_callbacks(button, click, control, shift):
@@ -724,8 +763,8 @@ def _release_callback(_obj, event):
             control_left,
             shift_left,
         )
-        self._style_class.add_observer('LeftButtonPressEvent', _left_button_press_callback)  # type: ignore[attr-defined]
-        self._style_class.add_observer('LeftButtonReleaseEvent', _left_button_release_callback)  # type: ignore[attr-defined]
+        self.style.add_observer('LeftButtonPressEvent', _left_button_press_callback)
+        self.style.add_observer('LeftButtonReleaseEvent', _left_button_release_callback)
 
         _middle_button_press_callback, _middle_button_release_callback = _setup_callbacks(
             'middle',
@@ -733,8 +772,8 @@ def _release_callback(_obj, event):
             control_middle,
             shift_middle,
         )
-        self._style_class.add_observer('MiddleButtonPressEvent', _middle_button_press_callback)  # type: ignore[attr-defined]
-        self._style_class.add_observer('MiddleButtonReleaseEvent', _middle_button_release_callback)  # type: ignore[attr-defined]
+        self.style.add_observer('MiddleButtonPressEvent', _middle_button_press_callback)
+        self.style.add_observer('MiddleButtonReleaseEvent', _middle_button_release_callback)
 
         _right_button_press_callback, _right_button_release_callback = _setup_callbacks(
             'right',
@@ -742,8 +781,8 @@ def _release_callback(_obj, event):
             control_right,
             shift_right,
         )
-        self._style_class.add_observer('RightButtonPressEvent', _right_button_press_callback)  # type: ignore[attr-defined]
-        self._style_class.add_observer('RightButtonReleaseEvent', _right_button_release_callback)  # type: ignore[attr-defined]
+        self.style.add_observer('RightButtonPressEvent', _right_button_press_callback)
+        self.style.add_observer('RightButtonReleaseEvent', _right_button_release_callback)
 
     def enable_2d_style(self):
         """Set the interactive style to 2D.
@@ -821,9 +860,7 @@ def enable_trackball_actor_style(self):
         >>> plotter.show()  # doctest:+SKIP
 
         """
-        self._style = 'TrackballActor'
-        self._style_class = None
-        self.update_style()
+        self.style = InteractorStyleTrackballActor(self)
 
     def enable_image_style(self):
         """Set the interactive style to Image.
@@ -851,9 +888,7 @@ def enable_image_style(self):
         >>> plotter.show()  # doctest:+SKIP
 
         """
-        self._style = 'Image'
-        self._style_class = None
-        self.update_style()
+        self.style = InteractorStyleImage(self)
 
     def enable_joystick_style(self):
         """Set the interactive style to Joystick Camera.
@@ -884,9 +919,7 @@ def enable_joystick_style(self):
         >>> plotter.show()  # doctest:+SKIP
 
         """
-        self._style = 'JoystickCamera'
-        self._style_class = None
-        self.update_style()
+        self.style = InteractorStyleJoystickCamera(self)
 
     def enable_joystick_actor_style(self):
         """Set the interactive style to Joystick Actor.
@@ -918,9 +951,7 @@ def enable_joystick_actor_style(self):
         >>> plotter.show()  # doctest:+SKIP
 
         """
-        self._style = 'JoystickActor'
-        self._style_class = None
-        self.update_style()
+        self.style = InteractorStyleJoystickActor(self)
 
     def enable_zoom_style(self):
         """Set the interactive style to Rubber Band Zoom.
@@ -944,9 +975,7 @@ def enable_zoom_style(self):
         >>> plotter.show()  # doctest:+SKIP
 
         """
-        self._style = 'RubberBandZoom'
-        self._style_class = None
-        self.update_style()
+        self.style = InteractorStyleZoom(self)
 
     def enable_terrain_style(self, mouse_wheel_zooms: bool | float = True, shift_pans: bool = True):
         """Set the interactive style to Terrain.
@@ -1024,9 +1053,7 @@ def enable_terrain_style(self, mouse_wheel_zooms: bool | float = True, shift_pan
         >>> plotter.show()  # doctest:+SKIP
 
         """
-        self._style = 'Terrain'
-        self._style_class = None
-        self.update_style()
+        self.style = InteractorStyleTerrain(self)
 
         if mouse_wheel_zooms:
             factor = 1.05 if isinstance(mouse_wheel_zooms, bool) else mouse_wheel_zooms
@@ -1057,7 +1084,7 @@ def wheel_zoom_callback(_obj, event):  # pragma: no cover
             callback = partial(try_callback, wheel_zoom_callback)
 
             for event in 'MouseWheelForwardEvent', 'MouseWheelBackwardEvent':
-                self._style_class.add_observer(event, callback)  # type: ignore[attr-defined]
+                self.style.add_observer(event, callback)
 
         if shift_pans:
 
@@ -1065,17 +1092,17 @@ def pan_on_shift_callback(_obj, event):  # pragma: no cover
                 """Trigger left mouse panning if shift is pressed."""
                 if event == 'LeftButtonPressEvent':
                     if self.interactor.GetShiftKey():
-                        self._style_class.StartPan()  # type: ignore[union-attr]
-                    self._style_class.OnLeftButtonDown()  # type: ignore[union-attr]
+                        self.style.StartPan()  # type: ignore[union-attr]
+                    self.style.OnLeftButtonDown()  # type: ignore[union-attr]
                 elif event == 'LeftButtonReleaseEvent':
                     # always stop panning on release
-                    self._style_class.EndPan()  # type: ignore[union-attr]
-                    self._style_class.OnLeftButtonUp()  # type: ignore[union-attr]
+                    self.style.EndPan()  # type: ignore[union-attr]
+                    self.style.OnLeftButtonUp()  # type: ignore[union-attr]
 
             callback = partial(try_callback, pan_on_shift_callback)
 
             for event in 'LeftButtonPressEvent', 'LeftButtonReleaseEvent':
-                self._style_class.add_observer(event, callback)  # type: ignore[attr-defined]
+                self.style.add_observer(event, callback)
 
     def enable_rubber_band_style(self):
         """Set the interactive style to Rubber Band Picking.
@@ -1104,9 +1131,7 @@ def enable_rubber_band_style(self):
         >>> plotter.show()  # doctest:+SKIP
 
         """
-        self._style = 'RubberBandPick'
-        self._style_class = None
-        self.update_style()
+        self.style = InteractorStyleRubberBandPick(self)
 
     def enable_rubber_band_2d_style(self):
         """Set the interactive style to Rubber Band 2D.
@@ -1139,9 +1164,7 @@ def enable_rubber_band_2d_style(self):
         >>> plotter.show()  # doctest:+SKIP
 
         """
-        self._style = 'RubberBand2D'
-        self._style_class = None
-        self.update_style()
+        self.style = InteractorStyleRubberBand2D(self)
 
     def _simulate_keypress(self, key):
         """Simulate a keypress."""
@@ -1528,11 +1551,12 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
@@ -1540,63 +1564,189 @@ def close(self):
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events. See
-    # https://public.kitware.com/pipermail/vtkusers/2013-December/082315.html.
+@abstract_class
+class InteractorStyleCaptureMixin(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events. See
+    https://public.kitware.com/pipermail/vtkusers/2013-December/082315.html.
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.InteractorStyleCaptureMixin.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
+
+
+# All interactor styles here inherit from `InteractorStyleCaptureMixin`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(InteractorStyleCaptureMixin, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    Wraps :vtk:`vtkInteractorStyleImage`.
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+    See Also
+    --------
+    :meth:`pyvista.RenderWindowInteractor.enable_image_style`
+
+    """
+
+
+class InteractorStyleJoystickActor(
+    InteractorStyleCaptureMixin, _vtk.vtkInteractorStyleJoystickActor
+):
+    """Joystick actor interactor style.
+
+    Wraps :vtk:`vtkInteractorStyleJoystickActor`.
+
+    See Also
+    --------
+    :meth:`pyvista.RenderWindowInteractor.enable_joystick_actor_style`
+
+    """
+
+
+class InteractorStyleJoystickCamera(
+    InteractorStyleCaptureMixin, _vtk.vtkInteractorStyleJoystickCamera
+):
+    """Joystick camera interactor style.
+
+    Wraps :vtk:`vtkInteractorStyleJoystickCamera`.
+
+    See Also
+    --------
+    :meth:`pyvista.RenderWindowInteractor.enable_joystick_style`
+
+    """
+
+
+class InteractorStyleRubberBand2D(InteractorStyleCaptureMixin, _vtk.vtkInteractorStyleRubberBand2D):
+    """Rubber band 2D interactor style.
+
+    Wraps :vtk:`vtkInteractorStyleRubberBand2D`.
+
+    See Also
+    --------
+    :meth:`pyvista.RenderWindowInteractor.enable_rubber_band_2d_style`
+
+    """
+
+
+class InteractorStyleRubberBandPick(
+    InteractorStyleCaptureMixin, _vtk.vtkInteractorStyleRubberBandPick
+):
+    """Rubber band pick interactor style.
+
+    Wraps :vtk:`vtkInteractorStyleRubberBandPick`.
+
+    See Also
+    --------
+    :meth:`pyvista.RenderWindowInteractor.enable_rubber_band_style`
+
+    """
+
+
+class InteractorStyleTrackballActor(
+    InteractorStyleCaptureMixin, _vtk.vtkInteractorStyleTrackballActor
+):
+    """Trackball actor interactor style.
+
+    Wraps :vtk:`vtkInteractorStyleTrackballActor`.
+
+    See Also
+    --------
+    :meth:`pyvista.RenderWindowInteractor.enable_trackball_actor_style`
+
+    """
+
+
+class InteractorStyleTrackballCamera(
+    InteractorStyleCaptureMixin, _vtk.vtkInteractorStyleTrackballCamera
+):
+    """Trackball camera interactor style.
+
+    Wraps :vtk:`vtkInteractorStyleTrackballCamera`.
+
+    See Also
+    --------
+    :meth:`pyvista.RenderWindowInteractor.enable_trackball_style`
+    :meth:`pyvista.RenderWindowInteractor.enable_custom_trackball_style`
+    :meth:`pyvista.RenderWindowInteractor.enable_2d_style`
+
+    """
+
+
+class InteractorStyleTerrain(InteractorStyleCaptureMixin, _vtk.vtkInteractorStyleTerrain):
+    """Terrain interactor style.
+
+    Wraps :vtk:`vtkInteractorStyleTerrain`.
+
+    See Also
+    --------
+    :meth:`pyvista.RenderWindowInteractor.enable_terrain_style`
+
+    """
+
+
+class InteractorStyleZoom(InteractorStyleCaptureMixin, _vtk.vtkInteractorStyleRubberBandZoom):
+    """Rubber band zoom interactor style.
+
+    Wraps :vtk:`vtkInteractorStyleRubberBandZoom`.
+
+    See Also
+    --------
+    :meth:`pyvista.RenderWindowInteractor.enable_zoom_style`
+
+    """
diff --git a/tests/plotting/test_render_window_interactor.py b/tests/plotting/test_render_window_interactor.py
index d1d8ccef391..60801b253a3 100644
--- a/tests/plotting/test_render_window_interactor.py
+++ b/tests/plotting/test_render_window_interactor.py
@@ -11,6 +11,15 @@
 import pyvista as pv
 from pyvista import _vtk
 from pyvista.core.errors import PyVistaDeprecationWarning
+from pyvista.plotting.render_window_interactor import InteractorStyleImage
+from pyvista.plotting.render_window_interactor import InteractorStyleJoystickActor
+from pyvista.plotting.render_window_interactor import InteractorStyleJoystickCamera
+from pyvista.plotting.render_window_interactor import InteractorStyleRubberBand2D
+from pyvista.plotting.render_window_interactor import InteractorStyleRubberBandPick
+from pyvista.plotting.render_window_interactor import InteractorStyleTerrain
+from pyvista.plotting.render_window_interactor import InteractorStyleTrackballActor
+from pyvista.plotting.render_window_interactor import InteractorStyleTrackballCamera
+from pyvista.plotting.render_window_interactor import InteractorStyleZoom
 from tests.plotting.test_plotting import skip_windows_mesa
 
 if TYPE_CHECKING:
@@ -348,3 +357,54 @@ def test_enable_custom_trackball_style():
 def test_enable_2d_style():
     pl = pv.Plotter()
     pl.enable_2d_style()
+
+
+def test_enable_interactors():
+    mapping = {
+        'enable_trackball_style': InteractorStyleTrackballCamera,
+        'enable_custom_trackball_style': InteractorStyleTrackballCamera,
+        'enable_2d_style': InteractorStyleTrackballCamera,
+        'enable_trackball_actor_style': InteractorStyleTrackballActor,
+        'enable_image_style': InteractorStyleImage,
+        'enable_joystick_style': InteractorStyleJoystickCamera,
+        'enable_joystick_actor_style': InteractorStyleJoystickActor,
+        'enable_zoom_style': InteractorStyleZoom,
+        'enable_terrain_style': InteractorStyleTerrain,
+        'enable_rubber_band_style': InteractorStyleRubberBandPick,
+        'enable_rubber_band_2d_style': InteractorStyleRubberBand2D,
+    }
+
+    pl = pv.Plotter()
+
+    # check that all "enable_*_style" methods on plotter are in the mapping and vice versa
+    attrs = dir(pl)
+    attrs_enable_style = {
+        attr for attr in attrs if attr.startswith('enable_') and attr.endswith('_style')
+    }
+
+    check_set = set(mapping.keys())
+    assert attrs_enable_style == check_set
+
+    # do the same for methods on the RenderWindowInteractor
+    attrs = dir(pl.iren)
+    attrs_enable_style = {
+        attr for attr in attrs if attr.startswith('enable_') and attr.endswith('_style')
+    }
+    check_set = set(mapping.keys())
+    assert attrs_enable_style == check_set
+
+    # check that the method gives the right class
+    for attr, class_ in mapping.items():
+        print(attr, class_)
+        getattr(pl, attr)()
+        assert isinstance(pl.iren.style, class_)
+
+    for attr, class_ in mapping.items():
+        getattr(pl.iren, attr)()
+        assert isinstance(pl.iren.style, class_)
+
+
+def test_setting_custom_style():
+    pl = pv.Plotter()
+    pl.iren.style = _vtk.vtkInteractorStyleJoystickActor()
+    assert isinstance(pl.iren.style, _vtk.vtkInteractorStyleJoystickActor)
diff --git a/tests/test_snake_case_api.py b/tests/test_snake_case_api.py
index e55cfaf25b6..4d2c8a8c3d1 100644
--- a/tests/test_snake_case_api.py
+++ b/tests/test_snake_case_api.py
@@ -134,6 +134,8 @@ def test_vtk_snake_case_api_is_disabled(vtk_subclass):
         vtk_attr_camel_case = 'GetName'
         vtk_attr_snake_case = 'name'
         kwargs['array'] = pv.vtk_points(np.eye(3)).GetData()
+    elif issubclass(vtk_subclass, pv.plotting.render_window_interactor.InteractorStyleCaptureMixin):
+        kwargs['render_window_interactor'] = pv.Plotter().iren
 
     instance = try_init_object(vtk_subclass, kwargs)
 


Reviews:
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:06:39Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:09:26Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T20:11:17Z
user: login: user27182
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T20:24:02Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T21:30:47Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T22:33:08Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T15:53:15Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T15:39:38Z
user: login: user27182
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T16:28:46Z
user: login: user27182
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T21:24:19Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T22:10:15Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T22:12:36Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T19:40:55Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T13:35:25Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T13:37:54Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:04:11Z
user: login: user27182
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T21:27:25Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T23:14:22Z
user: login: user27182
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T23:28:16Z
user: login: user27182
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T05:39:59Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T13:36:18Z
user: login: user27182
body: I am not familiar with the interactor style API but these changes look good. Just a few comments.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T19:22:18Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T20:11:28Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T20:32:57Z
user: login: user27182
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T20:38:01Z
user: login: MatthewFlamm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T20:40:34Z
user: login: user27182
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T20:43:06Z
user: login: user27182
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T01:11:19Z

Review comments:
diff_hunk: @@ -433,26 +437,31 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
+        """Update the camera interactor style.
+
+        Called when setting interactor_style attribute.
+        """
         self.interactor.SetInteractorStyle(self._style_class)
 
     @property
-    def style(self):
+    def style(self) -> Optional[Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle]]:
         """Return the current interactor style.
 
         Returns
         -------
-        vtkInteractorStyle
+        vtkInteractorStyle or vtkContextInteractorStyle or None
             The current interactor style.
 
         """
-        if self._style_class is None:
-            self.update_style()
         return self._style_class
 
+    @style.setter
+    def style(self, style: _vtk.vtkInteractorStyle):
+        # TODO If a plain vtk class, wrap it in pyvista class???
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: Always wrapping with custom pyvista functionality might lower flexibility for users?  Additionally, context style interactor functionality currently uses the private `_style_class` attribute, but if this changes in the future to use this setter, we would need to not modify the style passed in here.
created_at: 2025-05-06T16:06:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1540,63 +1529,81 @@ def close(self):
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+class _CustomInteractorStyle:
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: The functionality of `CustomInteractorStyle` is refactored nearly directly from the old `CustomStyle` inside `_style_factory`
created_at: 2025-05-06T16:08:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1525,94 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+class _CustomInteractorStyle(_vtk.vtkInteractorStyle):
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: It is odd to me that the `AddObserver` call here and below has mypy errors indicating that the first argument should be an int.
created_at: 2025-05-06T20:11:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1525,94 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+class _CustomInteractorStyle(_vtk.vtkInteractorStyle):
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: Probably just need to ignore it. The [docs for AddObserver](https://vtk.org/doc/nightly/html/classvtkObject.html) indicate that event must be `unsigned long`. But clearly strings work... maybe the pythonic API adds 'string' overloads that aren't reflected in the auto-generated type hints?

There are a few other instances where I've encountered incorrect VTK type hints, not sure if it's worth reporting upstream...
created_at: 2025-05-06T20:24:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1525,94 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+class _CustomInteractorStyle(_vtk.vtkInteractorStyle):
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: The second overload has the first parameter as `char*`.  It seems likely to me that this is some sort of vtk python issue.  I'll ignore here with a comment.
created_at: 2025-05-06T21:30:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,26 +441,35 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting interactor_style attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
+    def style(
+        self,
+    ) -> Optional[
+        Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle, _CustomInteractorStyle]
+    ]:
         """Return the current interactor style.
 
         Returns
         -------
-        vtkInteractorStyle
+        vtkInteractorStyle or vtkContextInteractorStyle or None
             The current interactor style.
 
         """
-        if self._style_class is None:
-            self.update_style()
         return self._style_class
 
+    @style.setter
+    def style(self, style: Optional[Union[_vtk.vtkInteractorStyle, _CustomInteractorStyle]]):
+        # TODO If a plain vtk class, wrap it in pyvista class???
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
```
created_at: 2025-05-06T22:33:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,26 +441,58 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting interactor_style attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
-        """Return the current interactor style.
+    def style(
+        self,
+    ) -> Optional[
+        Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle, _CustomInteractorStyle]
+    ]:
+        """Get/set the current interactor style.
+
+        .. warning::
+
+            Setting an interactor style needs careful control of events handling.
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: We could also consider exposing `_CustomInteractorStyle` as public and put in the docs explicitly.  Users could then subclass from it to get the recommended PyVista functionality for interactor styles.  Looking for feedback on this.
created_at: 2025-05-07T15:53:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1551,96 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+class _CustomInteractorStyle(_vtk.vtkInteractorStyle):
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):
+        for obs in self._observers:
+            self.RemoveObserver(obs)
+
+
+class InteractorStyleImage(_CustomInteractorStyle, _vtk.vtkInteractorStyleImage):
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: I assume all of these also have to inherit from `DisableVtkSnakeCase`?
created_at: 2025-05-09T15:39:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1551,96 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+class _CustomInteractorStyle(_vtk.vtkInteractorStyle):
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):
+        for obs in self._observers:
+            self.RemoveObserver(obs)
+
+
+class InteractorStyleImage(_CustomInteractorStyle, _vtk.vtkInteractorStyleImage):
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: Probably. In theory the `test_snake_case_api` tests should be picking this up automatically to enforce this... might be an issue with the tests
created_at: 2025-05-09T16:28:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1551,96 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+class _CustomInteractorStyle(_vtk.vtkInteractorStyle):
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):
+        for obs in self._observers:
+            self.RemoveObserver(obs)
+
+
+class InteractorStyleImage(_CustomInteractorStyle, _vtk.vtkInteractorStyleImage):
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: Only classes found in these modules are subject to the test for conformance (inheritance) with disabling vtk's snake case API:
https://github.com/pyvista/pyvista/blob/54d8532a008ab7afee41d583f9fe3f0728c2ee81/tests/test_snake_case_api.py#L29-L30

Exposing `_CustomInteractorStyle` as a public class as suggested in https://github.com/pyvista/pyvista/pull/7497#discussion_r2077974809 would cause it to be subject to this test automatically.

Should we update the test to search for any/all classes defined everywhere in all `.py` files? Clearly, it's possible that there are classes defined elsewhere (either private or public) that are not covered by this test.
created_at: 2025-05-09T21:24:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1551,96 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+class _CustomInteractorStyle(_vtk.vtkInteractorStyle):
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):
+        for obs in self._observers:
+            self.RemoveObserver(obs)
+
+
+class InteractorStyleImage(_CustomInteractorStyle, _vtk.vtkInteractorStyleImage):
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: > Exposing `_CustomInteractorStyle` as a public class as suggested in [#7497 (comment)](https://github.com/pyvista/pyvista/pull/7497#discussion_r2077974809) would cause it to be subject to this test automatically.

Let's do this for this PR.  The style classes could be added to the documentation too.  I will mark as draft in the meantime as I likely won't get to it for a few days (or more)
created_at: 2025-05-09T22:10:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1551,96 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+class _CustomInteractorStyle(_vtk.vtkInteractorStyle):
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):
+        for obs in self._observers:
+            self.RemoveObserver(obs)
+
+
+class InteractorStyleImage(_CustomInteractorStyle, _vtk.vtkInteractorStyleImage):
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: > Should we update the test to search for any/all classes defined everywhere in all .py files? Clearly, it's possible that there are classes defined elsewhere (either private or public) that are not covered by this test.

I believe the intent of this class is to override all objects inherited from vtk, so it would make sense to me to test for all classes.
created_at: 2025-05-09T22:12:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,7 +35,17 @@ all plotting functionality in PyVista.
    CameraPosition
    CornerAnnotation
    CubeAxesActor
+   CustomInteractorStyle
path: doc/source/api/plotting/index.rst
user: login: MatthewFlamm
body: This doesn't work presumably because these aren't in the `pyvista` namespace.  I'm thinking we should use a separate heading here like `Interactor Style API` to include these.
created_at: 2025-05-12T19:40:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1551,96 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+class _CustomInteractorStyle(_vtk.vtkInteractorStyle):
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):
+        for obs in self._observers:
+            self.RemoveObserver(obs)
+
+
+class InteractorStyleImage(_CustomInteractorStyle, _vtk.vtkInteractorStyleImage):
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: I made these public, but not in the main pyvista namespace.  So #7509 was needed to check this in the end.  The use case for users using these classes is narrow.  The API documentation now suggests users to utilize the `enable_*_style` methods unless customizing the styles.
created_at: 2025-05-13T13:35:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,26 +442,61 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting interactor_style attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
-        """Return the current interactor style.
+    def style(
+        self,
+    ) -> Optional[
+        Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle, CaptureInteractorStyle]
+    ]:
+        """Get/set the current interactor style.
+
+        .. warning::
+
+            Setting an interactor style needs careful control of events handling.
+            See implementation of interactor styles in source code for more details.
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
            See :class:`CaptureInteractorStyle` and its implementation as an example.
```
created_at: 2025-05-13T13:37:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,26 +441,58 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting interactor_style attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
-        """Return the current interactor style.
+    def style(
+        self,
+    ) -> Optional[
+        Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle, _CustomInteractorStyle]
+    ]:
+        """Get/set the current interactor style.
+
+        .. warning::
+
+            Setting an interactor style needs careful control of events handling.
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: This is now public and explicitly referenced in this warning.
created_at: 2025-05-13T14:04:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_actor_style
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: When I search for this in the current docs https://dev.pyvista.org/search.html?q=enable_joystick, I see results for
[pyvista.Plotter.enable_joystick_actor_style](https://dev.pyvista.org/api/plotting/_autosummary/pyvista.Plotter.enable_joystick_actor_style.html#pyvista.Plotter.enable_joystick_actor_style) and [pyvista.RenderWindowInteractor.enable_joystick_actor_style](https://dev.pyvista.org/api/plotting/_autosummary/pyvista.RenderWindowInteractor.enable_joystick_actor_style.html#pyvista.RenderWindowInteractor.enable_joystick_actor_style), so I think the links need to be one of these, e.g.:
```suggestion
    pyvista.RenderWindowInteractor.enable_joystick_actor_style
```

or make it shorter to simply show it as "enable_joystick_actor_style" if desired:

```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_joystick_actor_style`
```
created_at: 2025-05-13T21:27:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    :class:`~pyvista.RenderWindowInteractor.enable_joystick_actor_style`
+
+    """
+
+
+class InteractorStyleJoystickCamera(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickCamera):
+    """Joystick camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_joystick_style`
```
created_at: 2025-05-13T23:05:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    :class:`~pyvista.RenderWindowInteractor.enable_joystick_actor_style`
+
+    """
+
+
+class InteractorStyleJoystickCamera(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickCamera):
+    """Joystick camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_style
+
+    """
+
+
+class InteractorStyleRubberBand2D(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBand2D):
+    """Rubber band 2D interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_2d_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_rubber_band_2d_style`
```
created_at: 2025-05-13T23:07:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_image_style`
```
created_at: 2025-05-13T23:08:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    :class:`~pyvista.RenderWindowInteractor.enable_joystick_actor_style`
+
+    """
+
+
+class InteractorStyleJoystickCamera(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickCamera):
+    """Joystick camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_style
+
+    """
+
+
+class InteractorStyleRubberBand2D(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBand2D):
+    """Rubber band 2D interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_2d_style
+
+    """
+
+
+class InteractorStyleRubberBandPick(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBandPick):
+    """Rubber band pick interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_rubber_band_style`
```
created_at: 2025-05-13T23:11:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    :class:`~pyvista.RenderWindowInteractor.enable_joystick_actor_style`
+
+    """
+
+
+class InteractorStyleJoystickCamera(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickCamera):
+    """Joystick camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_style
+
+    """
+
+
+class InteractorStyleRubberBand2D(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBand2D):
+    """Rubber band 2D interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_2d_style
+
+    """
+
+
+class InteractorStyleRubberBandPick(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBandPick):
+    """Rubber band pick interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_style
+
+    """
+
+
+class InteractorStyleTrackballActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballActor):
+    """Trackball actor interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_actor_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_trackball_actor_style`
```
created_at: 2025-05-13T23:12:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    :class:`~pyvista.RenderWindowInteractor.enable_joystick_actor_style`
+
+    """
+
+
+class InteractorStyleJoystickCamera(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickCamera):
+    """Joystick camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_style
+
+    """
+
+
+class InteractorStyleRubberBand2D(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBand2D):
+    """Rubber band 2D interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_2d_style
+
+    """
+
+
+class InteractorStyleRubberBandPick(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBandPick):
+    """Rubber band pick interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_style
+
+    """
+
+
+class InteractorStyleTrackballActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballActor):
+    """Trackball actor interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_actor_style
+
+    """
+
+
+class InteractorStyleTrackballCamera(
+    CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballCamera
+):
+    """Trackball camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_trackball_style`
```
created_at: 2025-05-13T23:13:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    :class:`~pyvista.RenderWindowInteractor.enable_joystick_actor_style`
+
+    """
+
+
+class InteractorStyleJoystickCamera(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickCamera):
+    """Joystick camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_style
+
+    """
+
+
+class InteractorStyleRubberBand2D(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBand2D):
+    """Rubber band 2D interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_2d_style
+
+    """
+
+
+class InteractorStyleRubberBandPick(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBandPick):
+    """Rubber band pick interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_style
+
+    """
+
+
+class InteractorStyleTrackballActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballActor):
+    """Trackball actor interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_actor_style
+
+    """
+
+
+class InteractorStyleTrackballCamera(
+    CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballCamera
+):
+    """Trackball camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_style
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_custom_trackball_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_custom_trackball_style`
```
created_at: 2025-05-13T23:13:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    :class:`~pyvista.RenderWindowInteractor.enable_joystick_actor_style`
+
+    """
+
+
+class InteractorStyleJoystickCamera(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickCamera):
+    """Joystick camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_style
+
+    """
+
+
+class InteractorStyleRubberBand2D(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBand2D):
+    """Rubber band 2D interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_2d_style
+
+    """
+
+
+class InteractorStyleRubberBandPick(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBandPick):
+    """Rubber band pick interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_style
+
+    """
+
+
+class InteractorStyleTrackballActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballActor):
+    """Trackball actor interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_actor_style
+
+    """
+
+
+class InteractorStyleTrackballCamera(
+    CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballCamera
+):
+    """Trackball camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_style
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_custom_trackball_style
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_2d_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_2d_style`
```
created_at: 2025-05-13T23:13:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    :class:`~pyvista.RenderWindowInteractor.enable_joystick_actor_style`
+
+    """
+
+
+class InteractorStyleJoystickCamera(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickCamera):
+    """Joystick camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_style
+
+    """
+
+
+class InteractorStyleRubberBand2D(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBand2D):
+    """Rubber band 2D interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_2d_style
+
+    """
+
+
+class InteractorStyleRubberBandPick(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBandPick):
+    """Rubber band pick interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_style
+
+    """
+
+
+class InteractorStyleTrackballActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballActor):
+    """Trackball actor interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_actor_style
+
+    """
+
+
+class InteractorStyleTrackballCamera(
+    CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballCamera
+):
+    """Trackball camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_style
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_custom_trackball_style
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_2d_style
+
+    """
+
+
+class InteractorStyleTerrain(CaptureInteractorStyle, _vtk.vtkInteractorStyleTerrain):
+    """Terrain interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_terrain_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_terrain_style`
```
created_at: 2025-05-13T23:13:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    :class:`~pyvista.RenderWindowInteractor.enable_joystick_actor_style`
+
+    """
+
+
+class InteractorStyleJoystickCamera(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickCamera):
+    """Joystick camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_style
+
+    """
+
+
+class InteractorStyleRubberBand2D(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBand2D):
+    """Rubber band 2D interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_2d_style
+
+    """
+
+
+class InteractorStyleRubberBandPick(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBandPick):
+    """Rubber band pick interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_rubber_band_style
+
+    """
+
+
+class InteractorStyleTrackballActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballActor):
+    """Trackball actor interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_actor_style
+
+    """
+
+
+class InteractorStyleTrackballCamera(
+    CaptureInteractorStyle, _vtk.vtkInteractorStyleTrackballCamera
+):
+    """Trackball camera interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_trackball_style
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_custom_trackball_style
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_2d_style
+
+    """
+
+
+class InteractorStyleTerrain(CaptureInteractorStyle, _vtk.vtkInteractorStyleTerrain):
+    """Terrain interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_terrain_style
+
+    """
+
+
+class InteractorStyleZoom(CaptureInteractorStyle, _vtk.vtkInteractorStyleRubberBandZoom):
+    """Rubber band zoom interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_zoom_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: ```suggestion
    :class:`~pyvista.RenderWindowInteractor.enable_zoom_style`
```
created_at: 2025-05-13T23:14:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_actor_style
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: Whoops, I think :meth: is needed instead of :class: ... 
created_at: 2025-05-13T23:28:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,26 +443,61 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting interactor_style attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
-        """Return the current interactor style.
+    def style(
+        self,
+    ) -> Optional[
+        Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle, CaptureInteractorStyle]
+    ]:
+        """Get/set the current interactor style.
+
+        .. warning::
+
+            Setting an interactor style needs careful control of events handling.
+            See :class:`CaptureInteractorStyle` and its implementation as an example.
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: ```suggestion
            See :class:`~plotting.render_window_interactor.CaptureInteractorStyle` and its implementation as an example.
```
created_at: 2025-05-14T05:39:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1528,75 +1553,176 @@ def close(self):
         This will terminate the render window if it is not already closed.
         """
         self.remove_observers()
-        if self._style_class == self._context_style:  # pragma: no cover
+        if self.style == self._context_style:  # pragma: no cover
             self._set_context_style(None)  # Disable context interactor style first
-        if self._style_class is not None:
-            self._style_class.remove_observers()
-            self._style_class = None
+        if self.style is not None:
+            if hasattr(self.style, 'remove_observers'):
+                self.style.remove_observers()
+            self.style = None
 
         self.terminate_app()
         self.interactor = None
         self._click_event_callbacks = None  # type: ignore[assignment]
         self._timer_event = None
 
 
-def _style_factory(klass):
-    """Create a subclass with capturing ability, return it."""
-    # We have to use a custom subclass for this because the default ones
-    # swallow the release events
-    # http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+@abstract_class
+class CaptureInteractorStyle(DisableVtkSnakeCase, _vtk.vtkInteractorStyle):
+    """A mixin for subclasses of vtkInteractorStyle with capturing ability.
 
-    def _make_class(klass):
-        """Make the class."""
-        try:
-            from vtkmodules import vtkInteractionStyle
-        except ImportError:  # pragma: no cover
-            import vtk as vtkInteractionStyle  # type: ignore[no-redef]
-
-        class CustomStyle(getattr(vtkInteractionStyle, 'vtkInteractorStyle' + klass)):  # type: ignore[misc]
-            def __init__(self, parent):
-                super().__init__()
-                self._parent = weakref.ref(parent)
-
-                self._observers = []
-                self._observers.append(
-                    self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),
-                )
-                self._observers.append(
-                    self.AddObserver(
-                        'LeftButtonReleaseEvent',
-                        partial(try_callback, self._release),
-                    ),
-                )
+    Use a custom capturing events because the default ones
+    swallow the release events
+    http://vtk.1045678.n5.nabble.com/Mouse-button-release-event-is-still-broken-in-VTK-6-0-0-td5724762.html
+
+    """
+
+    def __init__(self, render_window_interactor: RenderWindowInteractor):
+        super().__init__()
+        self._parent = weakref.ref(render_window_interactor)
+
+        # An unknown problem with AddObserver not typed to include string despite overload.
+        # Ignore typing.
+        self._observers = []
+        self._observers.append(
+            self.AddObserver('LeftButtonPressEvent', partial(try_callback, self._press)),  # type: ignore[arg-type]
+        )
+        self._observers.append(
+            self.AddObserver(
+                'LeftButtonReleaseEvent',  # type: ignore[arg-type]
+                partial(try_callback, self._release),
+            ),
+        )
+
+    def _press(self, *args):
+        # Figure out which renderer has the event and disable the
+        # others
+        self.OnLeftButtonDown()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            click_pos = parent.get_event_position()  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                interact = renderer.IsInViewport(*click_pos)
+                renderer.SetInteractive(interact)
+
+    def _release(self, *args):
+        self.OnLeftButtonUp()
+        parent = self._parent()
+        if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
+            for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
+                renderer.SetInteractive(True)
+
+    def add_observer(self, event, callback):
+        """Keep track of observers.
+
+        Parameters
+        ----------
+        event : str
+            VTK event string
+        callback : callable
+            Function to call during callback
+
+        """
+        self._observers.append(self.AddObserver(event, callback))
+
+    def remove_observers(self):  # numpydoc ignore=SS06
+        """Remove all observers added through
+        :func:`~pyvista.plotting.render_window_interactor.CaptureInteractorStyle.add_observer`.
+        """  # noqa : D205
+        for obs in self._observers:
+            self.RemoveObserver(obs)
 
-            def _press(self, *args):
-                # Figure out which renderer has the event and disable the
-                # others
-                super().OnLeftButtonDown()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    click_pos = parent.get_event_position()  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        interact = renderer.IsInViewport(*click_pos)
-                        renderer.SetInteractive(interact)
-
-            def _release(self, *args):
-                super().OnLeftButtonUp()
-                parent = self._parent()
-                if len(parent._plotter.renderers) > 1:  # type: ignore[union-attr]
-                    for renderer in parent._plotter.renderers:  # type: ignore[union-attr]
-                        renderer.SetInteractive(True)
-
-            def add_observer(self, event, callback):
-                self._observers.append(self.AddObserver(event, callback))
-
-            def remove_observers(self):
-                for obs in self._observers:
-                    self.RemoveObserver(obs)
-
-        return CustomStyle
-
-    # cache classes
-    if klass not in _CLASSES:
-        _CLASSES[klass] = _make_class(klass)
-    return _CLASSES[klass]
+
+# All interactor styles here inherit from `CaptureInteractorStyle`, which
+# inherits from `DisableVtkSnakeCase`, so don't duplicate again.
+class InteractorStyleImage(CaptureInteractorStyle, _vtk.vtkInteractorStyleImage):
+    """Image interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_image_style
+
+    """
+
+
+class InteractorStyleJoystickActor(CaptureInteractorStyle, _vtk.vtkInteractorStyleJoystickActor):
+    """Joystick actor interactor style.
+
+    See Also
+    --------
+    pyvista.plotting.render_window_interactor.RenderWindowInteractor.enable_joystick_actor_style
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: I switched to using :meth:. I also got rid of `~` now that the resolving path is shorter.
created_at: 2025-05-14T13:36:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,8 +111,9 @@ def __init__(self, plotter, desired_update_rate=30, light_follow_camera=True, in
         self._MAX_CLICK_DELTA = 40  # squared => ~6 pixels
 
         # Set default style
-        self._style = 'RubberBandPick'
-        self._style_class = None
+        self._style_class: Optional[_vtk.vtkInteractorStyle] = None
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: Use PEP 604 syntax
```suggestion
        self._style_class: _vtk.vtkInteractorStyle | None = None
```
created_at: 2025-05-21T19:13:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,8 +111,9 @@ def __init__(self, plotter, desired_update_rate=30, light_follow_camera=True, in
         self._MAX_CLICK_DELTA = 40  # squared => ~6 pixels
 
         # Set default style
-        self._style = 'RubberBandPick'
-        self._style_class = None
+        self._style_class: Optional[_vtk.vtkInteractorStyle] = None
+        self._style: Literal['Interactor', 'Context'] = 'Interactor'
+        self.style = InteractorStyleRubberBandPick(self)
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: I am not familiar with the old API. Was it possible to use a string to set this before? E.g. `'RubberBandPick'` ? Should we allow doing `self.style = 'RubberBandPick'` ?
created_at: 2025-05-21T19:15:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,26 +443,58 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting :meth:`style` attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
-        """Return the current interactor style.
+    def style(
+        self,
+    ) -> Optional[
+        Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle, CaptureInteractorStyle]
+    ]:
+        """Get/set the current interactor style.
+
+        .. warning::
+
+            Setting an interactor style needs careful control of events handling.
+            See :class:`~plotting.render_window_interactor.CaptureInteractorStyle` and its implementation as an example.
 
         Returns
         -------
-        :vtk:`vtkInteractorStyle`
+        :vtk:`vtkInteractorStyle` | :vtk:`vtkContextInteractorStyle` | None
             The current interactor style.
 
+        Examples
+        --------
+        Set interactor style with a customized vtk interactor
+
+        >>> import pyvista as pv
+        >>> from vtkmodules.vtkInteractionStyle import (
+        ...     vtkInteractorStyleTrackballCamera,
+        ... )
+
+        >>> class MyCustomInteractorStyle(vtkInteractorStyleTrackballCamera):
+        ...     # Implement custom functionality
+        ...     def __repr__(self):
+        ...         return 'A custom interactor style.'
+
+        >>> plotter = pv.Plotter()
+        >>> plotter.iren.style = MyCustomInteractorStyle()
+        >>> plotter.iren.style
+        A custom interactor style.
+
         """
-        if self._style_class is None:
-            self.update_style()
         return self._style_class
 
+    @style.setter
+    def style(self, style: Optional[Union[_vtk.vtkInteractorStyle, CaptureInteractorStyle]]):
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: ```suggestion
    def style(self, style: _vtk.vtkInteractorStyle | CaptureInteractorStyle | None):
```
created_at: 2025-05-21T19:16:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,26 +443,58 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting :meth:`style` attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
-        """Return the current interactor style.
+    def style(
+        self,
+    ) -> Optional[
+        Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle, CaptureInteractorStyle]
+    ]:
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: ```suggestion
    ) -> _vtk.vtkContextInteractorStyle | _vtk.vtkInteractorStyle | CaptureInteractorStyle | None:
```
created_at: 2025-05-21T19:17:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,26 +443,58 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting :meth:`style` attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
-        """Return the current interactor style.
+    def style(
+        self,
+    ) -> Optional[
+        Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle, CaptureInteractorStyle]
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: I see the VTK classes have a convention where
- abstract classes use the convention `<name>InteractorStyle`
- concrete classes use the convention `InteractorStyle<name>`

And it looks like this convention is followed for PyVista classes. I find the reversal of names a bit odd... should we adopt a single, unified convention for these names?
created_at: 2025-05-21T19:19:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,8 +111,9 @@ def __init__(self, plotter, desired_update_rate=30, light_follow_camera=True, in
         self._MAX_CLICK_DELTA = 40  # squared => ~6 pixels
 
         # Set default style
-        self._style = 'RubberBandPick'
-        self._style_class = None
+        self._style_class: Optional[_vtk.vtkInteractorStyle] = None
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: I'll commit these changes but will likely need to clean up things like imports, but maybe ruff can autocorrect it
created_at: 2025-05-21T20:11:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,8 +111,9 @@ def __init__(self, plotter, desired_update_rate=30, light_follow_camera=True, in
         self._MAX_CLICK_DELTA = 40  # squared => ~6 pixels
 
         # Set default style
-        self._style = 'RubberBandPick'
-        self._style_class = None
+        self._style_class: Optional[_vtk.vtkInteractorStyle] = None
+        self._style: Literal['Interactor', 'Context'] = 'Interactor'
+        self.style = InteractorStyleRubberBandPick(self)
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: It was only possible if the user used private attributes and knew the class naming structure in vtk AND knew to call `update_style` method.  `self._style` previously held the name of the variable part of the vtk class, e.g. `RubberBandPick` for `vtkInteractorStyleRubbetBandPick`.  So this was not part of public API.

I tried to keep the changes limited to the user selected styles.  `self._style` is no longer needed for the user selected styles, so it is now just labeled 'Interactor' in this case. Maybe a better name should be used?  But it is a private attribute after all.  It is still used for keeping track of when the context interactor style is used for chart interaction.  This could be a future PR to simplify this. 

I think setting via string could be handled with #7359.  This PR would enable this in easier manner.
created_at: 2025-05-21T20:32:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,8 +111,9 @@ def __init__(self, plotter, desired_update_rate=30, light_follow_camera=True, in
         self._MAX_CLICK_DELTA = 40  # squared => ~6 pixels
 
         # Set default style
-        self._style = 'RubberBandPick'
-        self._style_class = None
+        self._style_class: Optional[_vtk.vtkInteractorStyle] = None
+        self._style: Literal['Interactor', 'Context'] = 'Interactor'
+        self.style = InteractorStyleRubberBandPick(self)
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: Ok thanks for the info, it's fine as-is
created_at: 2025-05-21T20:38:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,26 +443,58 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting :meth:`style` attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
-        """Return the current interactor style.
+    def style(
+        self,
+    ) -> Optional[
+        Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle, CaptureInteractorStyle]
path: pyvista/plotting/render_window_interactor.py
user: login: MatthewFlamm
body: What about being explicit like `InteractorStyleCaptureMixin`?
created_at: 2025-05-21T20:40:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,26 +443,58 @@ def key_press_event(self, *args):
                 func()
 
     def update_style(self):
-        """Update the camera interactor style."""
-        if self._style_class is None:
-            # We need an actually custom style to handle button up events
-            self._style_class = _style_factory(self._style)(self)
-        self.interactor.SetInteractorStyle(self._style_class)
+        """Update the camera interactor style.
+
+        Called when setting :meth:`style` attribute.
+        """
+        self.interactor.SetInteractorStyle(self.style)
 
     @property
-    def style(self):
-        """Return the current interactor style.
+    def style(
+        self,
+    ) -> Optional[
+        Union[_vtk.vtkContextInteractorStyle, _vtk.vtkInteractorStyle, CaptureInteractorStyle]
path: pyvista/plotting/render_window_interactor.py
user: login: user27182
body: I like explicit, It was otherwise unclear which ones were concrete classes that I might want to try to use/ set the interactor to
created_at: 2025-05-21T20:43:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: pyvista-bot
created_at: 2025-05-06T18:00:57Z
body: <!-- NETLIFY DEPLOY COMMENT GENERATED BY ACTIONS_NETLIFY - APP ID SHA256: d3574f413fe51079937885070831e027ca5b10f7facb9e609494e0a481a31f79 -->
🚀 Deployed on https://682f39bb26dd3e0484364fea--pyvista-dev.netlify.app
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-06T20:36:58Z
body: ## [Codecov](https://app.codecov.io/gh/pyvista/pyvista/pull/7497?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyvista) Report
Attention: Patch coverage is `98.80952%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 96.22%. Comparing base [(`26f98ab`)](https://app.codecov.io/gh/pyvista/pyvista/commit/26f98ab9156c93dc492d05697b2e6bc869bad2e0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyvista) to head [(`733028a`)](https://app.codecov.io/gh/pyvista/pyvista/commit/733028a96617ec8a2d4601e48978692682f7017b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyvista).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #7497   +/-   ##
=======================================
  Coverage   96.21%   96.22%           
=======================================
  Files         149      149           
  Lines       30742    30739    -3     
  Branches     4030     4028    -2     
=======================================
- Hits        29579    29578    -1     
+ Misses        554      553    -1     
+ Partials      609      608    -1     
```

</details>
author_association: NONE
user: login: MatthewFlamm
created_at: 2025-05-07T15:48:10Z
body: I live tested all the interactor styles using the below code.  I am not familiar with all the interactor styles, but it seemed like they all worked as intended.  I want to do a few more live tests as in the description, but this is ready for review now.

```py
import pyvista as pv
pl = pv.Plotter()
styles = [attr for attr in dir(pl) if attr.startswith('enable_') and attr.endswith('_style')]
for style in styles:
    pl = pv.Plotter()
    pl.add_mesh(pv.Cone(center=(0, 0, 0)), name='Cone')
    pl.add_mesh(pv.Cube(center=(1, 0, 0)), name='Cube')
    pl.add_mesh(pv.Sphere(center=(1, 1, 0)), name='Sphere')
    pl.add_mesh(pv.Cylinder(center=(0, 1, 0)), name='Cylinder')
    pl.add_text(style)
    getattr(pl, style)()
    pl.show()
```
author_association: CONTRIBUTOR
user: login: MatthewFlamm
created_at: 2025-05-07T19:30:37Z
body: Tested chart interaction and scene interaction interoperability using this example https://docs.pyvista.org/examples/02-plot/chart_overlays

AffineWidget3D also seems to work as the code is written, but I uncovered a bug in testing it (even on the main branch).  This is in https://github.com/pyvista/pyvista/issues/7500.




author_association: CONTRIBUTOR
user: login: user27182
created_at: 2025-05-08T03:15:53Z
body: A blank image is (falsely) generated by the plot directive for the `style` docstring... this is the same as issue #6320  
author_association: CONTRIBUTOR
user: login: MatthewFlamm
created_at: 2025-05-08T16:41:49Z
body: Using an explicit `.. pyvista-plot::` directive with `:nofigs:` option was successful.  Docs for `style` look good to me.  If #7502 is successful and merged, we can revert https://github.com/pyvista/pyvista/pull/7497/commits/89f6ed8f20e3b4bd1db1ed113ac2b06a617a847b.
author_association: CONTRIBUTOR
user: login: MatthewFlamm
created_at: 2025-05-13T14:14:36Z
body: We should wait for #7502 to merge this, if it looks good, to simplify the docstring here.  Only the invocation of `pyvista-plot` will change, so this PR is ready for review again.
author_association: CONTRIBUTOR
user: login: MatthewFlamm
created_at: 2025-05-15T13:12:22Z
body: The docs look good to me now with proper linking.  Thanks for the help.  The only thing that looks off is the Returns section of style.

![image](https://github.com/user-attachments/assets/f813d5b1-d874-4527-bac1-faeb00b4a7e9)

I notice this happens in several places in the documentation, e.g.:
https://docs.pyvista.org/api/core/helpers#pyvista.core.utilities.arrays.convert_array

What is the best style here?
author_association: CONTRIBUTOR
user: login: user27182
created_at: 2025-05-16T17:50:57Z
body: > The docs look good to me now with proper linking. Thanks for the help. The only thing that looks off is the Returns section of style.
> 
> ![image](https://private-user-images.githubusercontent.com/39341281/444126121-f813d5b1-d874-4527-bac1-faeb00b4a7e9.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDc0MTc3MjQsIm5iZiI6MTc0NzQxNzQyNCwicGF0aCI6Ii8zOTM0MTI4MS80NDQxMjYxMjEtZjgxM2Q1YjEtZDg3NC00NTI3LWJhYzEtZmFlYjAwYjRhN2U5LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA1MTYlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNTE2VDE3NDM0NFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTY3NjMxMWI0MmMwM2JmOWRmMDlkNDlmZmRmMWNiNmJiNzEzYzQ2YTE4ZDQzOTNlYjY5MjIyYmFjNTllYTE1YzcmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.AFygqdsjtZ_vnhLuOdOLyud2PhJiI7Lgk2HOI_RpYh4)
> 
> I notice this happens in several places in the documentation, e.g.: https://docs.pyvista.org/api/core/helpers#pyvista.core.utilities.arrays.convert_array
> 
> What is the best style here?

Looks like sphinx removes the whitespace? Might be better to use `|` instead, like https://docs.pyvista.org/api/core/_autosummary/pyvista.dataobjectfilters.clip#pyvista.DataObjectFilters.clip
author_association: CONTRIBUTOR
user: login: MatthewFlamm
created_at: 2025-05-21T20:52:27Z
body: pre-commit.ci autofix
author_association: CONTRIBUTOR
user: login: MatthewFlamm
created_at: 2025-05-21T21:11:43Z
body: Also put in links to the vtk classes for the interactor styles for each wrapped class.
author_association: CONTRIBUTOR