Repository: napari/napari

Issue:
3 files ASCII with CRLF, should maybe be converted to unix ending ? 
## 🧰 Task

It seem we have 3 files that have CRLF in them, some tools don't like those, and it may be disturbing to some contributors that may see spurious `^M` (CR) in diff tools.

It would be good to update those to redo the newline, but unfortunately that would modify all the lines in history

```
 fd '.py' | xargs file | grep CRLF
napari/_tests/test_interactive_transforms.py:                         Python script text executable, ASCII text, with CRLF line terminators
napari/components/_interaction_box_mouse_bindings.py:                 Python script text executable, ASCII text, with CRLF line terminators
napari/layers/image/_image_key_bindings.py:                           Python script text executable, ASCII text, with CRLF line terminators
```

and if you are curious:

```
$ fd '.py$' | xargs file | cut -f2 -d: | tr -s ' ' | sort | uniq -c | sort -nr
 662  Python script text executable, ASCII text
  31  empty
  25  Python script text executable, Unicode text, UTF-8 text
   6  ASCII text
   3  Python script text executable, ASCII text, with CRLF line terminators
   1  directory
   1  Unicode text, UTF-8 text, with very long lines (1116)
   1  Python script text executable, ASCII text, with very long lines (497)
   1  Python script text executable, ASCII text, with very long lines (471)
   1  Python script text executable, ASCII text, with very long lines (310)
   1  Pascal source text, ASCII text
   1  GNU gettext message catalogue text, Unicode text, UTF-8 text, with very long lines (2060)
```


Discussion:
Hmm, i know very little about this. I would be fine with any approach. What do others think here?
Unix ending works for me

> On Dec 30, 2021, at 5:28 PM, Nicholas Sofroniew ***@***.***> wrote:
> 
> ﻿
> Hmm, i know very little about this. I would be fine with any approach. What do others think here?
> 
> —
> Reply to this email directly, view it on GitHub, or unsubscribe.
> You are receiving this because you are subscribed to this thread.

I'm also +1 on harmonising line endings. @Carreau do you think we should also add another pre-commit check so that we don't add any new such files by accident?

Resolving patch:
diff --git a/napari/components/_interaction_box_mouse_bindings.py b/napari/components/_interaction_box_mouse_bindings.py
--- a/napari/components/_interaction_box_mouse_bindings.py
+++ b/napari/components/_interaction_box_mouse_bindings.py
@@ -1,433 +1,433 @@
-import warnings
-from weakref import ref
-
-import numpy as np
-
-from ..layers.utils.layer_utils import dims_displayed_world_to_layer
-from ..utils.action_manager import action_manager
-from ..utils.transforms import Affine
-from ..utils.translations import trans
-from ._interaction_box_constants import Box
-
-
-def inside_boxes(boxes):
-    """Checks which boxes contain the origin. Boxes need not be axis aligned
-
-    Parameters
-    ----------
-    boxes : (N, 8, 2) array
-        Array of N boxes that should be checked
-
-    Returns
-    -------
-    inside : (N,) array of bool
-        True if corresponding box contains the origin.
-    """
-
-    AB = boxes[:, 0] - boxes[:, 6]
-    AM = boxes[:, 0]
-    BC = boxes[:, 6] - boxes[:, 4]
-    BM = boxes[:, 6]
-
-    ABAM = np.multiply(AB, AM).sum(1)
-    ABAB = np.multiply(AB, AB).sum(1)
-    BCBM = np.multiply(BC, BM).sum(1)
-    BCBC = np.multiply(BC, BC).sum(1)
-
-    c1 = 0 <= ABAM
-    c2 = ABAM <= ABAB
-    c3 = 0 <= BCBM
-    c4 = BCBM <= BCBC
-
-    inside = np.all(np.array([c1, c2, c3, c4]), axis=0)
-
-    return inside
-
-
-class InteractionBoxMouseBindings:
-    def __init__(self, viewer, interaction_box_visual):
-        self._selected_vertex: int = None
-        self._fixed_vertex: int = None
-        self._fixed_aspect: float = None
-        self._layer_listening_for_affine = None
-        self._ref_viewer = ref(viewer)
-        self._interaction_box_model = viewer.overlays.interaction_box
-        self._ref_interaction_box_visual = ref(interaction_box_visual)
-        viewer.layers.events.inserted.connect(self._on_add_layer)
-        viewer.layers.events.removed.connect(self._on_remove_layer)
-        viewer.layers.selection.events.active.connect(self._on_active)
-        viewer.dims.events.order.connect(self._on_dim_change)
-        viewer.dims.events.ndisplay.connect(self._on_ndisplay_change)
-        self._interaction_box_model.events.transform_drag.connect(
-            self._on_transform_change
-        )
-        self.initialize_mouse_events(viewer)
-        self.initialize_key_events(viewer)
-
-    def _on_remove_layer(self, event):
-        """Gets called when layer is removed and removes event listener"""
-
-        layer = event.value
-        if hasattr(layer, 'mode'):
-            layer.events.mode.disconnect(self)
-
-    def _on_add_layer(self, event):
-        """Gets called when layer is added and adds event listener to mode change"""
-        layer = event.value
-        if hasattr(layer, 'mode'):
-            layer.events.mode.connect(self._on_mode_change)
-
-    def _on_active(self, event):
-        """Gets called when active layer is changed"""
-        active_layer = event.value
-        if getattr(active_layer, 'mode', None) == 'transform':
-            self._couple_interaction_box_to_active()
-            self._interaction_box_model.show = True
-            self._layer_affine_event_helper(active_layer)
-        else:
-            self._interaction_box_model.show = False
-
-    def _on_ndisplay_change(self):
-        """Gets called on ndisplay change to disable interaction box in 3D"""
-        if (
-            getattr(self._ref_viewer().layers.selection.active, 'mode', None)
-            == 'transform'
-            and self._ref_viewer().dims.ndisplay > 2
-        ):
-            self._ref_viewer().layers.selection.active.mode = 'pan_zoom'
-
-    def _couple_interaction_box_to_active(self, event=None):
-        viewer = self._ref_viewer()
-        active_layer = viewer.layers.selection.active
-        # This is necessary in case the current layer has fewer dims than the viewer
-
-        layer_dims = dims_displayed_world_to_layer(
-            list(viewer.dims.displayed),
-            viewer.dims.ndim,
-            active_layer.ndim,
-        )
-        # The -0.5 is necessary because the pixel at (0,0) actually extends to (-0.5,0.5) (in case of the image layer)
-
-        viewer.overlays.interaction_box.points = (
-            active_layer.extent.data[:, layer_dims] - 0.5
-        )
-        viewer.overlays.interaction_box.transform = (
-            active_layer.affine.set_slice(layer_dims)
-        )
-
-    def _on_dim_change(self, event):
-        """Gets called when changing order of dims to make sure interaction box is using right extent and transform"""
-        if (
-            getattr(self._ref_viewer().layers.selection.active, 'mode', None)
-            == 'transform'
-        ):
-            self._couple_interaction_box_to_active()
-
-    def _layer_affine_event_helper(self, layer):
-        """Helper function to connect listener to the transform of active layer and removes previous callbacks"""
-
-        if self._layer_listening_for_affine is not None:
-            self._layer_listening_for_affine.events.affine.disconnect(self)
-        layer.events.affine.connect(self._couple_interaction_box_to_active)
-        self._layer_listening_for_affine = layer
-
-    def _on_mode_change(self, event):
-        """Gets called on mode change to enable interaction box in transform mode"""
-        viewer = self._ref_viewer()
-        if event.mode == 'transform':
-            if viewer.dims.ndisplay > 2:
-                warnings.warn(
-                    trans._(
-                        'Interactive transforms in 3D are not yet supported.',
-                        deferred=True,
-                    ),
-                    category=UserWarning,
-                )
-                viewer.layers.selection.active.mode = 'pan_zoom'
-                return
-            viewer.layers.selection.active = event.source
-            self._couple_interaction_box_to_active()
-            viewer.overlays.interaction_box.show_vertices = True
-            viewer.overlays.interaction_box.show_handle = True
-            viewer.overlays.interaction_box.allow_new_selection = False
-            viewer.overlays.interaction_box.show = True
-            self._layer_affine_event_helper(event.source)
-
-        else:
-            viewer.overlays.interaction_box.show = False
-            viewer.overlays.interaction_box.points = None
-            viewer.overlays.interaction_box.transform = Affine()
-
-    def _on_transform_change(self, event):
-        """Gets called when the interaction box is transformed to update transform of the layer"""
-
-        layer_dims_displayed = dims_displayed_world_to_layer(
-            list(self._ref_viewer().dims.displayed),
-            self._ref_viewer().dims.ndim,
-            self._ref_viewer().layers.selection.active.ndim,
-        )
-        self._ref_viewer().layers.selection.active.affine = (
-            self._ref_viewer().layers.selection.active.affine.replace_slice(
-                layer_dims_displayed, event.value
-            )
-        )
-
-    def initialize_key_events(self, viewer):
-
-        action_manager.register_action(
-            "napari:reset_active_layer_affine",
-            self._reset_active_layer_affine,
-            trans._("Reset the affine transform of the active layer"),
-            self._ref_viewer(),
-        )
-
-        action_manager.register_action(
-            "napari:transform_active_layer",
-            self._transform_active_layer,
-            trans._("Activate transform mode for the active layer"),
-            self._ref_viewer(),
-        )
-
-        @viewer.bind_key('Shift')
-        def hold_to_lock_aspect_ratio(viewer):
-            """Hold to lock aspect ratio when resizing a shape."""
-            # on key press
-            self._fixed_aspect = True
-            yield
-            # on key release
-            self._fixed_aspect = False
-
-    def initialize_mouse_events(self, viewer):
-        """Adds event handling functions to the layer"""
-
-        @viewer.mouse_move_callbacks.append
-        def mouse_move(viewer, event):
-            if (
-                not self._interaction_box_model.show
-                or self._interaction_box_model._box is None
-            ):
-                return
-
-            # The _box of the visual model has the handle
-            box = self._ref_interaction_box_visual()._box
-            coord = [event.position[i] for i in viewer.dims.displayed]
-            distances = abs(box - coord)
-
-            # Get the vertex sizes
-            sizes = (
-                self._ref_interaction_box_visual()._vertex_size / 2
-            ) / self._ref_viewer().camera.zoom
-
-            # Check if any matching vertices
-            matches = np.all(distances <= sizes, axis=1).nonzero()
-            if len(matches[0]) > 0:
-                self._selected_vertex = matches[0][-1]
-                # Exclde center vertex
-                if self._selected_vertex == Box.CENTER:
-                    self._selected_vertex = None
-            else:
-                self._selected_vertex = None
-            self._interaction_box_model.selected_vertex = self._selected_vertex
-
-        @viewer.mouse_drag_callbacks.append
-        def mouse_drag(viewer, event):
-            if not self._interaction_box_model.show:
-                return
-
-            # Handling drag start, decide what action to take
-            self._set_drag_start_values(
-                viewer, [event.position[i] for i in viewer.dims.displayed]
-            )
-            drag_callback = None
-            final_callback = None
-            if self._selected_vertex is not None:
-                if self._selected_vertex == Box.HANDLE:
-                    drag_callback = self._on_drag_rotation
-                    final_callback = self._on_final_transform
-                    yield
-                else:
-                    self._fixed_vertex = (
-                        self._selected_vertex + 4
-                    ) % Box.LEN_WITHOUT_HANDLE
-                    drag_callback = self._on_drag_scale
-                    final_callback = self._on_final_transform
-                    yield
-            else:
-                if (
-                    self._interaction_box_model._box is not None
-                    and self._interaction_box_model.show
-                    and inside_boxes(
-                        np.array(
-                            [
-                                self._interaction_box_model._box
-                                - self._drag_start_coordinates
-                            ]
-                        )
-                    )[0]
-                ):
-                    drag_callback = self._on_drag_translate
-                    final_callback = self._on_final_transform
-
-                    yield
-                else:
-                    if self._interaction_box_model.allow_new_selection:
-                        self._interaction_box_model.points = None
-                        self._interaction_box_model.transform = Affine()
-                        drag_callback = self._on_drag_newbox
-                        final_callback = self._on_end_newbox
-                    yield
-            # Handle events during dragging
-            while event.type == 'mouse_move':
-                if drag_callback is not None:
-                    drag_callback(viewer, event)
-                yield
-
-            if final_callback is not None:
-                final_callback(viewer, event)
-
-            self._clear_drag_start_values()
-
-    def _set_drag_start_values(self, viewer, position):
-        """Gets called whenever a drag is started to remember starting values"""
-
-        self._drag_start_coordinates = np.array(position)
-        self._drag_start_box = np.copy(self._ref_interaction_box_visual()._box)
-        self._interaction_box_model.transform_start = (
-            self._interaction_box_model.transform
-        )
-
-    def _clear_drag_start_values(self):
-        """Gets called at the end of a drag to reset remembered values"""
-
-        self._drag_start_coordinates = None
-        self._drag_start_box = None
-
-    def _on_drag_rotation(self, viewer, event):
-        """Gets called upon mouse_move in the case of a rotation"""
-        center = self._drag_start_box[Box.CENTER]
-        handle = self._drag_start_box[Box.HANDLE]
-        mouse_offset = (
-            np.array([event.position[i] for i in viewer.dims.displayed])
-            - center
-        )
-        handle_offset = handle - center
-        angle = np.degrees(
-            np.arctan2(mouse_offset[0], mouse_offset[1])
-            - np.arctan2(handle_offset[0], handle_offset[1])
-        )
-
-        if np.linalg.norm(mouse_offset) < 1:
-            angle = 0
-        elif self._fixed_aspect:
-            angle = np.round(angle / 45) * 45
-
-        tform1 = Affine(translate=-center)
-        tform2 = Affine(rotate=-angle)
-        tform3 = Affine(translate=center)
-        transform = (
-            tform3.compose(tform2)
-            .compose(tform1)
-            .compose(self._interaction_box_model.transform_start)
-        )
-        self._interaction_box_model.transform = transform
-        self._interaction_box_model.transform_drag = transform
-
-    def _on_drag_scale(self, viewer, event):
-        """Gets called upon mouse_move in the case of a scaling operation"""
-
-        # Transform everything back into axis-aligned space with fixed point at origin
-        transform = self._interaction_box_model.transform_start.inverse
-        center = transform(self._drag_start_box[self._fixed_vertex])
-        transform = Affine(translate=-center).compose(transform)
-        coord = transform(
-            np.array([event.position[i] for i in viewer.dims.displayed])
-        )
-        drag_start = transform(self._drag_start_box[self._selected_vertex])
-        # If sidepoint of fixed aspect ratio project offset onto vector along which to scale
-        # Since the fixed verted is now at the origin this vector is drag_start
-        if self._fixed_aspect or self._selected_vertex % 2 == 1:
-            offset = coord - drag_start
-            offset_proj = (
-                np.dot(drag_start, offset) / (np.linalg.norm(drag_start) ** 2)
-            ) * drag_start
-
-            # Prevent numeric instabilities
-            offset_proj[np.abs(offset_proj) < 1e-5] = 0
-            drag_start[drag_start == 0.0] = 1e-5
-
-            scale = np.array([1.0, 1.0]) + (offset_proj) / drag_start
-        else:
-            scale = coord / drag_start
-
-        # Apply scaling
-        transform = Affine(scale=scale).compose(transform)
-
-        # translate back and apply intial rotation again
-        transform = Affine(translate=center).compose(transform)
-        transform = self._interaction_box_model.transform_start.compose(
-            transform
-        )
-        # Chain with original transform
-        transform = transform.compose(
-            self._interaction_box_model.transform_start
-        )
-
-        self._interaction_box_model.transform = transform
-        self._interaction_box_model.transform_drag = transform
-
-    def _on_drag_translate(self, viewer, event):
-        """Gets called upon mouse_move in the case of a translation operation"""
-
-        offset = (
-            np.array([event.position[i] for i in viewer.dims.displayed])
-            - self._drag_start_coordinates
-        )
-
-        transform = Affine(translate=offset).compose(
-            self._interaction_box_model.transform_start
-        )
-        self._interaction_box_model.transform = transform
-        self._interaction_box_model.transform_drag = transform
-
-    def _on_final_transform(self, viewer, event):
-        """Gets called upon mouse_move in the case of a translation operation"""
-        self._interaction_box_model.transform_final = (
-            self._interaction_box_model.transform
-        )
-
-    def _on_drag_newbox(self, viewer, event):
-        """Gets called upon mouse_move in the case of a drawing a new box"""
-
-        self._interaction_box_model.points = np.array(
-            [
-                self._drag_start_coordinates,
-                np.array([event.position[i] for i in viewer.dims.displayed]),
-            ]
-        )
-        self._interaction_box_model.show = True
-        self._interaction_box_model.show_handle = False
-        self._interaction_box_model.show_vertices = False
-        self._interaction_box_model.selection_box_drag = (
-            self._interaction_box_model._box[Box.WITHOUT_HANDLE]
-        )
-
-    def _on_end_newbox(self, viewer, event):
-        """Gets called when dragging ends in the case of a drawing a new box"""
-
-        if self._interaction_box_model._box is not None:
-            self._interaction_box_model.selection_box_final = (
-                self._interaction_box_model._box[Box.WITHOUT_HANDLE]
-            )
-
-    def _reset_active_layer_affine(self, event=None):
-        active_layer = self._ref_viewer().layers.selection.active
-        if active_layer is not None:
-            active_layer.affine = Affine(ndim=active_layer.ndim)
-
-    def _transform_active_layer(self, event=None):
-        active_layer = self._ref_viewer().layers.selection.active
-        if active_layer is not None and hasattr(active_layer, 'mode'):
-            if active_layer.mode != 'transform':
-                active_layer.mode = 'transform'
-            else:
-                active_layer.mode = 'pan_zoom'
+import warnings
+from weakref import ref
+
+import numpy as np
+
+from ..layers.utils.layer_utils import dims_displayed_world_to_layer
+from ..utils.action_manager import action_manager
+from ..utils.transforms import Affine
+from ..utils.translations import trans
+from ._interaction_box_constants import Box
+
+
+def inside_boxes(boxes):
+    """Checks which boxes contain the origin. Boxes need not be axis aligned
+
+    Parameters
+    ----------
+    boxes : (N, 8, 2) array
+        Array of N boxes that should be checked
+
+    Returns
+    -------
+    inside : (N,) array of bool
+        True if corresponding box contains the origin.
+    """
+
+    AB = boxes[:, 0] - boxes[:, 6]
+    AM = boxes[:, 0]
+    BC = boxes[:, 6] - boxes[:, 4]
+    BM = boxes[:, 6]
+
+    ABAM = np.multiply(AB, AM).sum(1)
+    ABAB = np.multiply(AB, AB).sum(1)
+    BCBM = np.multiply(BC, BM).sum(1)
+    BCBC = np.multiply(BC, BC).sum(1)
+
+    c1 = 0 <= ABAM
+    c2 = ABAM <= ABAB
+    c3 = 0 <= BCBM
+    c4 = BCBM <= BCBC
+
+    inside = np.all(np.array([c1, c2, c3, c4]), axis=0)
+
+    return inside
+
+
+class InteractionBoxMouseBindings:
+    def __init__(self, viewer, interaction_box_visual):
+        self._selected_vertex: int = None
+        self._fixed_vertex: int = None
+        self._fixed_aspect: float = None
+        self._layer_listening_for_affine = None
+        self._ref_viewer = ref(viewer)
+        self._interaction_box_model = viewer.overlays.interaction_box
+        self._ref_interaction_box_visual = ref(interaction_box_visual)
+        viewer.layers.events.inserted.connect(self._on_add_layer)
+        viewer.layers.events.removed.connect(self._on_remove_layer)
+        viewer.layers.selection.events.active.connect(self._on_active)
+        viewer.dims.events.order.connect(self._on_dim_change)
+        viewer.dims.events.ndisplay.connect(self._on_ndisplay_change)
+        self._interaction_box_model.events.transform_drag.connect(
+            self._on_transform_change
+        )
+        self.initialize_mouse_events(viewer)
+        self.initialize_key_events(viewer)
+
+    def _on_remove_layer(self, event):
+        """Gets called when layer is removed and removes event listener"""
+
+        layer = event.value
+        if hasattr(layer, 'mode'):
+            layer.events.mode.disconnect(self)
+
+    def _on_add_layer(self, event):
+        """Gets called when layer is added and adds event listener to mode change"""
+        layer = event.value
+        if hasattr(layer, 'mode'):
+            layer.events.mode.connect(self._on_mode_change)
+
+    def _on_active(self, event):
+        """Gets called when active layer is changed"""
+        active_layer = event.value
+        if getattr(active_layer, 'mode', None) == 'transform':
+            self._couple_interaction_box_to_active()
+            self._interaction_box_model.show = True
+            self._layer_affine_event_helper(active_layer)
+        else:
+            self._interaction_box_model.show = False
+
+    def _on_ndisplay_change(self):
+        """Gets called on ndisplay change to disable interaction box in 3D"""
+        if (
+            getattr(self._ref_viewer().layers.selection.active, 'mode', None)
+            == 'transform'
+            and self._ref_viewer().dims.ndisplay > 2
+        ):
+            self._ref_viewer().layers.selection.active.mode = 'pan_zoom'
+
+    def _couple_interaction_box_to_active(self, event=None):
+        viewer = self._ref_viewer()
+        active_layer = viewer.layers.selection.active
+        # This is necessary in case the current layer has fewer dims than the viewer
+
+        layer_dims = dims_displayed_world_to_layer(
+            list(viewer.dims.displayed),
+            viewer.dims.ndim,
+            active_layer.ndim,
+        )
+        # The -0.5 is necessary because the pixel at (0,0) actually extends to (-0.5,0.5) (in case of the image layer)
+
+        viewer.overlays.interaction_box.points = (
+            active_layer.extent.data[:, layer_dims] - 0.5
+        )
+        viewer.overlays.interaction_box.transform = (
+            active_layer.affine.set_slice(layer_dims)
+        )
+
+    def _on_dim_change(self, event):
+        """Gets called when changing order of dims to make sure interaction box is using right extent and transform"""
+        if (
+            getattr(self._ref_viewer().layers.selection.active, 'mode', None)
+            == 'transform'
+        ):
+            self._couple_interaction_box_to_active()
+
+    def _layer_affine_event_helper(self, layer):
+        """Helper function to connect listener to the transform of active layer and removes previous callbacks"""
+
+        if self._layer_listening_for_affine is not None:
+            self._layer_listening_for_affine.events.affine.disconnect(self)
+        layer.events.affine.connect(self._couple_interaction_box_to_active)
+        self._layer_listening_for_affine = layer
+
+    def _on_mode_change(self, event):
+        """Gets called on mode change to enable interaction box in transform mode"""
+        viewer = self._ref_viewer()
+        if event.mode == 'transform':
+            if viewer.dims.ndisplay > 2:
+                warnings.warn(
+                    trans._(
+                        'Interactive transforms in 3D are not yet supported.',
+                        deferred=True,
+                    ),
+                    category=UserWarning,
+                )
+                viewer.layers.selection.active.mode = 'pan_zoom'
+                return
+            viewer.layers.selection.active = event.source
+            self._couple_interaction_box_to_active()
+            viewer.overlays.interaction_box.show_vertices = True
+            viewer.overlays.interaction_box.show_handle = True
+            viewer.overlays.interaction_box.allow_new_selection = False
+            viewer.overlays.interaction_box.show = True
+            self._layer_affine_event_helper(event.source)
+
+        else:
+            viewer.overlays.interaction_box.show = False
+            viewer.overlays.interaction_box.points = None
+            viewer.overlays.interaction_box.transform = Affine()
+
+    def _on_transform_change(self, event):
+        """Gets called when the interaction box is transformed to update transform of the layer"""
+
+        layer_dims_displayed = dims_displayed_world_to_layer(
+            list(self._ref_viewer().dims.displayed),
+            self._ref_viewer().dims.ndim,
+            self._ref_viewer().layers.selection.active.ndim,
+        )
+        self._ref_viewer().layers.selection.active.affine = (
+            self._ref_viewer().layers.selection.active.affine.replace_slice(
+                layer_dims_displayed, event.value
+            )
+        )
+
+    def initialize_key_events(self, viewer):
+
+        action_manager.register_action(
+            "napari:reset_active_layer_affine",
+            self._reset_active_layer_affine,
+            trans._("Reset the affine transform of the active layer"),
+            self._ref_viewer(),
+        )
+
+        action_manager.register_action(
+            "napari:transform_active_layer",
+            self._transform_active_layer,
+            trans._("Activate transform mode for the active layer"),
+            self._ref_viewer(),
+        )
+
+        @viewer.bind_key('Shift')
+        def hold_to_lock_aspect_ratio(viewer):
+            """Hold to lock aspect ratio when resizing a shape."""
+            # on key press
+            self._fixed_aspect = True
+            yield
+            # on key release
+            self._fixed_aspect = False
+
+    def initialize_mouse_events(self, viewer):
+        """Adds event handling functions to the layer"""
+
+        @viewer.mouse_move_callbacks.append
+        def mouse_move(viewer, event):
+            if (
+                not self._interaction_box_model.show
+                or self._interaction_box_model._box is None
+            ):
+                return
+
+            # The _box of the visual model has the handle
+            box = self._ref_interaction_box_visual()._box
+            coord = [event.position[i] for i in viewer.dims.displayed]
+            distances = abs(box - coord)
+
+            # Get the vertex sizes
+            sizes = (
+                self._ref_interaction_box_visual()._vertex_size / 2
+            ) / self._ref_viewer().camera.zoom
+
+            # Check if any matching vertices
+            matches = np.all(distances <= sizes, axis=1).nonzero()
+            if len(matches[0]) > 0:
+                self._selected_vertex = matches[0][-1]
+                # Exclde center vertex
+                if self._selected_vertex == Box.CENTER:
+                    self._selected_vertex = None
+            else:
+                self._selected_vertex = None
+            self._interaction_box_model.selected_vertex = self._selected_vertex
+
+        @viewer.mouse_drag_callbacks.append
+        def mouse_drag(viewer, event):
+            if not self._interaction_box_model.show:
+                return
+
+            # Handling drag start, decide what action to take
+            self._set_drag_start_values(
+                viewer, [event.position[i] for i in viewer.dims.displayed]
+            )
+            drag_callback = None
+            final_callback = None
+            if self._selected_vertex is not None:
+                if self._selected_vertex == Box.HANDLE:
+                    drag_callback = self._on_drag_rotation
+                    final_callback = self._on_final_transform
+                    yield
+                else:
+                    self._fixed_vertex = (
+                        self._selected_vertex + 4
+                    ) % Box.LEN_WITHOUT_HANDLE
+                    drag_callback = self._on_drag_scale
+                    final_callback = self._on_final_transform
+                    yield
+            else:
+                if (
+                    self._interaction_box_model._box is not None
+                    and self._interaction_box_model.show
+                    and inside_boxes(
+                        np.array(
+                            [
+                                self._interaction_box_model._box
+                                - self._drag_start_coordinates
+                            ]
+                        )
+                    )[0]
+                ):
+                    drag_callback = self._on_drag_translate
+                    final_callback = self._on_final_transform
+
+                    yield
+                else:
+                    if self._interaction_box_model.allow_new_selection:
+                        self._interaction_box_model.points = None
+                        self._interaction_box_model.transform = Affine()
+                        drag_callback = self._on_drag_newbox
+                        final_callback = self._on_end_newbox
+                    yield
+            # Handle events during dragging
+            while event.type == 'mouse_move':
+                if drag_callback is not None:
+                    drag_callback(viewer, event)
+                yield
+
+            if final_callback is not None:
+                final_callback(viewer, event)
+
+            self._clear_drag_start_values()
+
+    def _set_drag_start_values(self, viewer, position):
+        """Gets called whenever a drag is started to remember starting values"""
+
+        self._drag_start_coordinates = np.array(position)
+        self._drag_start_box = np.copy(self._ref_interaction_box_visual()._box)
+        self._interaction_box_model.transform_start = (
+            self._interaction_box_model.transform
+        )
+
+    def _clear_drag_start_values(self):
+        """Gets called at the end of a drag to reset remembered values"""
+
+        self._drag_start_coordinates = None
+        self._drag_start_box = None
+
+    def _on_drag_rotation(self, viewer, event):
+        """Gets called upon mouse_move in the case of a rotation"""
+        center = self._drag_start_box[Box.CENTER]
+        handle = self._drag_start_box[Box.HANDLE]
+        mouse_offset = (
+            np.array([event.position[i] for i in viewer.dims.displayed])
+            - center
+        )
+        handle_offset = handle - center
+        angle = np.degrees(
+            np.arctan2(mouse_offset[0], mouse_offset[1])
+            - np.arctan2(handle_offset[0], handle_offset[1])
+        )
+
+        if np.linalg.norm(mouse_offset) < 1:
+            angle = 0
+        elif self._fixed_aspect:
+            angle = np.round(angle / 45) * 45
+
+        tform1 = Affine(translate=-center)
+        tform2 = Affine(rotate=-angle)
+        tform3 = Affine(translate=center)
+        transform = (
+            tform3.compose(tform2)
+            .compose(tform1)
+            .compose(self._interaction_box_model.transform_start)
+        )
+        self._interaction_box_model.transform = transform
+        self._interaction_box_model.transform_drag = transform
+
+    def _on_drag_scale(self, viewer, event):
+        """Gets called upon mouse_move in the case of a scaling operation"""
+
+        # Transform everything back into axis-aligned space with fixed point at origin
+        transform = self._interaction_box_model.transform_start.inverse
+        center = transform(self._drag_start_box[self._fixed_vertex])
+        transform = Affine(translate=-center).compose(transform)
+        coord = transform(
+            np.array([event.position[i] for i in viewer.dims.displayed])
+        )
+        drag_start = transform(self._drag_start_box[self._selected_vertex])
+        # If sidepoint of fixed aspect ratio project offset onto vector along which to scale
+        # Since the fixed verted is now at the origin this vector is drag_start
+        if self._fixed_aspect or self._selected_vertex % 2 == 1:
+            offset = coord - drag_start
+            offset_proj = (
+                np.dot(drag_start, offset) / (np.linalg.norm(drag_start) ** 2)
+            ) * drag_start
+
+            # Prevent numeric instabilities
+            offset_proj[np.abs(offset_proj) < 1e-5] = 0
+            drag_start[drag_start == 0.0] = 1e-5
+
+            scale = np.array([1.0, 1.0]) + (offset_proj) / drag_start
+        else:
+            scale = coord / drag_start
+
+        # Apply scaling
+        transform = Affine(scale=scale).compose(transform)
+
+        # translate back and apply intial rotation again
+        transform = Affine(translate=center).compose(transform)
+        transform = self._interaction_box_model.transform_start.compose(
+            transform
+        )
+        # Chain with original transform
+        transform = transform.compose(
+            self._interaction_box_model.transform_start
+        )
+
+        self._interaction_box_model.transform = transform
+        self._interaction_box_model.transform_drag = transform
+
+    def _on_drag_translate(self, viewer, event):
+        """Gets called upon mouse_move in the case of a translation operation"""
+
+        offset = (
+            np.array([event.position[i] for i in viewer.dims.displayed])
+            - self._drag_start_coordinates
+        )
+
+        transform = Affine(translate=offset).compose(
+            self._interaction_box_model.transform_start
+        )
+        self._interaction_box_model.transform = transform
+        self._interaction_box_model.transform_drag = transform
+
+    def _on_final_transform(self, viewer, event):
+        """Gets called upon mouse_move in the case of a translation operation"""
+        self._interaction_box_model.transform_final = (
+            self._interaction_box_model.transform
+        )
+
+    def _on_drag_newbox(self, viewer, event):
+        """Gets called upon mouse_move in the case of a drawing a new box"""
+
+        self._interaction_box_model.points = np.array(
+            [
+                self._drag_start_coordinates,
+                np.array([event.position[i] for i in viewer.dims.displayed]),
+            ]
+        )
+        self._interaction_box_model.show = True
+        self._interaction_box_model.show_handle = False
+        self._interaction_box_model.show_vertices = False
+        self._interaction_box_model.selection_box_drag = (
+            self._interaction_box_model._box[Box.WITHOUT_HANDLE]
+        )
+
+    def _on_end_newbox(self, viewer, event):
+        """Gets called when dragging ends in the case of a drawing a new box"""
+
+        if self._interaction_box_model._box is not None:
+            self._interaction_box_model.selection_box_final = (
+                self._interaction_box_model._box[Box.WITHOUT_HANDLE]
+            )
+
+    def _reset_active_layer_affine(self, event=None):
+        active_layer = self._ref_viewer().layers.selection.active
+        if active_layer is not None:
+            active_layer.affine = Affine(ndim=active_layer.ndim)
+
+    def _transform_active_layer(self, event=None):
+        active_layer = self._ref_viewer().layers.selection.active
+        if active_layer is not None and hasattr(active_layer, 'mode'):
+            if active_layer.mode != 'transform':
+                active_layer.mode = 'transform'
+            else:
+                active_layer.mode = 'pan_zoom'
diff --git a/napari/layers/image/_image_key_bindings.py b/napari/layers/image/_image_key_bindings.py
--- a/napari/layers/image/_image_key_bindings.py
+++ b/napari/layers/image/_image_key_bindings.py
@@ -1,34 +1,34 @@
-from __future__ import annotations
-
-from ...layers.utils.layer_utils import register_layer_action
-from ...utils.translations import trans
-from ._image_constants import Mode
-from .image import Image
-
-
-@Image.bind_key('Space')
-def hold_to_pan_zoom(layer):
-    """Hold to pan and zoom in the viewer."""
-    if layer._mode != Mode.PAN_ZOOM:
-        # on key press
-        prev_mode = layer.mode
-        layer.mode = Mode.PAN_ZOOM
-
-        yield
-
-        # on key release
-        layer.mode = prev_mode
-
-
-def register_image_action(description):
-    return register_layer_action(Image, description)
-
-
-@register_image_action(trans._('Transform'))
-def activate_image_select_mode(layer):
-    layer.mode = Mode.TRANSFORM
-
-
-@register_image_action(trans._('Pan/zoom'))
-def activate_image_pan_zoom_mode(layer):
-    layer.mode = Mode.PAN_ZOOM
+from __future__ import annotations
+
+from ...layers.utils.layer_utils import register_layer_action
+from ...utils.translations import trans
+from ._image_constants import Mode
+from .image import Image
+
+
+@Image.bind_key('Space')
+def hold_to_pan_zoom(layer):
+    """Hold to pan and zoom in the viewer."""
+    if layer._mode != Mode.PAN_ZOOM:
+        # on key press
+        prev_mode = layer.mode
+        layer.mode = Mode.PAN_ZOOM
+
+        yield
+
+        # on key release
+        layer.mode = prev_mode
+
+
+def register_image_action(description):
+    return register_layer_action(Image, description)
+
+
+@register_image_action(trans._('Transform'))
+def activate_image_select_mode(layer):
+    layer.mode = Mode.TRANSFORM
+
+
+@register_image_action(trans._('Pan/zoom'))
+def activate_image_pan_zoom_mode(layer):
+    layer.mode = Mode.PAN_ZOOM


Test patch:
diff --git a/napari/_tests/test_interactive_transforms.py b/napari/_tests/test_interactive_transforms.py
--- a/napari/_tests/test_interactive_transforms.py
+++ b/napari/_tests/test_interactive_transforms.py
@@ -1,140 +1,140 @@
-import numpy as np
-import pytest
-from vispy import keys
-
-from napari.components._interaction_box_constants import Box
-from napari.utils.transforms import Affine
-
-
-def check_corners_of_axis_aligned_interaction_box(
-    box, *, top_left_corner=[0, 0], bottom_right_corner=None
-):
-    if not np.allclose(box[Box.TOP_LEFT], np.array(top_left_corner) - 0.5):
-        pytest.fail(
-            f"Top-left corner incorrect {box[Box.TOP_LEFT]} vs {np.array(top_left_corner) - 0.5}"
-        )
-    if not np.allclose(
-        box[Box.BOTTOM_RIGHT], np.array(bottom_right_corner) - 0.5
-    ):
-        pytest.fail(
-            f"Bottom-right corner incorrect {box[Box.BOTTOM_RIGHT]} vs {np.array(bottom_right_corner) - 0.5}"
-        )
-
-
-def test_interaction_box_display(make_napari_viewer):
-    viewer = make_napari_viewer()
-
-    data = np.random.random((2, 6, 30, 40))
-    layer = viewer.add_image(data)
-
-    layer.mode = 'transform'
-
-    assert viewer.overlays.interaction_box.show
-    check_corners_of_axis_aligned_interaction_box(
-        viewer.overlays.interaction_box._box,
-        top_left_corner=[0, 0],
-        bottom_right_corner=[30, 40],
-    )
-
-
-def test_disable_with_3d(make_napari_viewer):
-    viewer = make_napari_viewer()
-
-    data = np.random.random((2, 6, 30, 40))
-    layer = viewer.add_image(data)
-
-    layer.mode = 'transform'
-    viewer.dims.ndisplay = 3
-    assert layer.mode == 'pan_zoom'
-    with pytest.warns(UserWarning):
-        layer.mode = 'transform'
-    assert layer.mode == 'pan_zoom'
-
-
-def test_disable_on_layer_cange(make_napari_viewer):
-    viewer = make_napari_viewer()
-
-    data = np.random.random((2, 6, 30, 40))
-    layer = viewer.add_image(data)
-
-    layer.mode = 'transform'
-    viewer.add_image(data)
-    assert viewer.overlays.interaction_box.show is False
-    viewer.layers.selection.active = layer
-    assert viewer.overlays.interaction_box.show is True
-
-
-def test_interaction_box_dim_change(make_napari_viewer):
-
-    viewer = make_napari_viewer()
-
-    data = np.random.random((2, 6, 30, 40))
-    layer = viewer.add_image(data)
-
-    layer.mode = 'transform'
-
-    viewer.dims._roll()
-
-    check_corners_of_axis_aligned_interaction_box(
-        viewer.overlays.interaction_box._box,
-        top_left_corner=[0, 0],
-        bottom_right_corner=[6, 30],
-    )
-
-    viewer.dims._transpose()
-
-    check_corners_of_axis_aligned_interaction_box(
-        viewer.overlays.interaction_box._box,
-        top_left_corner=[0, 0],
-        bottom_right_corner=[30, 6],
-    )
-
-
-def test_vertex_highlight(make_napari_viewer):
-    viewer = make_napari_viewer()
-
-    data = np.random.random((2, 6, 30, 40))
-    layer = viewer.add_image(data)
-
-    layer.mode = 'transform'
-
-    viewer.overlays.interaction_box.selected_vertex = 9
-
-    np.testing.assert_almost_equal(
-        viewer.window._qt_viewer.interaction_box_visual.round_marker_node._data[
-            'a_bg_color'
-        ][
-            0
-        ][
-            :-1
-        ],
-        viewer.window._qt_viewer.interaction_box_visual._highlight_color,
-    )
-
-
-def test_panzoom_on_space(make_napari_viewer):
-    viewer = make_napari_viewer()
-    view = viewer.window._qt_viewer
-    data = np.random.random((2, 6, 30, 40))
-    layer = viewer.add_image(data)
-
-    layer.mode = 'transform'
-    view.canvas.events.key_press(key=keys.Key('Space'))
-    assert layer.mode == 'pan_zoom'
-    assert viewer.overlays.interaction_box.show is False
-
-
-def test_transform_coupling(make_napari_viewer):
-    viewer = make_napari_viewer()
-
-    data = np.random.random((2, 6, 30, 40))
-    layer = viewer.add_image(data)
-    layer.mode = 'transform'
-
-    layer.affine = Affine(scale=[0.5, 0.5, 0.5, 0.5])
-    np.testing.assert_almost_equal(
-        viewer.overlays.interaction_box.transform.scale, [0.5, 0.5]
-    )
-
-    viewer.overlays.interaction_box.transform_drag = Affine(scale=[2.0, 2.0])
-    np.testing.assert_almost_equal(layer.affine.scale, [0.5, 0.5, 2.0, 2.0])
+import numpy as np
+import pytest
+from vispy import keys
+
+from napari.components._interaction_box_constants import Box
+from napari.utils.transforms import Affine
+
+
+def check_corners_of_axis_aligned_interaction_box(
+    box, *, top_left_corner=[0, 0], bottom_right_corner=None
+):
+    if not np.allclose(box[Box.TOP_LEFT], np.array(top_left_corner) - 0.5):
+        pytest.fail(
+            f"Top-left corner incorrect {box[Box.TOP_LEFT]} vs {np.array(top_left_corner) - 0.5}"
+        )
+    if not np.allclose(
+        box[Box.BOTTOM_RIGHT], np.array(bottom_right_corner) - 0.5
+    ):
+        pytest.fail(
+            f"Bottom-right corner incorrect {box[Box.BOTTOM_RIGHT]} vs {np.array(bottom_right_corner) - 0.5}"
+        )
+
+
+def test_interaction_box_display(make_napari_viewer):
+    viewer = make_napari_viewer()
+
+    data = np.random.random((2, 6, 30, 40))
+    layer = viewer.add_image(data)
+
+    layer.mode = 'transform'
+
+    assert viewer.overlays.interaction_box.show
+    check_corners_of_axis_aligned_interaction_box(
+        viewer.overlays.interaction_box._box,
+        top_left_corner=[0, 0],
+        bottom_right_corner=[30, 40],
+    )
+
+
+def test_disable_with_3d(make_napari_viewer):
+    viewer = make_napari_viewer()
+
+    data = np.random.random((2, 6, 30, 40))
+    layer = viewer.add_image(data)
+
+    layer.mode = 'transform'
+    viewer.dims.ndisplay = 3
+    assert layer.mode == 'pan_zoom'
+    with pytest.warns(UserWarning):
+        layer.mode = 'transform'
+    assert layer.mode == 'pan_zoom'
+
+
+def test_disable_on_layer_cange(make_napari_viewer):
+    viewer = make_napari_viewer()
+
+    data = np.random.random((2, 6, 30, 40))
+    layer = viewer.add_image(data)
+
+    layer.mode = 'transform'
+    viewer.add_image(data)
+    assert viewer.overlays.interaction_box.show is False
+    viewer.layers.selection.active = layer
+    assert viewer.overlays.interaction_box.show is True
+
+
+def test_interaction_box_dim_change(make_napari_viewer):
+
+    viewer = make_napari_viewer()
+
+    data = np.random.random((2, 6, 30, 40))
+    layer = viewer.add_image(data)
+
+    layer.mode = 'transform'
+
+    viewer.dims._roll()
+
+    check_corners_of_axis_aligned_interaction_box(
+        viewer.overlays.interaction_box._box,
+        top_left_corner=[0, 0],
+        bottom_right_corner=[6, 30],
+    )
+
+    viewer.dims._transpose()
+
+    check_corners_of_axis_aligned_interaction_box(
+        viewer.overlays.interaction_box._box,
+        top_left_corner=[0, 0],
+        bottom_right_corner=[30, 6],
+    )
+
+
+def test_vertex_highlight(make_napari_viewer):
+    viewer = make_napari_viewer()
+
+    data = np.random.random((2, 6, 30, 40))
+    layer = viewer.add_image(data)
+
+    layer.mode = 'transform'
+
+    viewer.overlays.interaction_box.selected_vertex = 9
+
+    np.testing.assert_almost_equal(
+        viewer.window._qt_viewer.interaction_box_visual.round_marker_node._data[
+            'a_bg_color'
+        ][
+            0
+        ][
+            :-1
+        ],
+        viewer.window._qt_viewer.interaction_box_visual._highlight_color,
+    )
+
+
+def test_panzoom_on_space(make_napari_viewer):
+    viewer = make_napari_viewer()
+    view = viewer.window._qt_viewer
+    data = np.random.random((2, 6, 30, 40))
+    layer = viewer.add_image(data)
+
+    layer.mode = 'transform'
+    view.canvas.events.key_press(key=keys.Key('Space'))
+    assert layer.mode == 'pan_zoom'
+    assert viewer.overlays.interaction_box.show is False
+
+
+def test_transform_coupling(make_napari_viewer):
+    viewer = make_napari_viewer()
+
+    data = np.random.random((2, 6, 30, 40))
+    layer = viewer.add_image(data)
+    layer.mode = 'transform'
+
+    layer.affine = Affine(scale=[0.5, 0.5, 0.5, 0.5])
+    np.testing.assert_almost_equal(
+        viewer.overlays.interaction_box.transform.scale, [0.5, 0.5]
+    )
+
+    viewer.overlays.interaction_box.transform_drag = Affine(scale=[2.0, 2.0])
+    np.testing.assert_almost_equal(layer.affine.scale, [0.5, 0.5, 2.0, 2.0])