diff --git a/src/napari_imagej/widgets/result_tree.py b/src/napari_imagej/widgets/result_tree.py
index 1a7a6952..86f0f9a1 100644
--- a/src/napari_imagej/widgets/result_tree.py
+++ b/src/napari_imagej/widgets/result_tree.py
@@ -7,17 +7,33 @@
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 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
+
+ from typing import Dict, List
+
+
+# Color used for additional information in the QTreeView
+HIGHLIGHT = "#8C745E"
+
class SearcherTreeView(QTreeView):
floatAbove = Signal()
@@ -31,6 +47,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 +121,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 +135,51 @@ 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()
+ # 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)
+
+ painter.restore()
+
+ 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())
+ 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 +253,9 @@ 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
+ # Write the number of results in "highlight text"
+ f'{item.searcher.title()} ({item.rowCount()})',
+ Qt.DisplayRole,
)
else:
item.setData(str(item.searcher.title()), 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/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..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,15 +49,24 @@ def test_searchers_persist(fixed_tree: SearcherTreeView, asserter):
asserter(lambda: fixed_tree.model().invisibleRootItem().rowCount() == 2)
-def test_resultTreeItem_regression():
+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
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()
+ # Phase 2: Searchers
dummy = DummySearcher("This is not a Searcher")
item = SearcherItem(dummy)
assert item.searcher == dummy
@@ -72,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)
@@ -87,7 +96,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..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
@@ -44,7 +48,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)