Repository: napari/napari

Issue:
Better chunk and/or memory awareness when rolling dims
## 🚀 Feature
@AbigailMcGovern and I have run into the issue that we have bigger-than-RAM 3D+t data chunked into timepoints. During manual annotation we want to roll the dims to be able to look at XZ or YZ planes, but the UI does not allow one to ignore t when rolling.

This is more general, e.g. 3D data where individual planes are usable but the full dataset is larger than RAM.

There's a few, not-mutually exclusive routes that napari could take here:

- understand chunks and chunked loading, and prevent views (rolling or 2D/3D) that would result in larger-than-RAM loads.
- allow users to define axes that shouldn't be rolled
- have a "rolling" widget where one can drag and drop axes to reorder them
- something else?



Discussion:
Agreed the situation here should be improved! This is something that if we improved https://github.com/napari/napari/issues/906#issuecomment-774749188 could be done with more awareness over axis labels too.

Resolving patch:
diff --git a/napari/_qt/widgets/qt_dims_sorter.py b/napari/_qt/widgets/qt_dims_sorter.py
new file mode 100644
--- /dev/null
+++ b/napari/_qt/widgets/qt_dims_sorter.py
@@ -0,0 +1,100 @@
+from typing import TYPE_CHECKING, Tuple, Union
+
+import numpy as np
+from qtpy.QtWidgets import QGridLayout, QLabel, QWidget
+
+from ..._qt.containers import QtListView
+from ...components import Dims
+from ...utils.events import SelectableEventedList
+from ...utils.translations import trans
+from .qt_tooltip import QtToolTipLabel
+
+if TYPE_CHECKING:
+    from ...viewer import Viewer
+
+
+class AxisModel:
+    """View of an axis within a dims model keeping track of axis names."""
+
+    def __init__(self, dims: Dims, axis: int):
+        self.dims = dims
+        self.axis = axis
+
+    def __hash__(self) -> int:
+        return id(self)
+
+    def __str__(self) -> str:
+        return repr(self)
+
+    def __repr__(self) -> str:
+        return self.dims.axis_labels[self.axis]
+
+    def __eq__(self, other: Union[int, str]) -> bool:
+        if isinstance(other, int):
+            return self.axis == other
+        else:
+            return repr(self) == other
+
+
+def set_dims_order(dims: Dims, order: Tuple[int, ...]):
+    if type(order[0]) == AxisModel:
+        order = [a.axis for a in order]
+    dims.order = order
+
+
+def _array_in_range(arr: np.ndarray, low: int, high: int) -> bool:
+    return (arr >= low) & (arr < high)
+
+
+def move_indices(axes_list: SelectableEventedList, order: Tuple[int, ...]):
+    with axes_list.events.blocker_all():
+        if tuple(axes_list) == tuple(order):
+            return
+        axes = [a.axis for a in axes_list]
+        ax_to_existing_position = {a: ix for ix, a in enumerate(axes)}
+        move_list = np.asarray(
+            [(ax_to_existing_position[order[i]], i) for i in range(len(order))]
+        )
+        for src, dst in move_list:
+            axes_list.move(src, dst)
+            move_list[_array_in_range(move_list[:, 0], dst, src)] += 1
+        # remove the elements from the back if order has changed length
+        while len(axes_list) > len(order):
+            axes_list.pop()
+
+
+class QtDimsSorter(QWidget):
+    """
+    Modified from:
+    https://github.com/jni/zarpaint/blob/main/zarpaint/_dims_chooser.py
+    """
+
+    def __init__(self, viewer: 'Viewer', parent=None):
+        super().__init__(parent=parent)
+        dims = viewer.dims
+        root = SelectableEventedList(
+            [AxisModel(dims, dims.order[i]) for i in range(dims.ndim)]
+        )
+        root.events.reordered.connect(
+            lambda event: set_dims_order(dims, event.value)
+        )
+        dims.events.order.connect(
+            lambda event: move_indices(root, event.value)
+        )
+        view = QtListView(root)
+        view.setSizeAdjustPolicy(QtListView.AdjustToContents)
+
+        self.axes_list = root
+
+        layout = QGridLayout()
+        self.setLayout(layout)
+
+        widget_tooltip = QtToolTipLabel(self)
+        widget_tooltip.setObjectName('help_label')
+        widget_tooltip.setToolTip(trans._('Drag dimensions to reorder.'))
+
+        widget_title = QLabel(trans._('Dims. Ordering'), self)
+
+        self.layout().addWidget(widget_title, 0, 0)
+        self.layout().addWidget(widget_tooltip, 0, 1)
+        self.layout().addWidget(view, 1, 0)
diff --git a/napari/_qt/widgets/qt_viewer_buttons.py b/napari/_qt/widgets/qt_viewer_buttons.py
--- a/napari/_qt/widgets/qt_viewer_buttons.py
+++ b/napari/_qt/widgets/qt_viewer_buttons.py
@@ -17,6 +17,7 @@
 from ...utils.interactions import Shortcut
 from ...utils.translations import trans
 from ..dialogs.qt_modal import QtPopup
+from .qt_dims_sorter import QtDimsSorter
 from .qt_spinbox import QtSpinBox
 from .qt_tooltip import QtToolTipLabel
 
@@ -119,9 +120,12 @@ def __init__(self, viewer: 'Viewer'):
             'console', action='napari:toggle_console_visibility'
         )
         self.consoleButton.setProperty('expanded', False)
-        self.rollDimsButton = QtViewerPushButton(
-            'roll', action='napari:roll_axes'
-        )
+
+        rdb = QtViewerPushButton('roll', action='napari:roll_axes')
+        self.rollDimsButton = rdb
+        rdb.setContextMenuPolicy(Qt.CustomContextMenu)
+        rdb.customContextMenuRequested.connect(self._open_roll_popup)
+
         self.transposeDimsButton = QtViewerPushButton(
             'transpose', action='napari:transpose_axes'
         )
@@ -188,6 +192,23 @@ def open_perspective_popup(self):
         pop.frame.setLayout(layout)
         pop.show_above_mouse()
 
+    def _open_roll_popup(self):
+        """Open a grid popup to manually order the dimensions"""
+        if self.viewer.dims.ndisplay != 2:
+            return
+
+        dim_sorter = QtDimsSorter(self.viewer, self)
+        dim_sorter.setObjectName('dim_sorter')
+
+        # make layout
+        layout = QHBoxLayout()
+        layout.addWidget(dim_sorter)
+
+        # popup and show
+        pop = QtPopup(self)
+        pop.frame.setLayout(layout)
+        pop.show_above_mouse()
+
     def _open_grid_popup(self):
         """Open grid options pop up widget."""
 
diff --git a/tools/strings_list.py b/tools/strings_list.py
--- a/tools/strings_list.py
+++ b/tools/strings_list.py
@@ -659,6 +659,7 @@
         'new_points',
         'new_shapes',
         'roll',
+        'dim_sorter',
         'transpose',
         'layer',
         'gridStrideBox',


Test patch:
diff --git a/napari/_qt/widgets/_tests/test_qt_dims_sorter.py b/napari/_qt/widgets/_tests/test_qt_dims_sorter.py
new file mode 100644
--- /dev/null
+++ b/napari/_qt/widgets/_tests/test_qt_dims_sorter.py
@@ -0,0 +1,22 @@
+from napari._qt.widgets.qt_dims_sorter import QtDimsSorter
+
+
+def test_dims_sorter(make_napari_viewer):
+    viewer = make_napari_viewer()
+    dim_sorter = QtDimsSorter(viewer)
+    assert tuple(dim_sorter.axes_list) == (0, 1)
+
+    viewer.dims.axis_labels = ('y', 'x')
+    assert tuple(dim_sorter.axes_list) == ('y', 'x')
+
+    dim_sorter.axes_list.move(1, 0)
+    assert tuple(dim_sorter.axes_list) == ('x', 'y')
+    assert tuple(viewer.dims.order) == (1, 0)
+
+
+def test_dims_sorter_with_reordered_init(make_napari_viewer):
+    viewer = make_napari_viewer()
+    viewer.dims.order = (1, 0)
+
+    dim_sorter = QtDimsSorter(viewer)
+    assert tuple(dim_sorter.axes_list) == tuple(viewer.dims.order)