diff --git a/README.md b/README.md index 5e0151eefc6..57373680533 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ We're working on [tutorials](https://napari.org/stable/tutorials/), but you can It is recommended to install napari into a virtual environment, like this: ```sh -conda create -y -n napari-env -c conda-forge python=3.10 +conda create -y -n napari-env -c conda-forge python=3.11 conda activate napari-env python -m pip install "napari[all]" ``` @@ -88,7 +88,9 @@ You can see details of [the project roadmap here](https://napari.org/stable/road Contributions are encouraged! Please read our [contributing guide](https://napari.org/dev/developers/contributing/index.html) to get started. Given that we're in an early stage, you may want to reach out on our [GitHub Issues](https://github.com/napari/napari/issues) before jumping in. -If you want to contribute or edit to our documentation, please go to [napari/docs](https://github.com/napari/docs). +If you want to contribute to or edit our documentation, please go to [napari/docs](https://github.com/napari/docs). + +Visit our [project weather report dashboard](https://napari.org/weather-report/) to see metrics and how development is progressing. ## code of conduct diff --git a/napari/_qt/_tests/test_qt_utils.py b/napari/_qt/_tests/test_qt_utils.py index e5bc37185ec..b850a1167e3 100644 --- a/napari/_qt/_tests/test_qt_utils.py +++ b/napari/_qt/_tests/test_qt_utils.py @@ -1,10 +1,15 @@ +from unittest.mock import patch + +import numpy as np import pytest from qtpy.QtCore import QObject, Signal -from qtpy.QtWidgets import QMainWindow +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QApplication, QColorDialog, QMainWindow from napari._qt.utils import ( QBYTE_FLAG, add_flash_animation, + get_color, is_qbyte, qbytearray_to_str, qt_might_be_rich_text, @@ -117,3 +122,50 @@ class X: with pytest.raises(RuntimeError): f.result() assert x.a == 2 + + +def test_get_color(qtbot): + """Test the get_color utility function.""" + widget = QMainWindow() + qtbot.addWidget(widget) + + with patch.object(QColorDialog, 'exec_') as mock: + mock.return_value = QColorDialog.Accepted + color = get_color(None, 'hex') + assert isinstance(color, str), 'Expected string color' + + with patch.object(QColorDialog, 'exec_') as mock: + mock.return_value = QColorDialog.Accepted + color = get_color('#FF00FF', 'hex') + assert isinstance(color, str), 'Expected string color' + assert color == '#ff00ff', 'Expected color to be #FF00FF' + + with patch.object(QColorDialog, 'exec_') as mock: + mock.return_value = QColorDialog.Accepted + color = get_color(None, 'array') + assert not isinstance(color, str), 'Expected array color' + assert isinstance(color, np.ndarray), 'Expected numpy array color' + + with patch.object(QColorDialog, 'exec_') as mock: + mock.return_value = QColorDialog.Accepted + color = get_color(np.asarray([255, 0, 255]), 'array') + assert not isinstance(color, str), 'Expected array color' + assert isinstance(color, np.ndarray), 'Expected numpy array color' + np.testing.assert_array_equal(color, np.asarray([1, 0, 1])) + + with patch.object(QColorDialog, 'exec_') as mock: + mock.return_value = QColorDialog.Accepted + color = get_color(None, 'qcolor') + assert not isinstance(color, np.ndarray), 'Expected QColor color' + assert isinstance(color, QColor), 'Expected QColor color' + + with patch.object(QColorDialog, 'exec_') as mock: + mock.return_value = QColorDialog.Rejected + color = get_color(None, 'qcolor') + assert color is None, 'Expected None color' + + # close still open popup widgets + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QColorDialog): + widget.accept() + widget.close() diff --git a/napari/_qt/_tests/test_qt_viewer.py b/napari/_qt/_tests/test_qt_viewer.py index f4c26a89277..8d5140ecbf2 100644 --- a/napari/_qt/_tests/test_qt_viewer.py +++ b/napari/_qt/_tests/test_qt_viewer.py @@ -441,7 +441,6 @@ def test_points_layer_display_correct_slice_on_scale(make_napari_viewer): np.testing.assert_equal(response.indices, [0]) -@pytest.mark.slow @skip_on_win_ci def test_qt_viewer_clipboard_with_flash(make_napari_viewer, qtbot): viewer = make_napari_viewer() diff --git a/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py b/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py index 2d8730b4b11..53d02ceb8f7 100644 --- a/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py +++ b/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py @@ -4,7 +4,7 @@ import numpy as np import pytest from qtpy.QtCore import Qt -from qtpy.QtWidgets import QPushButton +from qtpy.QtWidgets import QApplication, QColorDialog, QPushButton from napari._qt.layer_controls.qt_image_controls_base import ( QContrastLimitsPopup, @@ -161,3 +161,19 @@ def test_blending_opacity_slider(qtbot): layer.blending = 'translucent' assert layer.blending == 'translucent' assert qtctrl.opacitySlider.isEnabled() + + +@pytest.mark.parametrize('layer', [Image(_IMAGE), Surface(_SURF)]) +def test_custom_colormap(qtbot, layer): + """Test whether colormap button does anything.""" + qtctrl = QtBaseImageControls(layer) + + # check widget popup + assert isinstance(qtctrl.colorbarLabel, QPushButton), ( + 'Colorbar button not found' + ) + qtbot.mouseRelease(qtctrl.colorbarLabel, Qt.MouseButton.LeftButton) + # close still open popup widgets + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QColorDialog): + widget.close() diff --git a/napari/_qt/layer_controls/qt_image_controls.py b/napari/_qt/layer_controls/qt_image_controls.py index 776ba7a2da1..92f39474dce 100644 --- a/napari/_qt/layer_controls/qt_image_controls.py +++ b/napari/_qt/layer_controls/qt_image_controls.py @@ -185,8 +185,7 @@ def __init__(self, layer) -> None: self.colorbarLabel.setVisible(False) else: colormap_layout.addWidget(self.colorbarLabel) - colormap_layout.addWidget(self.colormapComboBox) - colormap_layout.addStretch(1) + colormap_layout.addWidget(self.colormapComboBox, stretch=1) self.layout().addRow(self.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) diff --git a/napari/_qt/layer_controls/qt_image_controls_base.py b/napari/_qt/layer_controls/qt_image_controls_base.py index 509e112ce15..c3e9f9ca4ac 100644 --- a/napari/_qt/layer_controls/qt_image_controls_base.py +++ b/napari/_qt/layer_controls/qt_image_controls_base.py @@ -4,10 +4,9 @@ import numpy as np from qtpy.QtCore import Qt -from qtpy.QtGui import QImage, QPixmap +from qtpy.QtGui import QIcon, QImage, QPixmap from qtpy.QtWidgets import ( QHBoxLayout, - QLabel, QPushButton, QWidget, ) @@ -75,8 +74,8 @@ class QtBaseImageControls(QtLayerControls): Button to transform image layer. clim_popup : napari._qt.qt_range_slider_popup.QRangeSliderPopup Popup widget launching the contrast range slider. - colorbarLabel : qtpy.QtWidgets.QLabel - Label text of colorbar widget. + colorbarLabel : qtpy.QtWidgets.QPushButton + Button showing colorbar widget. Also enables selection of custom colormap. colormapComboBox : qtpy.QtWidgets.QComboBox Dropdown widget for selecting the layer colormap. contrastLimitsSlider : superqt.QRangeSlider @@ -146,9 +145,10 @@ def __init__(self, layer: Image) -> None: connect_setattr(sld.valueChanged, self.layer, 'gamma') self.gammaSlider = sld - self.colorbarLabel = QLabel(parent=self) + self.colorbarLabel = QPushButton(parent=self) self.colorbarLabel.setObjectName('colorbar') self.colorbarLabel.setToolTip(trans._('Colorbar')) + self.colorbarLabel.clicked.connect(self._on_make_colormap) self._on_colormap_change() if self.__class__ == QtBaseImageControls: @@ -158,6 +158,15 @@ def __init__(self, layer: Image) -> None: # widgets. self.layout().addRow(self.button_grid) + def _on_make_colormap(self): + """Make new colormap when colorbarLabel (pushbutton) is pressed.""" + from napari._qt.utils import get_color + from napari.utils.colormaps.colormap_utils import ensure_colormap + + color = get_color(self, mode='hex') + if color: + self.layer.colormap = ensure_colormap(color) + def changeColor(self, text): """Change colormap on the layer model. @@ -215,7 +224,7 @@ def _on_colormap_change(self): cbar.shape[0], QImage.Format_RGBA8888, ) - self.colorbarLabel.setPixmap(QPixmap.fromImage(image)) + self.colorbarLabel.setIcon(QIcon(QPixmap.fromImage(image))) def _on_gamma_change(self): """Receive the layer model gamma change event and update the slider.""" diff --git a/napari/_qt/layer_controls/qt_surface_controls.py b/napari/_qt/layer_controls/qt_surface_controls.py index 22defd6e2d1..f8c88dd8dd9 100644 --- a/napari/_qt/layer_controls/qt_surface_controls.py +++ b/napari/_qt/layer_controls/qt_surface_controls.py @@ -52,8 +52,7 @@ def __init__(self, layer) -> None: colormap_layout = QHBoxLayout() colormap_layout.addWidget(self.colorbarLabel) - colormap_layout.addWidget(self.colormapComboBox) - colormap_layout.addStretch(1) + colormap_layout.addWidget(self.colormapComboBox, stretch=1) shading_comboBox = QComboBox(self) for display_name, shading in SHADING_TRANSLATION.items(): diff --git a/napari/_qt/utils.py b/napari/_qt/utils.py index 51237f2b9ac..32ba1feab31 100644 --- a/napari/_qt/utils.py +++ b/napari/_qt/utils.py @@ -6,6 +6,7 @@ import weakref from collections.abc import Iterable, Sequence from contextlib import contextmanager +from enum import auto from functools import partial import numpy as np @@ -20,6 +21,7 @@ ) from qtpy.QtGui import QColor, QCursor, QDrag, QImage, QPainter, QPixmap from qtpy.QtWidgets import ( + QColorDialog, QGraphicsColorizeEffect, QGraphicsOpacityEffect, QHBoxLayout, @@ -30,13 +32,29 @@ from napari.utils.colormaps.standardize_color import transform_color from napari.utils.events.custom_types import Array -from napari.utils.misc import is_sequence +from napari.utils.misc import StringEnum, is_sequence from napari.utils.translations import trans QBYTE_FLAG = '!QBYTE_' RICH_TEXT_PATTERN = re.compile('<[^\n]+>') +class ColorMode(StringEnum): + """Enum fo selecting the color mode to return the color in. + + ColorMode.HEX + Returns color as hex string. + ColorMode.LOOP + Returns color as a numpy array. + ColorMode.QCOLOR + Returns color as a QColor object + """ + + HEX = auto() + ARRAY = auto() + QCOLOR = auto() + + def is_qbyte(string: str) -> bool: """Check if a string is a QByteArray string. @@ -388,3 +406,44 @@ def in_qt_main_thread() -> bool: True if we are in the main thread, False otherwise. """ return QCoreApplication.instance().thread() == QThread.currentThread() + + +def get_color( + color: str | np.ndarray | QColor | None = None, + mode: ColorMode = ColorMode.HEX, +) -> np.ndarray | None: + """ + Helper function to get a color from q QColorDialog. + + Parameters + ---------- + color : str | np.ndarray | QColor | None + Initial color to display in the dialog. Color will be automatically converted to QColor. + mode : ColorMode + Mode to return the color in (hex, array, QColor). + + Returns + ------- + new_color : str | np.ndarray | QColor + New color in the desired format. + """ + + if isinstance(color, str): + color = QColor(color) + elif isinstance(color, np.ndarray): + color = QColor(*color.astype(int)) + + dlg = QColorDialog(color) + new_color: str | np.ndarray | QColor | None = None + if dlg.exec_(): + new_color = dlg.currentColor() + if mode == ColorMode.HEX: + new_color = new_color.name() + elif mode == ColorMode.ARRAY: + new_color = ( + np.asarray( + [new_color.red(), new_color.green(), new_color.blue()] + ) + / 255 + ) + return new_color diff --git a/napari/_vispy/overlays/scale_bar.py b/napari/_vispy/overlays/scale_bar.py index d1dc64dd993..07b38f5ae13 100644 --- a/napari/_vispy/overlays/scale_bar.py +++ b/napari/_vispy/overlays/scale_bar.py @@ -7,6 +7,7 @@ from napari._vispy.overlays.base import ViewerOverlayMixin, VispyCanvasOverlay from napari._vispy.visuals.scale_bar import ScaleBar +from napari.settings import get_settings from napari.utils._units import PREFERRED_VALUES, get_unit_registry from napari.utils.colormaps.standardize_color import transform_color from napari.utils.theme import get_theme @@ -26,8 +27,12 @@ def __init__(self, *, viewer, overlay, parent=None) -> None: self.x_size = 150 # will be updated on zoom anyways # need to change from defaults because the anchor is in the center self.y_offset = 20 + # TODO: perhaps change name as y_size does not indicate bottom offset. self.y_size = 5 + # In the super().__init__ we see node is scale bar, need to connect its parent, canvas + self.node.events.parent_change.connect(self._on_parent_change) + self.overlay.events.box.connect(self._on_box_change) self.overlay.events.box_color.connect(self._on_data_change) self.overlay.events.color.connect(self._on_data_change) @@ -42,6 +47,30 @@ def __init__(self, *, viewer, overlay, parent=None) -> None: self.reset() + def _on_parent_change(self, event): + """Connect the canvas resize event to scale bar callback function(s).""" + if event.new and self.node.canvas: + event.new.canvas.events.resize.connect( + self._scale_scalebar_on_canvas_resize + ) + event.new.canvas.events.resize.connect(self._scale_font_size) + + def _scale_font_size(self, event): + """Scale the font size in response to a canvas resize""" + self.node.text.font_size = ( + event.source.size[1] + / get_settings().experimental.scale_bar_font_size + ) + + def _scale_scalebar_on_canvas_resize(self, event): + self._target_length = ( + event.source.size[0] / get_settings().experimental.scale_bar_length + ) + self.y_size = event.source.size[1] / 40 + self.x_size = event.source.size[0] / 20 + self.node.line._width = event.source.size[1] / 100 + self._on_zoom_change(force=True) + def _on_unit_change(self): self._unit = get_unit_registry()(self.overlay.unit) self._on_zoom_change(force=True) diff --git a/napari/_vispy/visuals/scale_bar.py b/napari/_vispy/visuals/scale_bar.py index a999e61a849..440301d9da1 100644 --- a/napari/_vispy/visuals/scale_bar.py +++ b/napari/_vispy/visuals/scale_bar.py @@ -8,10 +8,6 @@ def __init__(self) -> None: [ [0, 0], [1, 0], - [0, -5], - [0, 5], - [1, -5], - [1, 5], ] ) @@ -26,7 +22,7 @@ def __init__(self) -> None: anchor_y='top', font_size=10, ), - Line(connect='segments', method='gl', width=3), + Line(connect='strip', method='gl', width=3), ] ) diff --git a/napari/components/layerlist.py b/napari/components/layerlist.py index 7259ce5d0c3..45a1040d497 100644 --- a/napari/components/layerlist.py +++ b/napari/components/layerlist.py @@ -49,9 +49,11 @@ class LayerList(SelectableEventedList[Layer]): moved : (index: int, new_index: int, value: T) emitted after ``value`` is moved from ``index`` to ``new_index`` changed : (index: int, old_value: T, value: T) - emitted when item at ``index`` is changed from ``old_value`` to ``value`` + emitted when item at ``index`` is changed from ``old_value`` to + ``value`` changed : (index: slice, old_value: List[_T], value: List[_T]) - emitted when item at ``index`` is changed from ``old_value`` to ``value`` + emitted when items at ``index``es are changed from ``old_value`` to + ``value`` reordered : (value: self) emitted when the list is reordered (eg. moved/reversed). selection.events.changed : (added: Set[_T], removed: Set[_T]) @@ -62,6 +64,49 @@ class LayerList(SelectableEventedList[Layer]): selection.events._current : (value: _T) emitted when the current item has changed. (Private event) + Notes + ----- + + Note that ``changed`` events are only emitted when an element of the + list changes, *not* when the list itself changes (for example when items + are added or removed). For example, ``layerlist.append(layer)`` will emit + an ``inserted`` event. ``layerlist[idx] = layer`` *will* emit a ``changed`` + event. + + However, the layerlist does not have a way of detecting when an object in + the list is modified in-place. Therefore, although + ``layerlist[idx].scale = [2, 1, 1]`` changes the *value* of the layer at + position ``idx``, a ``changed`` event will not be emitted. + + Examples + -------- + + >>> import napari + >>> from skimage.data import astronaut + >>> viewer = napari.Viewer() + >>> event_list = [] + + Connect to the event list: + + >>> viewer.layers.events.connect(event_list.append) + + + >>> viewer.add_image(astronaut()) + + >>> viewer.add_points() + + >>> viewer.layers + [, ] + + Inspecting the list of events, we see: + + >>> event_list[0].type + 'inserting' + >>> viewer.layers.pop(1) + + >>> event_list[-1].type + 'removed' + """ def __init__(self, data=()) -> None: @@ -396,6 +441,12 @@ def link_layers( layers: Iterable[str | Layer] | None = None, attributes: Iterable[str] = (), ): + """ + Links the selected layers. + + Once layers are linked, any action performed on one layer will be + performed on all linked layers at the same time. + """ return self._link_layers('link_layers', layers, attributes) def unlink_layers( @@ -403,6 +454,11 @@ def unlink_layers( layers: Iterable[str | Layer] | None = None, attributes: Iterable[str] = (), ): + """Unlinks previously linked layers. + + Changes to one of the layer's properties no longer result in the same + changes to the previously linked layers. + """ return self._link_layers('unlink_layers', layers, attributes) def save( diff --git a/napari/settings/_experimental.py b/napari/settings/_experimental.py index 244af5bb5ab..63d97aaa825 100644 --- a/napari/settings/_experimental.py +++ b/napari/settings/_experimental.py @@ -49,6 +49,26 @@ class ExperimentalSettings(EventedSettings): gt=0, lt=50, ) + scale_bar_length: int = Field( + 5, + title=trans._('Scale bar length'), + description=trans._( + 'The scale bar length as a fraction of the canvas width.' + ), + type=int, + ge=3, + le=10, + ) + scale_bar_font_size: int = Field( + 35, + title=trans._('Scale bar font size'), + description=trans._( + 'The scale bar font size as a fraction of the canvas height.' + ), + type=int, + ge=30, + le=50, + ) completion_radius: int = Field( default=-1,