diff --git a/brainrender_napari/napari_atlas_representation.py b/brainrender_napari/napari_atlas_representation.py index ed31269..462c2ad 100644 --- a/brainrender_napari/napari_atlas_representation.py +++ b/brainrender_napari/napari_atlas_representation.py @@ -3,7 +3,11 @@ import numpy as np from bg_atlasapi import BrainGlobeAtlas from meshio import Mesh +from napari.settings import get_settings from napari.viewer import Viewer +from qtpy.QtCore import Qt +from qtpy.QtGui import QCursor +from qtpy.QtWidgets import QLabel @dataclass @@ -15,23 +19,40 @@ class NapariAtlasRepresentation: mesh_opacity: float = 0.4 mesh_blending: str = "translucent_no_depth" + def __post_init__(self) -> None: + """Setup a custom QLabel tooltip and enable napari layer tooltips""" + self._tooltip = QLabel(self.viewer.window.qt_viewer.parent()) + self._tooltip.setWindowFlags( + Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint + ) + self._tooltip.setAttribute(Qt.WA_ShowWithoutActivating) + self._tooltip.setAlignment(Qt.AlignCenter) + self._tooltip.setStyleSheet("color: black") + napari_settings = get_settings() + napari_settings.appearance.layer_tooltip_visibility = True + def add_to_viewer(self): - """Adds the reference and annotation images to the viewer. + """Adds the reference and annotation images as layers to the viewer. + The layers are connected to the mouse move callback to set tooltip. The reference image's visibility is off, the annotation's is on. """ - self.viewer.add_image( + reference = self.viewer.add_image( self.bg_atlas.reference, scale=self.bg_atlas.resolution, name=f"{self.bg_atlas.atlas_name}_reference", visible=False, ) - self.viewer.add_labels( + + annotation = self.viewer.add_labels( self.bg_atlas.annotation, scale=self.bg_atlas.resolution, name=f"{self.bg_atlas.atlas_name}_annotation", ) + annotation.mouse_move_callbacks.append(self._on_mouse_move) + reference.mouse_move_callbacks.append(self._on_mouse_move) + def add_structure_to_viewer(self, structure_name: str): """Adds the mesh of a structure to the viewer @@ -67,8 +88,58 @@ def _add_mesh(self, mesh: Mesh, name: str, color=None): self.viewer.add_surface((points, cells), **viewer_kwargs) def add_additional_reference(self, additional_reference_key: str): - self.viewer.add_image( + """Adds a given additional reference as a layer to the viewer. + and connects it to the mouse move callback to set tooltip. + """ + additional_reference = self.viewer.add_image( self.bg_atlas.additional_references[additional_reference_key], scale=self.bg_atlas.resolution, name=f"{self.bg_atlas.atlas_name}_{additional_reference_key}_reference", ) + additional_reference.mouse_move_callbacks.append(self._on_mouse_move) + + def _on_mouse_move(self, layer, event): + """Adapts the tooltip according to the cursor position. + + The tooltip is only displayed if + * the viewer is in 2D display + * and the cursor is inside the annotation + * and the user has not switched off layer tooltips. + + Note that layer, event input args are unused, + because all the required info is in + * the bg_atlas.structure_from_coords + * the (screen) cursor position + * the (napari) cursor position + """ + cursor_position = self.viewer.cursor.position + napari_settings = get_settings() + tooltip_visibility = ( + napari_settings.appearance.layer_tooltip_visibility + ) + if ( + tooltip_visibility + and np.all(np.array(cursor_position) > 0) + and self.viewer.dims.ndisplay == 2 + ): + self._tooltip.move(QCursor.pos().x() + 20, QCursor.pos().y() + 20) + try: + structure_acronym = self.bg_atlas.structure_from_coords( + cursor_position, microns=True, as_acronym=True + ) + structure_name = self.bg_atlas.structures[structure_acronym][ + "name" + ] + hemisphere = self.bg_atlas.hemisphere_from_coords( + cursor_position, as_string=True, microns=True + ).capitalize() + tooltip_text = f"{structure_name} | {hemisphere}" + self._tooltip.setText(tooltip_text) + self._tooltip.adjustSize() + self._tooltip.show() + except (KeyError, IndexError): + # cursor position outside the image or in the image background + # so no tooltip to be displayed + # this saves us a bunch of assertions and extra computation + self._tooltip.setText("") + self._tooltip.hide() diff --git a/tests/test_unit/test_napari_atlas_representation.py b/tests/test_unit/test_napari_atlas_representation.py index 481d184..61870d2 100644 --- a/tests/test_unit/test_napari_atlas_representation.py +++ b/tests/test_unit/test_napari_atlas_representation.py @@ -2,6 +2,8 @@ from bg_atlasapi import BrainGlobeAtlas from napari.layers import Image, Labels from numpy import all, allclose +from qtpy.QtCore import QEvent, QPoint, Qt +from qtpy.QtGui import QMouseEvent from brainrender_napari.napari_atlas_representation import ( NapariAtlasRepresentation, @@ -50,6 +52,13 @@ def test_add_to_viewer(make_napari_viewer, expected_atlas_name, anisotropic): assert isinstance(annotation, Labels) assert isinstance(reference, Image) + assert ( + atlas_representation._on_mouse_move in annotation.mouse_move_callbacks + ) + assert ( + atlas_representation._on_mouse_move in reference.mouse_move_callbacks + ) + assert allclose(annotation.extent.world, reference.extent.world) @@ -118,8 +127,83 @@ def test_add_additional_reference(make_napari_viewer): atlas_representation = NapariAtlasRepresentation(atlas, viewer) atlas_representation.add_additional_reference(additional_reference_name) + additional_reference = viewer.layers[0] assert len(viewer.layers) == 1 assert ( - viewer.layers[0].name + additional_reference.name == f"{atlas_name}_{additional_reference_name}_reference" ) + assert ( + atlas_representation._on_mouse_move + in additional_reference.mouse_move_callbacks + ) + + +@pytest.mark.parametrize( + "cursor_position, expected_tooltip_text", + [ + ((6500.0, 4298.5, 9057.6), "Caudoputamen | Left"), + ((-1000, 0, 0), ""), # outside image + ], +) +def test_viewer_tooltip( + make_napari_viewer, mocker, cursor_position, expected_tooltip_text +): + """Checks that the custom callback for mouse movement sets the expected + tooltip text.""" + viewer = make_napari_viewer() + atlas_name = "allen_mouse_100um" + atlas = BrainGlobeAtlas(atlas_name=atlas_name) + atlas_representation = NapariAtlasRepresentation(atlas, viewer) + atlas_representation.add_to_viewer() + annotation = viewer.layers[1] + + event = QMouseEvent( + QEvent.MouseMove, + QPoint(0, 0), # any pos will do to check text + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + # a slight hacky mock of event.pos to circumvent + # the napari read-only wrapper around qt events + mock_event = mocker.patch.object(event, "pos", return_value=(50, 50)) + viewer.cursor.position = cursor_position + atlas_representation._on_mouse_move(annotation, mock_event) + assert atlas_representation._tooltip.text() == expected_tooltip_text + + +def test_too_quick_mouse_move_keyerror(make_napari_viewer, mocker): + """Quickly moving the cursor position can cause + structure_from_coords to be called with a background label. + This test checks that we handle that case gracefully.""" + viewer = make_napari_viewer() + atlas_name = "allen_mouse_100um" + atlas = BrainGlobeAtlas(atlas_name=atlas_name) + atlas_representation = NapariAtlasRepresentation(atlas, viewer) + atlas_representation.add_to_viewer() + annotation = viewer.layers[1] + + event = QMouseEvent( + QEvent.MouseMove, + QPoint(0, 0), # any pos will do to check text + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + # a slight hacky mock of event.pos to circumvent + # the napari read-only wrapper around qt events + mock_event = mocker.patch.object(event, "pos", return_value=(0, 0)) + viewer.cursor.position = (6500.0, 4298.5, 9057.6) + + # Mock the case where a quick mouse move calls structure_from_coords + # with key 0 (background) + mock_structure_from_coords = mocker.patch.object( + atlas_representation.bg_atlas, + "structure_from_coords", + side_effect=KeyError(), + ) + + atlas_representation._on_mouse_move(annotation, mock_event) + mock_structure_from_coords.assert_called_once() + assert atlas_representation._tooltip.text() == ""