Repository: napari/napari
Pull request #7686: Expose new camera orientation API in GUI in ndisplay popup widget
Author: login: TimMonko
Labels: name: feature
description: New feature or request
name: tests
description: Something related to our tests
name: qt
description: Relates to qt
name: ui change
Created: 2025-03-10T14:10:11Z | Merged: 2025-03-14T23:50:14Z

# References and relevant issues

Addresses part of #7683. Follow-up to #7663

# Description

I wanted to see how quickly I could mock up and expose the new `viewer.camera.orientation` API. This is what I have for now, a hopeful attempt to see if its a desirable solution to #7683.

1. adds widget to camera controls popup to expose `viewer.camera.orientation` API in both 2D and 3D
2. adds callback between widget values and viewer.camera.orientation, relying on the enum typing 
3. adds tests for the new popup controls

![image](https://github.com/user-attachments/assets/7f75d1bb-d6c6-45a0-9359-3dbae2abbf44)


Diff:
diff --git a/napari/_qt/widgets/_tests/test_qt_viewer_buttons.py b/napari/_qt/widgets/_tests/test_qt_viewer_buttons.py
index ccd53dde2a0..29e56f282b7 100644
--- a/napari/_qt/widgets/_tests/test_qt_viewer_buttons.py
+++ b/napari/_qt/widgets/_tests/test_qt_viewer_buttons.py
@@ -7,6 +7,11 @@
 from napari._app_model._app import get_app_model
 from napari._qt.dialogs.qt_modal import QtPopup
 from napari._qt.widgets.qt_viewer_buttons import QtViewerButtons
+from napari.components.camera import (
+    DepthAxisOrientation,
+    HorizontalAxisOrientation,
+    VerticalAxisOrientation,
+)
 from napari.components.viewer_model import ViewerModel
 from napari.viewer import Viewer
 
@@ -112,6 +117,26 @@ def test_ndisplay_button_popup(qt_viewer_buttons, qtbot):
             perspective_popup = widget
     assert perspective_popup
 
+    # check camera orientation combo changes affect viewer camera orientation
+    assert viewer_buttons.vertical_combo
+    assert (
+        viewer_buttons.vertical_combo.currentEnum()
+        == viewer.camera.orientation[1]
+    )
+    assert viewer_buttons.horizontal_combo
+    assert (
+        viewer_buttons.horizontal_combo.currentEnum()
+        == viewer.camera.orientation[2]
+    )
+    viewer_buttons.vertical_combo.setCurrentEnum(VerticalAxisOrientation.UP)
+    viewer_buttons.horizontal_combo.setCurrentEnum(
+        HorizontalAxisOrientation.LEFT
+    )
+    assert viewer.camera.orientation[1].value == 'up'
+    assert viewer.camera.orientation[2].value == 'left'
+    assert viewer.camera.orientation2d[0].value == 'up'
+    assert viewer.camera.orientation2d[1].value == 'left'
+
     # check zoom slider change affects viewer camera zoom
     assert viewer_buttons.zoom
     viewer_buttons.zoom.setValue(3)
@@ -128,6 +153,41 @@ def test_ndisplay_button_popup(qt_viewer_buttons, qtbot):
             perspective_popup = widget
     assert perspective_popup
 
+    # check camera orientation combo changes affect viewer camera orientation
+    assert viewer_buttons.depth_combo
+    assert (
+        viewer_buttons.depth_combo.currentEnum()
+        == viewer.camera.orientation[0]
+    )
+    assert viewer_buttons.vertical_combo
+    assert (
+        viewer_buttons.vertical_combo.currentEnum()
+        == viewer.camera.orientation[1]
+    )
+    assert viewer_buttons.horizontal_combo
+    assert (
+        viewer_buttons.horizontal_combo.currentEnum()
+        == viewer.camera.orientation[2]
+    )
+    # check the values set by testing 2D orientation popup are inherited
+    assert (
+        viewer_buttons.vertical_combo.currentEnum()
+        == VerticalAxisOrientation.UP
+    )
+    assert (
+        viewer_buttons.horizontal_combo.currentEnum()
+        == HorizontalAxisOrientation.LEFT
+    )
+
+    viewer_buttons.depth_combo.setCurrentEnum(DepthAxisOrientation.AWAY)
+    viewer_buttons.vertical_combo.setCurrentEnum(VerticalAxisOrientation.DOWN)
+    viewer_buttons.horizontal_combo.setCurrentEnum(
+        HorizontalAxisOrientation.RIGHT
+    )
+    assert viewer.camera.orientation[0].value == 'away'
+    assert viewer.camera.orientation[1].value == 'down'
+    assert viewer.camera.orientation[2].value == 'right'
+
     # check perspective slider change affects viewer camera perspective
     assert viewer_buttons.perspective
     viewer_buttons.perspective.setValue(5)
diff --git a/napari/_qt/widgets/qt_viewer_buttons.py b/napari/_qt/widgets/qt_viewer_buttons.py
index 10d557a12ae..f57439d1a8b 100644
--- a/napari/_qt/widgets/qt_viewer_buttons.py
+++ b/napari/_qt/widgets/qt_viewer_buttons.py
@@ -1,4 +1,5 @@
 import warnings
+from enum import Enum, EnumMeta
 from functools import partial, wraps
 from typing import TYPE_CHECKING
 
@@ -12,19 +13,29 @@
     QLabel,
     QPushButton,
     QVBoxLayout,
+    QWidget,
 )
-from superqt import QLabeledDoubleSlider
+from superqt import QEnumComboBox, QLabeledDoubleSlider
 
 from napari._qt.dialogs.qt_modal import QtPopup
 from napari._qt.widgets.qt_dims_sorter import QtDimsSorter
 from napari._qt.widgets.qt_spinbox import QtSpinBox
 from napari._qt.widgets.qt_tooltip import QtToolTipLabel
+from napari.components.camera import (
+    DepthAxisOrientation,
+    DepthAxisOrientationStr,
+    HorizontalAxisOrientation,
+    HorizontalAxisOrientationStr,
+    VerticalAxisOrientation,
+    VerticalAxisOrientationStr,
+)
 from napari.utils.action_manager import action_manager
 from napari.utils.misc import in_ipython, in_jupyter, in_python_repl
 from napari.utils.translations import trans
 
 if TYPE_CHECKING:
     from collections.abc import Callable
+    from typing import Any
 
     from napari.viewer import ViewerModel
 
@@ -118,6 +129,20 @@ def labeled_double_slider(
     return slider
 
 
+def enum_combobox(
+    *,
+    parent: QtPopup,
+    enum_class: EnumMeta,
+    current_enum: Enum,
+    callback: 'Callable[[],Any] | Callable[[Enum],Any]',
+) -> QEnumComboBox:
+    """Create an enum combobox widget."""
+    combo = QEnumComboBox(parent, enum_class=enum_class)
+    combo.setCurrentEnum(current_enum)
+    combo.currentEnumChanged.connect(callback)
+    return combo
+
+
 def help_tooltip(
     *,
     parent: QtPopup,
@@ -302,11 +327,11 @@ def _add_3d_camera_controls(
         )
 
         form_layout.insertRow(
-            1, QLabel(trans._('Perspective:')), self.perspective
+            2, QLabel(trans._('Perspective:')), self.perspective
         )
-        form_layout.insertRow(2, QLabel(trans._('Angles    X:')), self.rx)
-        form_layout.insertRow(3, QLabel(trans._('             Y:')), self.ry)
-        form_layout.insertRow(4, QLabel(trans._('             Z:')), self.rz)
+        form_layout.insertRow(3, QLabel(trans._('Angles    X:')), self.rx)
+        form_layout.insertRow(4, QLabel(trans._('             Y:')), self.ry)
+        form_layout.insertRow(5, QLabel(trans._('             Z:')), self.rz)
 
         help_layout.addWidget(perspective_help_symbol)
         help_layout.addWidget(angle_help_symbol)
@@ -332,15 +357,79 @@ def _add_shared_camera_controls(
             text='Controls zoom level of the camera. Larger values zoom in, smaller values zoom out.',
         )
 
-        form_layout.insertRow(0, QLabel(trans._('Zoom:')), self.zoom)
+        form_layout.insertRow(1, QLabel(trans._('Zoom:')), self.zoom)
         help_layout.addWidget(zoom_help_symbol)
 
+    def _add_orientation_controls(
+        self,
+        popup: QtPopup,
+        form_layout: QFormLayout,
+        help_layout: QVBoxLayout,
+    ) -> None:
+        """Add orientation controls to the popup."""
+        orientation_layout = QHBoxLayout()
+        orientation_layout.setContentsMargins(0, 0, 0, 0)
+        orientation_widget = QWidget(popup)
+
+        self.vertical_combo = enum_combobox(
+            parent=popup,
+            enum_class=VerticalAxisOrientation,
+            current_enum=self.viewer.camera.orientation[1],
+            callback=partial(
+                self._update_orientation, VerticalAxisOrientation
+            ),
+        )
+
+        self.horizontal_combo = enum_combobox(
+            parent=popup,
+            enum_class=HorizontalAxisOrientation,
+            current_enum=self.viewer.camera.orientation[2],
+            callback=partial(
+                self._update_orientation, HorizontalAxisOrientation
+            ),
+        )
+
+        if self.viewer.dims.ndisplay == 2:
+            orientation_layout.addWidget(self.vertical_combo)
+            orientation_layout.addWidget(self.horizontal_combo)
+            orientation_help_symbol = help_tooltip(
+                parent=popup,
+                text='Controls the orientation of the vertical and horizontal camera axes.',
+            )
+
+        else:
+            self.depth_combo = enum_combobox(
+                parent=popup,
+                enum_class=DepthAxisOrientation,
+                current_enum=self.viewer.camera.orientation[0],
+                callback=partial(
+                    self._update_orientation, DepthAxisOrientation
+                ),
+            )
+
+            orientation_layout.addWidget(self.depth_combo)
+            orientation_layout.addWidget(self.vertical_combo)
+            orientation_layout.addWidget(self.horizontal_combo)
+            orientation_help_symbol = help_tooltip(
+                parent=popup,
+                text='Controls the orientation of the depth, vertical, and horizontal camera axes.',
+            )
+
+        orientation_widget.setLayout(orientation_layout)
+
+        form_layout.insertRow(
+            0, QLabel(trans._('Orientation:')), orientation_widget
+        )
+        help_layout.addWidget(orientation_help_symbol)
+
     def open_ndisplay_camera_popup(self) -> None:
         """Show controls for camera settings based on ndisplay mode."""
         popup = QtPopup(self)
         form_layout = QFormLayout()
         help_layout = QVBoxLayout()
 
+        self._add_orientation_controls(popup, form_layout, help_layout)
+
         self._add_shared_camera_controls(popup, form_layout, help_layout)
 
         if self.viewer.dims.ndisplay == 3:
@@ -354,6 +443,38 @@ def open_ndisplay_camera_popup(self) -> None:
         # Reposition popup, must be done after all widgets are added
         self._position_popup_inside_viewer(popup, self.ndisplayButton)
 
+    def _update_orientation(
+        self,
+        orientation_type: type[
+            DepthAxisOrientation
+            | VerticalAxisOrientation
+            | HorizontalAxisOrientation
+        ],
+        orientation_value: (
+            DepthAxisOrientationStr
+            | VerticalAxisOrientationStr
+            | HorizontalAxisOrientationStr
+        ),
+    ) -> None:
+        """Update the orientation of the camera.
+
+        Parameters
+        ----------
+        orientation_type : type[DepthAxisOrientation | VerticalAxisOrientation | HorizontalAxisOrientation]
+            The orientation type (which implies the position/axis) to update.
+        value : DepthAxisOrientationStr | VerticalAxisOrientationStr | HorizontalAxisOrientationStr
+            New orientation value for the updated axis.
+        """
+        axes = (
+            DepthAxisOrientation,
+            VerticalAxisOrientation,
+            HorizontalAxisOrientation,
+        )
+        axis_to_update = axes.index(orientation_type)
+        new_orientation = list(self.viewer.camera.orientation)
+        new_orientation[axis_to_update] = orientation_type(orientation_value)
+        self.viewer.camera.orientation = tuple(new_orientation)
+
     def _update_camera_angles(self, idx: int, value: float) -> None:
         """Update the camera angles.
 


Reviews:
user: login: jni
body: Brilliant PR @TimMonko! I'm impressed you added tests. I am typically so lazy about Qt tests and @Czaki has to remind me to add them. 😂 🙏 So, huge thanks for making our job easy!

I've suggested:

- minor updates to the info wording
- minor formatting updates (actually pushed those)
- minor refactor of the `_update_orientation` method — let me know what you think, it's optional, I mildly prefer it but I can be convinced that your code is actually easier to follow.
- **major**: an update to the typing of `orientation_enum` to `Literal[DepthAxisOrientation | VerticalAxisOrientation | HorizontalAxisOrientation]`. I *think* that's actually the correct type, but it would be good if @Czaki could verify.

I'm marking this as request changes so that the major comment gets resolved before merge, but otherwise it's basically ready. I tried it out and I absolutely *love* that it updates based on whether the 2D or 3D camera is active. Great stuff.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-11T09:24:40Z
user: login: TimMonko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T13:30:21Z
user: login: jni
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-11T13:39:35Z
user: login: TimMonko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T13:50:36Z
user: login: Czaki
body: update typing 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T22:57:51Z

Review comments:
diff_hunk: @@ -332,15 +356,79 @@ def _add_shared_camera_controls(
             text='Controls zoom level of the camera. Larger values zoom in, smaller values zoom out.',
         )
 
-        form_layout.insertRow(0, QLabel(trans._('Zoom:')), self.zoom)
+        form_layout.insertRow(1, QLabel(trans._('Zoom:')), self.zoom)
         help_layout.addWidget(zoom_help_symbol)
 
+    def _add_orientation_controls(
+        self,
+        popup: QtPopup,
+        form_layout: QFormLayout,
+        help_layout: QVBoxLayout,
+    ) -> None:
+        """Add orientation controls to the popup."""
+        orientation_layout = QHBoxLayout()
+        orientation_layout.setContentsMargins(0, 0, 0, 0)
+        orientation_widget = QWidget(popup)
+
+        self.vertical_combo = enum_combobox(
+            parent=popup,
+            enum_class=VerticalAxisOrientation,
+            current_enum=self.viewer.camera.orientation[1],
+            callback=partial(
+                self._update_orientation, VerticalAxisOrientation
+            ),
+        )
+
+        self.horizontal_combo = enum_combobox(
+            parent=popup,
+            enum_class=HorizontalAxisOrientation,
+            current_enum=self.viewer.camera.orientation[2],
+            callback=partial(
+                self._update_orientation, HorizontalAxisOrientation
+            ),
+        )
+
+        if self.viewer.dims.ndisplay == 2:
+            orientation_layout.addWidget(self.vertical_combo)
+            orientation_layout.addWidget(self.horizontal_combo)
+            orientation_help_symbol = help_tooltip(
+                parent=popup,
+                text='Controls the Vertical and Horizontal orientation of the camera axes.',
+            )
+
+        else:
+            self.depth_combo = enum_combobox(
+                parent=popup,
+                enum_class=DepthAxisOrientation,
+                current_enum=self.viewer.camera.orientation[0],
+                callback=partial(
+                    self._update_orientation, DepthAxisOrientation
+                ),
+            )
+
+            orientation_layout.addWidget(self.depth_combo)
+            orientation_layout.addWidget(self.vertical_combo)
+            orientation_layout.addWidget(self.horizontal_combo)
+            orientation_help_symbol = help_tooltip(
+                parent=popup,
+                text='Controls the Depth, Vertical, and Horizontal orientation of the camera axes.',
path: napari/_qt/widgets/qt_viewer_buttons.py
user: login: jni
body: ```suggestion
                text='Controls the orientation of the depth, vertical, and horizontal camera axes.',
```
created_at: 2025-03-11T08:41:43Z
author_association: MEMBER
diff_hunk: @@ -332,15 +356,79 @@ def _add_shared_camera_controls(
             text='Controls zoom level of the camera. Larger values zoom in, smaller values zoom out.',
         )
 
-        form_layout.insertRow(0, QLabel(trans._('Zoom:')), self.zoom)
+        form_layout.insertRow(1, QLabel(trans._('Zoom:')), self.zoom)
         help_layout.addWidget(zoom_help_symbol)
 
+    def _add_orientation_controls(
+        self,
+        popup: QtPopup,
+        form_layout: QFormLayout,
+        help_layout: QVBoxLayout,
+    ) -> None:
+        """Add orientation controls to the popup."""
+        orientation_layout = QHBoxLayout()
+        orientation_layout.setContentsMargins(0, 0, 0, 0)
+        orientation_widget = QWidget(popup)
+
+        self.vertical_combo = enum_combobox(
+            parent=popup,
+            enum_class=VerticalAxisOrientation,
+            current_enum=self.viewer.camera.orientation[1],
+            callback=partial(
+                self._update_orientation, VerticalAxisOrientation
+            ),
+        )
+
+        self.horizontal_combo = enum_combobox(
+            parent=popup,
+            enum_class=HorizontalAxisOrientation,
+            current_enum=self.viewer.camera.orientation[2],
+            callback=partial(
+                self._update_orientation, HorizontalAxisOrientation
+            ),
+        )
+
+        if self.viewer.dims.ndisplay == 2:
+            orientation_layout.addWidget(self.vertical_combo)
+            orientation_layout.addWidget(self.horizontal_combo)
+            orientation_help_symbol = help_tooltip(
+                parent=popup,
+                text='Controls the Vertical and Horizontal orientation of the camera axes.',
path: napari/_qt/widgets/qt_viewer_buttons.py
user: login: jni
body: ```suggestion
                text='Controls the orientation of the vertical and horizontal camera axes.',
```
created_at: 2025-03-11T08:42:12Z
author_association: MEMBER
diff_hunk: @@ -354,6 +442,41 @@ def open_ndisplay_camera_popup(self) -> None:
         # Reposition popup, must be done after all widgets are added
         self._position_popup_inside_viewer(popup, self.ndisplayButton)
 
+    def _update_orientation(
+        self,
+        orientation_enum: (
+            DepthAxisOrientation
+            | VerticalAxisOrientation
+            | HorizontalAxisOrientation
+        ),
+        value: (
+            DepthAxisOrientationStr
+            | VerticalAxisOrientationStr
+            | HorizontalAxisOrientationStr
+        ),
path: napari/_qt/widgets/qt_viewer_buttons.py
user: login: jni
body: Note: I found the previous auto-formatting funky, because there was no indent on the continuation lines:

```python
         orientation_enum: DepthAxisOrientation
         | VerticalAxisOrientation
         | HorizontalAxisOrientation,
         value: DepthAxisOrientationStr
         | VerticalAxisOrientationStr
         | HorizontalAxisOrientationStr,
```

I added parens as a "guide" for the formatter (ruff and black allow this for certain expressions), and it worked, so I pushed the changes.
created_at: 2025-03-11T09:00:06Z
author_association: MEMBER
diff_hunk: @@ -354,6 +442,41 @@ def open_ndisplay_camera_popup(self) -> None:
         # Reposition popup, must be done after all widgets are added
         self._position_popup_inside_viewer(popup, self.ndisplayButton)
 
+    def _update_orientation(
+        self,
+        orientation_enum: (
+            DepthAxisOrientation
+            | VerticalAxisOrientation
+            | HorizontalAxisOrientation
+        ),
+        value: (
+            DepthAxisOrientationStr
+            | VerticalAxisOrientationStr
+            | HorizontalAxisOrientationStr
+        ),
+    ) -> None:
+        """Update the orientation of the camera.
+
+        Parameters
+        ----------
+        orientation_enum : DepthAxisOrientation | VerticalAxisOrientation | HorizontalAxisOrientation
+            The orientation enum to update.
+        value : DepthAxisOrientationStr | VerticalAxisOrientationStr | HorizontalAxisOrientationStr
+            New orientation value for the enum.
+        """
+        orientation = list(self.viewer.camera.orientation)
+
+        new_orientation = orientation_enum(value)
+
+        if orientation_enum is DepthAxisOrientation:
+            orientation[0] = new_orientation
+        elif orientation_enum is VerticalAxisOrientation:
+            orientation[1] = new_orientation
+        elif orientation_enum is HorizontalAxisOrientation:
+            orientation[2] = new_orientation
+
+        self.viewer.camera.orientation = tuple(orientation)
path: napari/_qt/widgets/qt_viewer_buttons.py
user: login: jni
body: ```suggestion
    def _update_orientation(
        self,
        orientation_type: Literal[
            DepthAxisOrientation
            | VerticalAxisOrientation
            | HorizontalAxisOrientation
        ],
        orientation_value: (
            DepthAxisOrientationStr
            | VerticalAxisOrientationStr
            | HorizontalAxisOrientationStr
        ),
    ) -> None:
        """Update the orientation of the camera.

        Parameters
        ----------
        orientation_type : DepthAxisOrientation | VerticalAxisOrientation | HorizontalAxisOrientation
            The orientation type (which implies the position/axis) to update.
        value : DepthAxisOrientationStr | VerticalAxisOrientationStr | HorizontalAxisOrientationStr
            New orientation value for the updated axis.
        """
        axes = (DepthAxisOrientation, VerticalAxisOrientation, HorizontalAxisOrientation)
        axis_to_update = axes.index(orientation_type)
        new_orientation = list(self.viewer.camera.orientation)  # new mutable copy
        new_orientation[axis_to_update] = orientation_type(orientation_value)
        self.viewer.camera.orientation = tuple(new_orientation)
```

I'm not 100% that this code is clearer, but I have an aversion to if/elif/... cascades, so I thought I'd give it a shot to see what you think @TimMonko.

Also, I realised as I was writing this that the typing for `orientation_type` (formerly `orientation_enum`) is incorrect: it's not one of the three enums, it's one of the three enum classes/types: see above e.g.:

```python
                callback=partial(
                    self._update_orientation, DepthAxisOrientation
                ),
```

So I've also suggested the type:

```python
Literal[DepthAxisOrientation | VerticalAxisOrientation | HorizontalAxisOrientation]
```

I think that's correct? @Czaki please verify (and maybe explain why mypy didn't catch it 😅).
created_at: 2025-03-11T09:19:38Z
author_association: MEMBER
diff_hunk: @@ -354,6 +442,41 @@ def open_ndisplay_camera_popup(self) -> None:
         # Reposition popup, must be done after all widgets are added
         self._position_popup_inside_viewer(popup, self.ndisplayButton)
 
+    def _update_orientation(
+        self,
+        orientation_enum: (
+            DepthAxisOrientation
+            | VerticalAxisOrientation
+            | HorizontalAxisOrientation
+        ),
+        value: (
+            DepthAxisOrientationStr
+            | VerticalAxisOrientationStr
+            | HorizontalAxisOrientationStr
+        ),
+    ) -> None:
+        """Update the orientation of the camera.
+
+        Parameters
+        ----------
+        orientation_enum : DepthAxisOrientation | VerticalAxisOrientation | HorizontalAxisOrientation
+            The orientation enum to update.
+        value : DepthAxisOrientationStr | VerticalAxisOrientationStr | HorizontalAxisOrientationStr
+            New orientation value for the enum.
+        """
+        orientation = list(self.viewer.camera.orientation)
+
+        new_orientation = orientation_enum(value)
+
+        if orientation_enum is DepthAxisOrientation:
+            orientation[0] = new_orientation
+        elif orientation_enum is VerticalAxisOrientation:
+            orientation[1] = new_orientation
+        elif orientation_enum is HorizontalAxisOrientation:
+            orientation[2] = new_orientation
+
+        self.viewer.camera.orientation = tuple(orientation)
path: napari/_qt/widgets/qt_viewer_buttons.py
user: login: TimMonko
body: I'm still not totally sure, but based on my research I think it should be type[...] and not Literal. 

Regardless, the method body is WAY better -- so thank you for the suggestion!  I hated the if/elif part but couldn't think of a cleaner implementation at the time. Sometimes avoiding elif for me results in very confusing code, but this is easily readable, in part because the definition of axes is so clear. I think the was really a fault of me starting with orientation2d for ndisplay 2 and orientation for ndisplay 3, where trying to avoid elif resulted in a really whacky map with a dict. 
created_at: 2025-03-11T13:30:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,6 +127,21 @@ def labeled_double_slider(
     return slider
 
 
+def enum_combobox(
+    *,
+    parent: QtPopup,
+    enum_class: type,
+    current_enum: str,
+    callback: 'Callable',
+) -> QEnumComboBox:
+    """Create an enum combobox widget."""
+    combo = QEnumComboBox(parent)
+    combo.setEnumClass(enum_class)
+    combo.setCurrentEnum(current_enum)
+    combo.currentEnumChanged.connect(callback)
+    return combo
path: napari/_qt/widgets/qt_viewer_buttons.py
user: login: TimMonko
body: Based on what I now know after looking through Juan's review, I'm not sure this is typed correctly? In particular, I would want `enum_class` to be `type[Enum]` and `current_enum` to be `Enum`?

But then again, what I did worked... and its kind of like setting the current enum is using the str. That's the part where I'm still confused how enums interact with things properly. 
created_at: 2025-03-11T13:50:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,6 +127,21 @@ def labeled_double_slider(
     return slider
 
 
+def enum_combobox(
+    *,
+    parent: QtPopup,
+    enum_class: type,
+    current_enum: str,
+    callback: 'Callable',
+) -> QEnumComboBox:
+    """Create an enum combobox widget."""
+    combo = QEnumComboBox(parent)
+    combo.setEnumClass(enum_class)
path: napari/_qt/widgets/qt_viewer_buttons.py
user: login: Czaki
body: ```suggestion
    combo = QEnumComboBox(parent, enum_class=enum_class)
```
created_at: 2025-03-13T22:53:09Z
author_association: COLLABORATOR
diff_hunk: @@ -12,13 +12,22 @@
     QLabel,
     QPushButton,
     QVBoxLayout,
+    QWidget,
 )
-from superqt import QLabeledDoubleSlider
+from superqt import QEnumComboBox, QLabeledDoubleSlider
path: napari/_qt/widgets/qt_viewer_buttons.py
user: login: Czaki
body: ```suggestion
from enum import Enum, EnumMeta
from superqt import QEnumComboBox, QLabeledDoubleSlider
```

pre-commit will move on proper place
created_at: 2025-03-13T22:55:01Z
author_association: COLLABORATOR
diff_hunk: @@ -118,6 +127,21 @@ def labeled_double_slider(
     return slider
 
 
+def enum_combobox(
+    *,
+    parent: QtPopup,
+    enum_class: type,
+    current_enum: str,
+    callback: 'Callable',
path: napari/_qt/widgets/qt_viewer_buttons.py
user: login: Czaki
body: ```suggestion
    parent: QtPopup,
    enum_class: EnumMeta,
    current_enum: Enum,
    callback: 'Callable[[],Any] | Callable[[Enum],Any]',
```
created_at: 2025-03-13T22:57:02Z
author_association: COLLABORATOR

PR comments:
user: login: TimMonko
created_at: 2025-03-10T14:11:13Z
body: cc: @hanjinliu @jni 
author_association: CONTRIBUTOR
user: login: hanjinliu
created_at: 2025-03-10T14:45:15Z
body: Thank you for your quick update, @TimMonko !
Yes, this feature is exactly what I'd like to have.
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-03-10T15:15:40Z
body: ## [Codecov](https://app.codecov.io/gh/napari/napari/pull/7686?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=napari) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.85%. Comparing base [(`610bdc6`)](https://app.codecov.io/gh/napari/napari/commit/610bdc6bb55dedad974889fe765234ec0773009a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=napari) to head [(`4cad3e7`)](https://app.codecov.io/gh/napari/napari/commit/4cad3e78e94f1002500bd8f9ab277df7c14ffdfd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=napari).
> Report is 5 commits behind head on main.

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


```diff
@@            Coverage Diff             @@
##             main    #7686      +/-   ##
==========================================
- Coverage   92.88%   92.85%   -0.03%     
==========================================
  Files         633      633              
  Lines       59335    59392      +57     
==========================================
+ Hits        55113    55151      +38     
- Misses       4222     4241      +19     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/napari/napari/pull/7686?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=napari).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=napari).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: TimMonko
created_at: 2025-03-11T04:31:16Z
body: @jni this is ready for review :)

Please any reviewer be aware that this is my first time _actually trying_ to wrap my head around how to use Enums and not just brute force them, so please keep an eye on its correctness. I actually feel quite clever with how cleaned up I was able to make this compared to the draft. 
author_association: CONTRIBUTOR
user: login: jni
created_at: 2025-03-11T09:26:08Z
body: Oh another minor comment — I'm not sure whether this should close #7683, or whether we should also add a preference. I imagine it would be super annoying if you always work with mrc files to have to change the axis orientation on each startup. Feedback welcome!

But either way the addition of a preference should come in a later PR.
author_association: MEMBER
user: login: TimMonko
created_at: 2025-03-11T13:36:06Z
body: Thank you for the great review!

> I'm not sure whether this should close #7683, or whether we should also add a preference.

As soon as I walked away from my computer I was thinking that issue is way too involved (and important) for this to close it, so I agree with you. 


author_association: CONTRIBUTOR
user: login: willingc
created_at: 2025-03-14T16:35:59Z
body: Rerunning failing windows job to see if it was a flaky ci failure or legit.
author_association: COLLABORATOR
user: login: TimMonko
created_at: 2025-03-14T17:07:51Z
body: The only changes are in typing, since it last succeeded, what could have changed? I'll be gone rest of day, so if there is a needed fix feel free to get passing so we can have in alpha release today. 
author_association: CONTRIBUTOR
user: login: willingc
created_at: 2025-03-14T18:48:59Z
body: Some I am pretty sure that the additon of the vispy bounding box test #7545 to fix Mac made Windows flaky in this PR and #7632. Probably the best approach right now is to skip the test in Windows and open an issue as a known regression that needs to fix either the flaky test (most likely) or underlying behavior.
author_association: COLLABORATOR
user: login: imagesc-bot
created_at: 2025-05-07T04:23:28Z
body: This pull request has been mentioned on **Image.sc Forum**. There might be relevant details there:

https://forum.image.sc/t/napari-0-6-0-released/112147/1

author_association: NONE