Skip to content

Commit

Permalink
Merge pull request #315 from imagej/search-result-menu-path
Browse files Browse the repository at this point in the history
Add menu path to search results
  • Loading branch information
gselzer authored Dec 3, 2024
2 parents 5b3cbc1 + 78345f1 commit de75079
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 21 deletions.
83 changes: 76 additions & 7 deletions src/napari_imagej/widgets/result_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()"""
Expand Down Expand Up @@ -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" <span style=\"color:{HIGHLIGHT};\">{props['Menu path']}</span>"
super().__init__(text)
self.result = result

# Set QtPy properties
Expand All @@ -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'<span style="color:{text_color};">{rich_text}</span>'
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)
Expand Down Expand Up @@ -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()} <span style="color:{HIGHLIGHT};">({item.rowCount()})</span>',
Qt.DisplayRole,
)
else:
item.setData(str(item.searcher.title()), Qt.DisplayRole)
4 changes: 3 additions & 1 deletion src/napari_imagej/widgets/widget_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 14 additions & 2 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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"
Expand All @@ -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:
"""
Expand Down
24 changes: 17 additions & 7 deletions tests/widgets/test_result_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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()} <span style="color:#8C745E;">foo > bar > baz</span>'
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
Expand All @@ -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)
Expand All @@ -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 <span style="color:#8C745E;">(3)</span>'
asserter(lambda: item.data(0) == data)
asserter(lambda: item.checkState() == Qt.Checked)

# Disable the searcher, assert the proper GUI response
Expand Down
17 changes: 13 additions & 4 deletions tests/widgets/widget_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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 <span style="color:#8C745E;">({count})</span>'
asserter(lambda: root.child(0, 0).data(0) == data)

count = 3
asserter(lambda: root.child(1, 0).rowCount() == count)
data = f'Test2 <span style="color:#8C745E;">({count})</span>'
asserter(lambda: root.child(1, 0).data(0) == data)

0 comments on commit de75079

Please sign in to comment.