diff --git a/napari/_qt/widgets/qt_viewer_buttons.py b/napari/_qt/widgets/qt_viewer_buttons.py index 7c5d4f4900d..52b4a82a8c8 100644 --- a/napari/_qt/widgets/qt_viewer_buttons.py +++ b/napari/_qt/widgets/qt_viewer_buttons.py @@ -143,11 +143,11 @@ def __init__(self, viewer: 'ViewerModel') -> None: ) self.gridViewButton = gvb gvb.setCheckable(True) - gvb.setChecked(viewer.grid.enabled) + gvb.setChecked(viewer.canvases.grid_enabled) gvb.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) gvb.customContextMenuRequested.connect(self._open_grid_popup) - @self.viewer.grid.events.enabled.connect + @self.viewer.canvases.events.grid_enabled.connect def _set_grid_mode_checkstate(event): gvb.setChecked(event.value) diff --git a/napari/_vispy/canvas.py b/napari/_vispy/canvas.py index 4c605ee4254..b0bff79e50d 100644 --- a/napari/_vispy/canvas.py +++ b/napari/_vispy/canvas.py @@ -156,8 +156,51 @@ def __init__( self.viewer.camera.events.zoom.connect(self._on_cursor) self.viewer.layers.events.reordered.connect(self._reorder_layers) self.viewer.layers.events.removed.connect(self._remove_layer) + self.viewer.canvases.events.grid_enabled.connect(self._on_grid_change) + self.viewer.canvases.events.stride.connect(self._on_grid_change) self.destroyed.connect(self._disconnect_theme) + def _on_grid_change(self): + """Change grid view""" + if self.viewer.canvases.grid_enabled: + grid_shape, n_gridboxes = self.viewer.canvases.actual_shape( + len(self.layer_to_visual) + ) + + self.grid = self.central_widget.add_grid() + camera = self.camera._view.camera + self.grid_views = [ + self.grid.add_view( + row=y, col=x, camera=camera if y == 0 and x == 0 else None + ) + for y in range(grid_shape[0]) + for x in range(grid_shape[1]) + if x * y < n_gridboxes + ] + self.camera._view = self.grid_views[0] + self.central_widget.remove_widget(self.view) + # del self.view + self.grid_cameras = [ + VispyCamera( + self.grid_views[i], self.viewer.camera, self.viewer.dims + ) + for i in range(len(self.grid_views[1:])) + ] + + for ind, layer in enumerate(self.layer_to_visual.values()): + if ind != 0: + self.grid_views[ind].camera = self.grid_cameras[ + ind - 1 + ]._view.camera + self.grid_views[ind].camera.link(self.grid_views[0].camera) + layer.node.parent = self.grid_views[ind].scene + else: + for layer in self.layer_to_visual.values(): + layer.node.parent = self.view.scene + self.camera._view = self.view + self.central_widget.remove_widget(self.grid) + del self.grid + @property def destroyed(self) -> pyqtBoundSignal: return self._scene_canvas._backend.destroyed @@ -314,7 +357,11 @@ def _map_canvas2world( of the viewer. """ nd = self.viewer.dims.ndisplay - transform = self.view.scene.transform + # TODO look into how to extend this to all grid boxes + if self.viewer.canvases.grid_enabled: + transform = self.grid_views[0].scene.transform + else: + transform = self.view.scene.transform mapped_position = transform.imap(list(position))[:nd] position_world_slice = mapped_position[::-1] diff --git a/napari/components/_tests/test_grid.py b/napari/components/_tests/test_grid.py index c0b4361bcf2..0fe0c93cff9 100644 --- a/napari/components/_tests/test_grid.py +++ b/napari/components/_tests/test_grid.py @@ -1,84 +1,84 @@ -from napari.components.grid import GridCanvas +from napari.components.canvas import Canvas def test_grid_creation(): """Test creating grid object""" - grid = GridCanvas() - assert grid is not None - assert not grid.enabled - assert grid.shape == (-1, -1) - assert grid.stride == 1 + canvas = Canvas() + assert canvas is not None + assert not canvas.grid_enabled + assert canvas.shape == (-1, -1) + assert canvas.stride == 1 def test_shape_stride_creation(): """Test creating grid object""" - grid = GridCanvas(shape=(3, 4), stride=2) - assert grid.shape == (3, 4) - assert grid.stride == 2 + canvas = Canvas(shape=(3, 4), stride=2) + assert canvas.shape == (3, 4) + assert canvas.stride == 2 def test_actual_shape_and_position(): """Test actual shape""" - grid = GridCanvas(enabled=True) - assert grid.enabled + canvas = Canvas(grid_enabled=True) + assert canvas.grid_enabled # 9 layers get put in a (3, 3) grid - assert grid.actual_shape(9) == (3, 3) - assert grid.position(0, 9) == (0, 0) - assert grid.position(2, 9) == (0, 2) - assert grid.position(3, 9) == (1, 0) - assert grid.position(8, 9) == (2, 2) + assert canvas.actual_shape(9) == ((3, 3), 9) + assert canvas.position(0, 9) == (0, 0) + assert canvas.position(2, 9) == (0, 2) + assert canvas.position(3, 9) == (1, 0) + assert canvas.position(8, 9) == (2, 2) # 5 layers get put in a (2, 3) grid - assert grid.actual_shape(5) == (2, 3) - assert grid.position(0, 5) == (0, 0) - assert grid.position(2, 5) == (0, 2) - assert grid.position(3, 5) == (1, 0) + assert canvas.actual_shape(5) == ((2, 3), 5) + assert canvas.position(0, 5) == (0, 0) + assert canvas.position(2, 5) == (0, 2) + assert canvas.position(3, 5) == (1, 0) # 10 layers get put in a (3, 4) grid - assert grid.actual_shape(10) == (3, 4) - assert grid.position(0, 10) == (0, 0) - assert grid.position(2, 10) == (0, 2) - assert grid.position(3, 10) == (0, 3) - assert grid.position(8, 10) == (2, 0) + assert canvas.actual_shape(10) == ((3, 4), 10) + assert canvas.position(0, 10) == (0, 0) + assert canvas.position(2, 10) == (0, 2) + assert canvas.position(3, 10) == (0, 3) + assert canvas.position(8, 10) == (2, 0) def test_actual_shape_with_stride(): """Test actual shape""" - grid = GridCanvas(enabled=True, stride=2) - assert grid.enabled + canvas = Canvas(grid_enabled=True, stride=2) + assert canvas.grid_enabled # 7 layers get put in a (2, 2) grid - assert grid.actual_shape(7) == (2, 2) - assert grid.position(0, 7) == (0, 0) - assert grid.position(1, 7) == (0, 0) - assert grid.position(2, 7) == (0, 1) - assert grid.position(3, 7) == (0, 1) - assert grid.position(6, 7) == (1, 1) + assert canvas.actual_shape(7) == ((2, 2), 4) + assert canvas.position(0, 7) == (0, 0) + assert canvas.position(1, 7) == (0, 0) + assert canvas.position(2, 7) == (0, 1) + assert canvas.position(3, 7) == (0, 1) + assert canvas.position(6, 7) == (1, 1) # 3 layers get put in a (1, 2) grid - assert grid.actual_shape(3) == (1, 2) - assert grid.position(0, 3) == (0, 0) - assert grid.position(1, 3) == (0, 0) - assert grid.position(2, 3) == (0, 1) + assert canvas.actual_shape(3) == ((1, 2), 2) + assert canvas.position(0, 3) == (0, 0) + assert canvas.position(1, 3) == (0, 0) + assert canvas.position(2, 3) == (0, 1) def test_actual_shape_and_position_negative_stride(): """Test actual shape""" - grid = GridCanvas(enabled=True, stride=-1) - assert grid.enabled + canvas = Canvas(grid_enabled=True, stride=-1) + assert canvas.grid_enabled # 9 layers get put in a (3, 3) grid - assert grid.actual_shape(9) == (3, 3) - assert grid.position(0, 9) == (2, 2) - assert grid.position(2, 9) == (2, 0) - assert grid.position(3, 9) == (1, 2) - assert grid.position(8, 9) == (0, 0) + assert canvas.actual_shape(9) == ((3, 3), 9) + assert canvas.position(0, 9) == (2, 2) + assert canvas.position(2, 9) == (2, 0) + assert canvas.position(3, 9) == (1, 2) + assert canvas.position(8, 9) == (0, 0) def test_actual_shape_grid_disabled(): """Test actual shape with grid disabled""" - grid = GridCanvas() - assert not grid.enabled - assert grid.actual_shape(9) == (1, 1) - assert grid.position(3, 9) == (0, 0) + canvas = Canvas() + assert not canvas.grid_enabled + assert canvas.actual_shape(9) == ((1, 1), 0) + assert canvas.position(3, 9) == (0, 0) diff --git a/napari/components/_viewer_key_bindings.py b/napari/components/_viewer_key_bindings.py index f8b49e84cee..76624489cd6 100644 --- a/napari/components/_viewer_key_bindings.py +++ b/napari/components/_viewer_key_bindings.py @@ -123,7 +123,7 @@ def transpose_axes(viewer: Viewer): @register_viewer_action(trans._("Toggle grid mode.")) def toggle_grid(viewer: Viewer): - viewer.grid.enabled = not viewer.grid.enabled + viewer.canvases.grid_enabled = not viewer.canvases.grid_enabled @register_viewer_action(trans._("Toggle visibility of selected layers")) diff --git a/napari/components/grid.py b/napari/components/canvas.py similarity index 61% rename from napari/components/grid.py rename to napari/components/canvas.py index f60c4fc4173..999b2dcbf71 100644 --- a/napari/components/grid.py +++ b/napari/components/canvas.py @@ -6,35 +6,12 @@ from napari.utils.events import EventedModel -class GridCanvas(EventedModel): - """Grid for canvas. - - Right now the only grid mode that is still inside one canvas with one - camera, but future grid modes could support multiple canvases. - - Attributes - ---------- - enabled : bool - If grid is enabled or not. - stride : int - Number of layers to place in each grid square before moving on to - the next square. The default ordering is to place the most visible - layer in the top left corner of the grid. A negative stride will - cause the order in which the layers are placed in the grid to be - reversed. - shape : 2-tuple of int - Number of rows and columns in the grid. A value of -1 for either or - both of will be used the row and column numbers will trigger an - auto calculation of the necessary grid shape to appropriately fill - all the layers at the appropriate stride. - """ - - # fields +class Canvas(EventedModel): + grid_enabled: bool = False stride: GridStride = 1 shape: Tuple[GridHeight, GridWidth] = (-1, -1) - enabled: bool = False - def actual_shape(self, nlayers: int = 1) -> Tuple[int, int]: + def actual_shape(self, nlayers: int = 1) -> Tuple[Tuple[int, int], int]: """Return the actual shape of the grid. This will return the shape parameter, unless one of the row @@ -54,11 +31,11 @@ def actual_shape(self, nlayers: int = 1) -> Tuple[int, int]: shape : 2-tuple of int Number of rows and columns in the grid. """ - if not self.enabled: - return (1, 1) + if not self.grid_enabled: + return (1, 1), 0 if nlayers == 0: - return (1, 1) + return (1, 1), 0 n_row, n_column = self.shape n_grid_squares = np.ceil(nlayers / abs(self.stride)).astype(int) @@ -74,7 +51,7 @@ def actual_shape(self, nlayers: int = 1) -> Tuple[int, int]: n_row = max(1, n_row) n_column = max(1, n_column) - return (n_row, n_column) + return (n_row, n_column), n_grid_squares def position(self, index: int, nlayers: int) -> Tuple[int, int]: """Return the position of a given linear index in grid. @@ -93,16 +70,16 @@ def position(self, index: int, nlayers: int) -> Tuple[int, int]: position : 2-tuple of int Row and column position of current index in the grid. """ - if not self.enabled: + if not self.grid_enabled: return (0, 0) - n_row, n_column = self.actual_shape(nlayers) + shape, n_gridboxes = self.actual_shape(nlayers) # Adjust for forward or reverse ordering adj_i = nlayers - index - 1 if self.stride < 0 else index adj_i = adj_i // abs(self.stride) - adj_i = adj_i % (n_row * n_column) - i_row = adj_i // n_column - i_column = adj_i % n_column + adj_i = adj_i % (shape[0] * shape[1]) + i_row = adj_i // shape[1] + i_column = adj_i % shape[1] return (i_row, i_column) diff --git a/napari/components/viewer_model.py b/napari/components/viewer_model.py index b5d0847da30..ecf56a6f7ee 100644 --- a/napari/components/viewer_model.py +++ b/napari/components/viewer_model.py @@ -26,9 +26,9 @@ from napari.components._layer_slicer import _LayerSlicer from napari.components._viewer_mouse_bindings import dims_scroll from napari.components.camera import Camera +from napari.components.canvas import Canvas from napari.components.cursor import Cursor from napari.components.dims import Dims -from napari.components.grid import GridCanvas from napari.components.layerlist import LayerList from napari.components.overlays import ( AxesOverlay, @@ -174,7 +174,7 @@ class ViewerModel(KeymapProvider, MousemapProvider, EventedModel): camera: Camera = Field(default_factory=Camera, allow_mutation=False) cursor: Cursor = Field(default_factory=Cursor, allow_mutation=False) dims: Dims = Field(default_factory=Dims, allow_mutation=False) - grid: GridCanvas = Field(default_factory=GridCanvas, allow_mutation=False) + canvases: Canvas = Field(default_factory=Canvas, allow_mutation=False) layers: LayerList = Field( default_factory=LayerList, allow_mutation=False ) # Need to create custom JSON encoder for layer! @@ -249,8 +249,6 @@ def __init__( ) # Connect events - self.grid.events.connect(self.reset_view) - self.grid.events.connect(self._on_grid_change) self.dims.events.ndisplay.connect(self._update_layers) self.dims.events.ndisplay.connect(self.reset_view) self.dims.events.order.connect(self._update_layers) @@ -261,7 +259,6 @@ def __init__( ) self.layers.events.inserted.connect(self._on_add_layer) self.layers.events.removed.connect(self._on_remove_layer) - self.layers.events.reordered.connect(self._on_grid_change) self.layers.events.reordered.connect(self._on_layers_change) self.layers.selection.events.active.connect(self._on_active_layer) @@ -295,8 +292,8 @@ def _update_viewer_grid(self): settings = get_settings() - self.grid.stride = settings.application.grid_stride - self.grid.shape = ( + self.canvases.stride = settings.application.grid_stride + self.canvases.shape = ( settings.application.grid_height, settings.application.grid_width, ) @@ -365,7 +362,9 @@ def reset_view(self): extent = self._sliced_extent_world_augmented scene_size = extent[1] - extent[0] corner = extent[0] - grid_size = list(self.grid.actual_shape(len(self.layers))) + grid_size, n_grid_squares = list( + self.canvases.actual_shape(len(self.layers)) + ) if len(scene_size) > len(grid_size): grid_size = [1] * (len(scene_size) - len(grid_size)) + grid_size size = np.multiply(scene_size, grid_size) @@ -521,32 +520,6 @@ def _update_status_bar_from_cursor(self, event=None): else: self.status = 'Ready' - def _on_grid_change(self): - """Arrange the current layers is a 2D grid.""" - extent = self._sliced_extent_world_augmented - n_layers = len(self.layers) - for i, layer in enumerate(self.layers): - i_row, i_column = self.grid.position(n_layers - 1 - i, n_layers) - self._subplot(layer, (i_row, i_column), extent) - - def _subplot(self, layer, position, extent): - """Shift a layer to a specified position in a 2D grid. - - Parameters - ---------- - layer : napari.layers.Layer - Layer that is to be moved. - position : 2-tuple of int - New position of layer in grid. - extent : array, shape (2, D) - Extent of the world. - """ - scene_shift = extent[1] - extent[0] - translate_2d = np.multiply(scene_shift[-2:], position) - translate = [0] * layer.ndim - translate[-2:] = translate_2d - layer._translate_grid = translate - @property def experimental(self): """Experimental commands for IPython console. @@ -590,7 +563,6 @@ def _on_add_layer(self, event): # Update dims and grid model self._on_layers_change() - self._on_grid_change() # Slice current layer based on dims self._update_layers(layers=[layer])