diff --git a/setup.cfg b/setup.cfg index c376325..01e4221 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -name = napari-tiled +name = napari_tiled version = 0.0.1 description = A Tiled browser plugin for napari long_description = file: README.md @@ -8,6 +8,7 @@ url = https://github.com/AbbyGi/napari-tiled author = Abby Giles author_email = agiles@bnl.gov license = BSD-3-Clause +license_file = LICENSE license_files = LICENSE classifiers = Development Status :: 2 - Pre-Alpha @@ -31,17 +32,16 @@ project_urls = [options] packages = find: install_requires = - numpy magicgui + napari + numpy qtpy - + tiled python_requires = >=3.8 include_package_data = True package_dir = =src -# add your package requirements here - [options.packages.find] where = src @@ -51,13 +51,12 @@ napari.manifest = [options.extras_require] testing = - tox - pytest # https://docs.pytest.org/en/latest/contents.html - pytest-cov # https://pytest-cov.readthedocs.io/en/latest/ - pytest-qt # https://pytest-qt.readthedocs.io/en/latest/ napari pyqt5 - + pytest + pytest-cov + pytest-qt + tox [options.package_data] * = *.yaml diff --git a/src/napari_tiled_browser/models/search.py b/src/napari_tiled_browser/models/search.py new file mode 100644 index 0000000..0a43f5a --- /dev/null +++ b/src/napari_tiled_browser/models/search.py @@ -0,0 +1,90 @@ +from napari.utils.events import EmitterGroup, Event +from tiled.client import from_uri + +DEFAULT_PAGE_LIMIT = 20 + + +class ResultsPage: + def __init__(self, *, uri=None): + self._uri = None + self._client = None + self._total_length = 0 + self._page_number = 0 + self._page_limit = DEFAULT_PAGE_LIMIT + self.queries = [] + self.results = [] + self.events = EmitterGroup( + source=self, + auto_connect=True, + page_number=Event, + page_limit=Event, + refreshed=Event, + connected=Event, + uri=Event, + ) + self.events.page_number.connect(self.refresh) + self.events.page_limit.connect(self.refresh) + self.events.connected.connect(self.refresh) + self.uri = uri + + @property + def page_number(self): + return self._page_number + + @page_number.setter + def page_number(self, value): + self._page_number = value + self.events.page_number(value=value) + + @property + def page_limit(self): + return self._page_limit + + @page_limit.setter + def page_limit(self, value): + self._page_limit = value + self.events.page_limit(value=value) + + @property + def range(self): + return ( + self.page_number * self.page_limit, + min((1 + self.page_number) * self.page_limit, self._total_length), + ) + + @property + def total_length(self): + return self._total_length + + @property + def uri(self): + return self._uri + + @uri.setter + def uri(self, value): + if value == self._uri: + return + self._uri = value + self.events.uri(value=value) + if value == "": + self._client = None + else: + self._client = from_uri(value) + self.events.connected() + + def refresh(self, event): + if self._client is None: + return [] + results = self._client + for query in self.queries: + results = results.search(query) + self._total_length = len(results) + self.results.clear() + self.results.extend( + results.keys()[ + (self.page_number * self.page_limit) : ( # noqa E203 + (1 + self.page_number) * self.page_limit + ) + ] + ) + self.events.refreshed() diff --git a/src/napari_tiled_browser/napari.yaml b/src/napari_tiled_browser/napari.yaml index 3545a3c..fb59fe4 100644 --- a/src/napari_tiled_browser/napari.yaml +++ b/src/napari_tiled_browser/napari.yaml @@ -2,9 +2,14 @@ name: napari-tiled display_name: Tiled Browser contributions: commands: + # - id: napari-tiled.make_qwidget + # python_name: napari_tiled_browser.tiled_widget:TiledBrowser + # title: Tiled Browser - id: napari-tiled.make_qwidget - python_name: napari_tiled_browser.tiled_widget:TiledBrowser - title: Tiled Browser + python_name: napari_tiled_browser.tiled_widget2:TiledBrowser2 + title: Tiled Browser 2 widgets: + # - command: napari-tiled.make_qwidget + # display_name: Tiled Browser - command: napari-tiled.make_qwidget - display_name: Tiled Browser + display_name: Tiled Browser 2 diff --git a/src/napari_tiled_browser/qt/search.py b/src/napari_tiled_browser/qt/search.py new file mode 100644 index 0000000..c908ed0 --- /dev/null +++ b/src/napari_tiled_browser/qt/search.py @@ -0,0 +1,166 @@ +from math import floor + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSplitter, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + + +class SearchResults(QWidget): + # your QWidget.__init__ can optionally request the napari viewer instance + # in one of two ways: + # 1. use a parameter called `napari_viewer`, as done here + # 2. use a type annotation of 'napari.viewer.Viewer' for any parameter + def __init__(self, model): + super().__init__() + self.model = model + + # Connection elements + self.url_entry = QLineEdit() + self.url_entry.setPlaceholderText("Enter a url") + self.connect_button = QPushButton("Connect") + self.connection_label = QLabel("No url connected") + self.connection_widget = QWidget() + + # Connection layout + connection_layout = QVBoxLayout() + connection_layout.addWidget(self.url_entry) + connection_layout.addWidget(self.connect_button) + connection_layout.addWidget(self.connection_label) + connection_layout.addStretch() + self.connection_widget.setLayout(connection_layout) + + # Navigation elements + self.rows_per_page_label = QLabel("Rows per page: ") + self.rows_per_page_selector = QComboBox() + # TODO: use model._page_limit as default option here? + self.rows_per_page_selector.addItems(["10", "20", "50"]) + self.rows_per_page_selector.setCurrentIndex(1) + + self.current_location_label = QLabel() + self.previous_page = ClickableQLabel("<") + self.next_page = ClickableQLabel(">") + self.navigation_widget = QWidget() + + self._rows_per_page = int(self.rows_per_page_selector.currentText()) + + # Navigation layout + navigation_layout = QHBoxLayout() + navigation_layout.addWidget(self.rows_per_page_label) + navigation_layout.addWidget(self.rows_per_page_selector) + navigation_layout.addWidget(self.current_location_label) + navigation_layout.addWidget(self.previous_page) + navigation_layout.addWidget(self.next_page) + self.navigation_widget.setLayout(navigation_layout) + + # Catalog table elements + self.catalog_table = QTableWidget(0, 1) + self.catalog_table.setHorizontalHeaderLabels(["ID"]) + self._create_table_rows() + self.catalog_table_widget = QWidget() + + # Catalog table layout + catalog_table_layout = QVBoxLayout() + catalog_table_layout.addWidget(self.catalog_table) + catalog_table_layout.addWidget(self.navigation_widget) + self.catalog_table_widget.setLayout(catalog_table_layout) + self.catalog_table_widget.setVisible(False) + + self.splitter = QSplitter(self) + self.splitter.setOrientation(Qt.Orientation.Vertical) + + self.splitter.addWidget(self.connection_widget) + self.splitter.addWidget(self.catalog_table_widget) + + self.splitter.setStretchFactor(1, 2) + + layout = QVBoxLayout() + layout.addWidget(self.splitter) + self.setLayout(layout) + + self.connect_button.clicked.connect(self._on_connect_clicked) + self.previous_page.clicked.connect(self._on_prev_page_clicked) + self.next_page.clicked.connect(self._on_next_page_clicked) + + self.rows_per_page_selector.currentTextChanged.connect( + self._on_rows_per_page_changed + ) + + self.model.events.connected.connect(self._on_connected) + self.model.events.refreshed.connect(self._on_refreshed) + + if self.model.uri != "": + self.url_entry.setText(self.model.uri) + self.connection_label.setText(f"Connected to {self.model.uri}") + # Create the table if we already have a uri + self._set_current_location_label() + self._create_table_rows() + self._populate_table() + self.catalog_table_widget.setVisible(True) + + def _on_connect_clicked(self): + url = self.url_entry.displayText() + self.model.uri = url + self.connection_label.setText(f"Connected to {url}") + + def _on_connected(self, event): + self.catalog_table_widget.setVisible(True) + + def _on_refreshed(self, event): + self._set_current_location_label() + self._create_table_rows() + self._populate_table() + + def _on_rows_per_page_changed(self, value): + lower_bound, _ = self.model.range + self.model.page_limit = int(value) + self.model.page_number = min( + self.model.page_number, + floor(self.model.total_length / self.model.page_limit), + ) + + def _create_table_rows(self): + target_length = len(self.model.results) + while self.catalog_table.rowCount() > target_length: + self.catalog_table.removeRow(0) + while self.catalog_table.rowCount() < target_length: + last_row_position = self.catalog_table.rowCount() + self.catalog_table.insertRow(last_row_position) + + def _populate_table(self): + for row_index, node in enumerate(self.model.results): + self.catalog_table.setItem(row_index, 0, QTableWidgetItem(node)) + + def _on_prev_page_clicked(self): + if self.model.page_number != 0: + self.model.page_number -= 1 + + def _on_next_page_clicked(self): + if ( + self.model.page_limit * (self.model.page_number + 1) + < self.model.total_length + ): + self.model.page_number += 1 + + def _set_current_location_label(self): + start, end = self.model.range + current_location_text = ( + f"{1 + start}-{end} of {self.model.total_length}" + ) + self.current_location_label.setText(current_location_text) + + +class ClickableQLabel(QLabel): + clicked = Signal() + + def mousePressEvent(self, event): + self.clicked.emit() diff --git a/src/napari_tiled_browser/tiled_widget2.py b/src/napari_tiled_browser/tiled_widget2.py new file mode 100644 index 0000000..5ac3527 --- /dev/null +++ b/src/napari_tiled_browser/tiled_widget2.py @@ -0,0 +1,28 @@ +""" +This module is an example of a barebones QWidget plugin for napari + +It implements the Widget specification. +see: https://napari.org/plugins/guides.html?#widgets + +Replace code below according to your needs. +""" + +from qtpy.QtWidgets import QVBoxLayout, QWidget + +from .models.search import ResultsPage +from .qt.search import SearchResults + + +class TiledBrowser2(QWidget): + # your QWidget.__init__ can optionally request the napari viewer instance + # in one of two ways: + # 1. use a parameter called `napari_viewer`, as done here + # 2. use a type annotation of 'napari.viewer.Viewer' for any parameter + def __init__(self, napari_viewer): + super().__init__() + self.viewer = napari_viewer + self.search = ResultsPage(uri=None) + widget = SearchResults(self.search) + layout = QVBoxLayout() + layout.addWidget(widget) + self.setLayout(layout)