From c0a48151bd849f1a5cf0850365b6cced881fbe34 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Mon, 2 Dec 2024 17:17:00 -0600 Subject: [PATCH 1/2] Add menu path to search results --- src/napari_imagej/widgets/result_tree.py | 80 +++++++++++++++++++++--- tests/utils.py | 16 ++++- tests/widgets/test_result_tree.py | 11 +++- tests/widgets/widget_utils.py | 13 ++-- 4 files changed, 105 insertions(+), 15 deletions(-) diff --git a/src/napari_imagej/widgets/result_tree.py b/src/napari_imagej/widgets/result_tree.py index 1a7a6952..080b5d8b 100644 --- a/src/napari_imagej/widgets/result_tree.py +++ b/src/napari_imagej/widgets/result_tree.py @@ -7,17 +7,32 @@ from __future__ import annotations from logging import getLogger -from typing import Dict, List - -from qtpy.QtCore import Qt, Signal -from qtpy.QtGui import QStandardItem, QStandardItemModel -from qtpy.QtWidgets import QAction, QMenu, QTreeView +from typing import Dict, List, TYPE_CHECKING + +from qtpy.QtCore import QRectF, Qt, Signal, QSize +from qtpy.QtGui import QStandardItem, QStandardItemModel, QTextDocument +from qtpy.QtWidgets import ( + QAction, + QMenu, + QStyle, + QStyledItemDelegate, + QStyleOptionViewItem, + QTreeView, +) from scyjava import Priority from napari_imagej import nij from napari_imagej.java import jc from napari_imagej.widgets.widget_utils import _get_icon, python_actions_for +if TYPE_CHECKING: + from qtpy.QtCore import QModelIndex + + +# FIXME: Ideally we'd use something from the palette +# but there's no good options +HIGHLIGHT = "#8C745E" + class SearcherTreeView(QTreeView): floatAbove = Signal() @@ -31,6 +46,7 @@ def __init__(self, output_signal: Signal): self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._create_custom_menu) self.model().rowsInserted.connect(self.expand_searchers) + self.setItemDelegate(HTMLItemDelegate()) def search(self, text: str): """Convenience method for calling self.model().search()""" @@ -104,7 +120,12 @@ def __lt__(self, other): class SearchResultItem(QStandardItem): def __init__(self, result: "jc.SearchResult"): - super().__init__(str(result.name())) + props = result.properties() + text = str(result.name()) + # Wrap up the icon path in "highlight text" + if "Menu path" in props: + text += f" {props['Menu path']}" + super().__init__(text) self.result = result # Set QtPy properties @@ -113,6 +134,50 @@ def __init__(self, result: "jc.SearchResult"): self.setIcon(icon) +class HTMLItemDelegate(QStyledItemDelegate): + """A QStyledItemDelegate that can handle HTML in provided text""" + + def paint(self, painter, option, index: QModelIndex): + options = QStyleOptionViewItem(option) + self.initStyleOption(options, index) + rich_text = options.text + + # "clear" the item using "normal" behavior. + # mimics qt source code. + options.text = "" + style = options.widget.style() + style.drawControl(QStyle.CE_ItemViewItem, options, painter, options.widget) + + # paint the HTML text + doc = QTextDocument() + text_color = options.palette.text().color().name() + rich_text = f'{rich_text}' + doc.setHtml(rich_text) + + painter.save() + painter.setClipRect(options.rect) + + # NB: Unsure why we can't pass subElementRect output to drawContents... + text_offset = style.subElementRect( + QStyle.SE_ItemViewItemText, options, options.widget + ).x() + painter.translate(text_offset, options.rect.top()) + rect = QRectF(0, 0, options.rect.width(), options.rect.height()) + doc.drawContents(painter, rect) + + painter.restore() + + def sizeHint(self, option, index): + options = QStyleOptionViewItem(option) + self.initStyleOption(options, index) + + doc = QTextDocument() + doc.setHtml(options.text) + doc.setTextWidth(options.rect.width()) + size = QSize(int(doc.idealWidth()), int(doc.size().height())) + return size + + class SearchResultModel(QStandardItemModel): insert_searcher: Signal = Signal(object) process: Signal = Signal(object) @@ -186,7 +251,8 @@ def _update_searcher_title(self, parent_idx, first: int, last: int): if isinstance(item, SearcherItem): if item.hasChildren(): item.setData( - f"{item.searcher.title()} ({item.rowCount()})", Qt.DisplayRole + f'{item.searcher.title()} ({item.rowCount()})', + Qt.DisplayRole, ) else: item.setData(str(item.searcher.title()), Qt.DisplayRole) diff --git a/tests/utils.py b/tests/utils.py index 19d20b56..10614eb0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,13 +2,18 @@ A module containing testing utilities """ -from typing import List +from __future__ import annotations + +from typing import TYPE_CHECKING from jpype import JImplements, JOverride from scyjava import JavaClasses from napari_imagej.java import NijJavaClasses +if TYPE_CHECKING: + from typing import Dict, List + class JavaClassesTest(NijJavaClasses): """ @@ -170,8 +175,9 @@ def results(self): class DummySearchResult(object): - def __init__(self, info: "jc.ModuleInfo" = None): + def __init__(self, info: "jc.ModuleInfo" = None, properties: Dict = {}): self._info = info + self._properties = properties def name(self): return "This is not a Search Result" @@ -185,6 +191,12 @@ def iconPath(self): def getClass(self): return None + def properties(self): + return self._properties + + def set_properties(self, props: Dict): + self._properties = props + class DummyModuleInfo: """ diff --git a/tests/widgets/test_result_tree.py b/tests/widgets/test_result_tree.py index 441ccb55..c3b3213d 100644 --- a/tests/widgets/test_result_tree.py +++ b/tests/widgets/test_result_tree.py @@ -49,12 +49,18 @@ def test_searchers_persist(fixed_tree: SearcherTreeView, asserter): asserter(lambda: fixed_tree.model().invisibleRootItem().rowCount() == 2) -def test_resultTreeItem_regression(): +def test_resultTreeItem_regression(ij): dummy = DummySearchResult() item = SearchResultItem(dummy) assert item.result == dummy assert item.data(0) == dummy.name() + dummy = DummySearchResult(properties={"Menu path": "foo > bar > baz"}) + item = SearchResultItem(dummy) + assert item.result == dummy + data = f'{dummy.name()} foo > bar > baz' + assert item.data(0) == data + def test_searcherTreeItem_regression(): init_ij() @@ -87,7 +93,8 @@ def test_search_tree_disable(fixed_tree: SearcherTreeView, asserter): # Grab an arbitratry enabled Searcher item = fixed_tree.model().invisibleRootItem().child(1, 0) # Assert GUI start - asserter(lambda: item.data(0) == "Test2 (3)") + data = 'Test2 (3)' + asserter(lambda: item.data(0) == data) asserter(lambda: item.checkState() == Qt.Checked) # Disable the searcher, assert the proper GUI response diff --git a/tests/widgets/widget_utils.py b/tests/widgets/widget_utils.py index b54b2014..ef8f0cf8 100644 --- a/tests/widgets/widget_utils.py +++ b/tests/widgets/widget_utils.py @@ -44,7 +44,12 @@ def _populate_tree(tree: SearcherTreeView, asserter): ) # Wait for the tree to populate - asserter(lambda: root.child(0, 0).rowCount() == 2) - asserter(lambda: root.child(0, 0).data(0) == "Test1 (2)") - asserter(lambda: root.child(1, 0).rowCount() == 3) - asserter(lambda: root.child(1, 0).data(0) == "Test2 (3)") + count = 2 + asserter(lambda: root.child(0, 0).rowCount() == count) + data = f'Test1 ({count})' + asserter(lambda: root.child(0, 0).data(0) == data) + + count = 3 + asserter(lambda: root.child(1, 0).rowCount() == count) + data = f'Test2 ({count})' + asserter(lambda: root.child(1, 0).data(0) == data) From 78345f1700d6961f521ce7b218b1c7d7105c6e44 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Tue, 3 Dec 2024 17:03:01 -0600 Subject: [PATCH 2/2] Minor fixes --- src/napari_imagej/widgets/result_tree.py | 15 +++++++++------ src/napari_imagej/widgets/widget_utils.py | 4 +++- tests/widgets/test_result_tree.py | 17 ++++++++++------- tests/widgets/widget_utils.py | 4 ++++ 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/napari_imagej/widgets/result_tree.py b/src/napari_imagej/widgets/result_tree.py index 080b5d8b..86f0f9a1 100644 --- a/src/napari_imagej/widgets/result_tree.py +++ b/src/napari_imagej/widgets/result_tree.py @@ -7,7 +7,7 @@ from __future__ import annotations from logging import getLogger -from typing import Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING from qtpy.QtCore import QRectF, Qt, Signal, QSize from qtpy.QtGui import QStandardItem, QStandardItemModel, QTextDocument @@ -28,9 +28,10 @@ if TYPE_CHECKING: from qtpy.QtCore import QModelIndex + from typing import Dict, List -# FIXME: Ideally we'd use something from the palette -# but there's no good options + +# Color used for additional information in the QTreeView HIGHLIGHT = "#8C745E" @@ -155,13 +156,13 @@ def paint(self, painter, option, index: QModelIndex): doc.setHtml(rich_text) painter.save() - painter.setClipRect(options.rect) - - # NB: Unsure why we can't pass subElementRect output to drawContents... + # Translate the painter to the correct item + # NB offset is necessary to account for checkbox, icon text_offset = style.subElementRect( QStyle.SE_ItemViewItemText, options, options.widget ).x() painter.translate(text_offset, options.rect.top()) + # Paint the rich text rect = QRectF(0, 0, options.rect.width(), options.rect.height()) doc.drawContents(painter, rect) @@ -171,6 +172,7 @@ def sizeHint(self, option, index): options = QStyleOptionViewItem(option) self.initStyleOption(options, index) + # size hint is the size of the rendered HTML doc = QTextDocument() doc.setHtml(options.text) doc.setTextWidth(options.rect.width()) @@ -251,6 +253,7 @@ def _update_searcher_title(self, parent_idx, first: int, last: int): if isinstance(item, SearcherItem): if item.hasChildren(): item.setData( + # Write the number of results in "highlight text" f'{item.searcher.title()} ({item.rowCount()})', Qt.DisplayRole, ) diff --git a/src/napari_imagej/widgets/widget_utils.py b/src/napari_imagej/widgets/widget_utils.py index 65d31a3a..506fb356 100644 --- a/src/napari_imagej/widgets/widget_utils.py +++ b/src/napari_imagej/widgets/widget_utils.py @@ -3,6 +3,7 @@ from typing import List from jpype import JArray, JByte +from scyjava import jvm_started from magicgui import magicgui from napari import Viewer from napari.layers import Image, Labels, Layer, Points, Shapes @@ -315,7 +316,8 @@ def _get_icon(path: str, cls: "jc.Class" = None): # TODO: Add icons from web return # Java Resources - elif isinstance(cls, jc.Class): + # NB: Use java only if JVM started + elif jvm_started() and isinstance(cls, jc.Class): stream = cls.getResourceAsStream(path) # Ignore falsy streams if not stream: diff --git a/tests/widgets/test_result_tree.py b/tests/widgets/test_result_tree.py index c3b3213d..022b31f0 100644 --- a/tests/widgets/test_result_tree.py +++ b/tests/widgets/test_result_tree.py @@ -6,7 +6,7 @@ from qtpy.QtCore import QRunnable, Qt, QThreadPool from qtpy.QtWidgets import QApplication, QMenu -from napari_imagej.java import init_ij +from napari_imagej import nij from napari_imagej.widgets.result_tree import ( SearcherItem, SearcherTreeView, @@ -23,7 +23,7 @@ def results_tree(): @pytest.fixture -def fixed_tree(ij, asserter): +def fixed_tree(asserter): """Creates a "fake" ResultsTree with deterministic results""" # Create a default SearchResultTree tree = SearcherTreeView(None) @@ -49,7 +49,12 @@ def test_searchers_persist(fixed_tree: SearcherTreeView, asserter): asserter(lambda: fixed_tree.model().invisibleRootItem().rowCount() == 2) -def test_resultTreeItem_regression(ij): +def test_regression(): + """Tests SearchResultItems, SearcherItems display as expected.""" + # SearchResultItems wrap SciJava SearchResults, so they expect a running JVM + nij.ij + + # Phase 1: Search Results dummy = DummySearchResult() item = SearchResultItem(dummy) assert item.result == dummy @@ -61,9 +66,7 @@ def test_resultTreeItem_regression(ij): data = f'{dummy.name()} foo > bar > baz' assert item.data(0) == data - -def test_searcherTreeItem_regression(): - init_ij() + # Phase 2: Searchers dummy = DummySearcher("This is not a Searcher") item = SearcherItem(dummy) assert item.searcher == dummy @@ -78,7 +81,7 @@ def test_searcherTreeItem_regression(): assert item.data(0) == dummy.title() -def test_key_return_expansion(fixed_tree: SearcherTreeView, qtbot, asserter): +def test_key_return_expansion(fixed_tree: SearcherTreeView, qtbot): idx = fixed_tree.model().index(0, 0) fixed_tree.setCurrentIndex(idx) expanded = fixed_tree.isExpanded(idx) diff --git a/tests/widgets/widget_utils.py b/tests/widgets/widget_utils.py index ef8f0cf8..13d61778 100644 --- a/tests/widgets/widget_utils.py +++ b/tests/widgets/widget_utils.py @@ -6,6 +6,7 @@ from qtpy.QtCore import Qt +from napari_imagej import nij from napari_imagej.widgets.result_tree import SearcherItem, SearcherTreeView from tests.utils import DummySearcher, DummySearchEvent, jc @@ -19,6 +20,9 @@ def _searcher_tree_named(tree: SearcherTreeView, name: str) -> Optional[Searcher def _populate_tree(tree: SearcherTreeView, asserter): + # DummySearchers are SciJava Searchers - we need a JVM + nij.ij + root = tree.model().invisibleRootItem() asserter(lambda: root.rowCount() == 0) # Add two searchers