Skip to content

Commit e530056

Browse files
authored
round out base gui features (#2)
* allow opening of nested nodes and display current path * add breadcrumbs * handle metadata * streamline rebuild process * add error messages * adjust formatting, fix contrast limits * fix connection error handling * add type information to info panel * add icons to nodes
1 parent fe2a8f4 commit e530056

File tree

1 file changed

+199
-57
lines changed

1 file changed

+199
-57
lines changed

src/napari_tiled_browser/tiled_widget.py

Lines changed: 199 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,15 @@
66
77
Replace code below according to your needs.
88
"""
9+
import collections
10+
from datetime import date, datetime
11+
import functools
12+
import json
913

1014
from napari.utils.notifications import show_info
15+
from napari.resources._icons import ICONS
1116
from qtpy.QtCore import Qt, Signal
17+
from qtpy.QtGui import QIcon, QPixmap
1218
from qtpy.QtWidgets import (
1319
QAbstractItemView,
1420
QComboBox,
@@ -17,25 +23,46 @@
1723
QLineEdit,
1824
QPushButton,
1925
QSplitter,
26+
QStyle,
2027
QTableWidget,
2128
QTableWidgetItem,
29+
QTextEdit,
2230
QVBoxLayout,
2331
QWidget,
2432
)
2533
from tiled.client import from_uri
34+
from tiled.client.array import DaskArrayClient
35+
from tiled.client.node import Node
2636
from tiled.structures.core import StructureFamily
2737

2838

39+
def json_decode(obj):
40+
if isinstance(obj, (datetime, date)):
41+
return obj.isoformat()
42+
return str(obj)
43+
44+
45+
class DummyClient:
46+
"Placeholder for a structure family we cannot (yet) handle"
47+
def __init__(self, *args, item, **kwargs):
48+
self.item = item
49+
50+
STRUCTURE_CLIENTS = collections.defaultdict(lambda: DummyClient)
51+
STRUCTURE_CLIENTS.update({"array": DaskArrayClient, "node": Node})
52+
2953
class TiledBrowser(QWidget):
54+
NODE_ID_MAXLEN = 8
55+
SUPPORTED_TYPES = (StructureFamily.array, StructureFamily.node)
56+
3057
# your QWidget.__init__ can optionally request the napari viewer instance
3158
# in one of two ways:
3259
# 1. use a parameter called `napari_viewer`, as done here
3360
# 2. use a type annotation of 'napari.viewer.Viewer' for any parameter
3461
def __init__(self, napari_viewer):
3562
super().__init__()
3663
self.viewer = napari_viewer
37-
self.catalog = None
38-
self._current_page = 0 # Keep track of where in the catalog we are
64+
65+
self.set_root(None)
3966

4067
# Connection elements
4168
self.url_entry = QLineEdit()
@@ -55,7 +82,7 @@ def __init__(self, napari_viewer):
5582
# Navigation elements
5683
self.rows_per_page_label = QLabel("Rows per page: ")
5784
self.rows_per_page_selector = QComboBox()
58-
self.rows_per_page_selector.addItems(["5", "10"])
85+
self.rows_per_page_selector.addItems(["5", "10", "25"])
5986
self.rows_per_page_selector.setCurrentIndex(0)
6087

6188
self.current_location_label = QLabel()
@@ -74,20 +101,41 @@ def __init__(self, napari_viewer):
74101
navigation_layout.addWidget(self.next_page)
75102
self.navigation_widget.setLayout(navigation_layout)
76103

104+
# Current path layout
105+
self.current_path_label = QLabel()
106+
self._rebuild_current_path_label()
107+
77108
# Catalog table elements
78109
self.catalog_table = QTableWidget(0, 1)
110+
self.catalog_table.horizontalHeader().setStretchLastSection(True)
111+
self.catalog_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # disable editing
79112
self.catalog_table.horizontalHeader().hide() # remove header
80113
self.catalog_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) # disable multi-select
81-
82114
# disabled due to bad colour palette # self.catalog_table.setAlternatingRowColors(True)
83-
self._create_table_rows()
84115
self.catalog_table.itemDoubleClicked.connect(self._on_item_double_click)
116+
self.catalog_table.itemSelectionChanged.connect(self._on_item_selected)
85117
self.catalog_table_widget = QWidget()
118+
self.catalog_breadcrumbs = None
119+
120+
# Info layout
121+
self.info_box = QTextEdit()
122+
self.info_box.setReadOnly(True)
123+
self.load_button = QPushButton('Open')
124+
self.load_button.setEnabled(False)
125+
self.load_button.clicked.connect(self._on_load)
126+
catalog_info_layout = QHBoxLayout()
127+
catalog_info_layout.addWidget(self.catalog_table)
128+
load_layout = QVBoxLayout()
129+
load_layout.addWidget(self.info_box)
130+
load_layout.addWidget(self.load_button)
131+
catalog_info_layout.addLayout(load_layout)
86132

87133
# Catalog table layout
88134
catalog_table_layout = QVBoxLayout()
89-
catalog_table_layout.addWidget(self.catalog_table)
135+
catalog_table_layout.addWidget(self.current_path_label)
136+
catalog_table_layout.addLayout(catalog_info_layout)
90137
catalog_table_layout.addWidget(self.navigation_widget)
138+
catalog_table_layout.addStretch(1)
91139
self.catalog_table_widget.setLayout(catalog_table_layout)
92140
self.catalog_table_widget.setVisible(False)
93141

@@ -111,97 +159,191 @@ def __init__(self, napari_viewer):
111159
self._on_rows_per_page_changed
112160
)
113161

114-
# def _on_connect_clicked(self):
115-
# url = self.url_entry.displayText()
116-
# if not url:
117-
# show_info("Please specify a url.")
118-
# return
119-
# try:
120-
# self.catalog = from_uri(url)
121-
# except Exception:
122-
# show_info("Could not connect. Please check the url.")
123-
# return
124-
# self.connection_label.setText(f"Connected to {url}")
125-
# self.catalog_table_widget.setVisible(True)
126-
# self._set_current_location_label()
127-
# self._populate_table()
128-
129162
def _on_connect_clicked(self):
130-
url = self.url_entry.displayText()
163+
url = self.url_entry.displayText().strip()
131164
# url = "https://tiled-demo.blueskyproject.io/api"
132165
if not url:
133166
show_info("Please specify a url.")
134167
return
135168
try:
136-
# self.catalog = from_uri(url)["bmm"]["raw"] # .keys()[:13]
137-
self.catalog = from_uri(url)
169+
root = from_uri(url, STRUCTURE_CLIENTS)
170+
if isinstance(root, DummyClient):
171+
show_info("Unsupported tiled type detected")
138172
except Exception:
139173
show_info("Could not connect. Please check the url.")
174+
else:
175+
self.connection_label.setText(f"Connected to {url}")
176+
self.set_root(root)
177+
178+
def set_root(self, root):
179+
self.root = root
180+
self.node_path = ()
181+
self._current_page = 0
182+
if root is not None:
183+
self.catalog_table_widget.setVisible(True)
184+
self._rebuild()
185+
186+
def get_current_node(self):
187+
return self.get_node(self.node_path)
188+
189+
@functools.lru_cache(maxsize=1)
190+
def get_node(self, node_path):
191+
if node_path:
192+
return self.root[node_path]
193+
return self.root
194+
195+
def enter_node(self, node_id):
196+
self.node_path += (node_id,)
197+
self._current_page = 0
198+
self._rebuild()
199+
200+
def exit_node(self):
201+
self.node_path = self.node_path[:-1]
202+
self._current_page = 0
203+
self._rebuild()
204+
205+
def open_node(self, node_id):
206+
node = self.get_current_node()[node_id]
207+
family = node.item['attributes']['structure_family']
208+
if isinstance(node, DummyClient):
209+
show_info(f"Cannot open type: '{family}'")
140210
return
211+
if family == StructureFamily.array:
212+
layer = self.viewer.add_image(node, name=node_id)
213+
layer.reset_contrast_limits()
214+
elif family == StructureFamily.node:
215+
self.enter_node(node_id)
216+
else:
217+
show_info(f"Type not supported:'{family}")
141218

142-
print(f"{self.catalog = }")
143-
self.connection_label.setText(f"Connected to {url}")
144-
self.catalog_table_widget.setVisible(True)
145-
self._set_current_location_label()
146-
self._populate_table()
219+
def _on_load(self):
220+
selected = self.catalog_table.selectedItems()
221+
if not selected:
222+
return
223+
item = selected[0]
224+
if item is self.catalog_breadcrumbs:
225+
return
226+
self.open_node(item.text())
147227

148228
def _on_rows_per_page_changed(self, value):
149229
self._rows_per_page = int(value)
150-
self._create_table_rows()
151-
self._populate_table()
230+
self._current_page = 0
231+
self._rebuild_table()
152232
self._set_current_location_label()
153233

154-
def _create_table_rows(self):
234+
def _on_item_double_click(self, item):
235+
if item is self.catalog_breadcrumbs:
236+
self.exit_node()
237+
return
238+
self.open_node(item.text())
239+
240+
def _on_item_selected(self):
241+
selected = self.catalog_table.selectedItems()
242+
if not selected or (item:=selected[0]) is self.catalog_breadcrumbs:
243+
self._clear_metadata()
244+
return
245+
246+
name = item.text()
247+
node_path = self.node_path + (name,)
248+
node = self.get_node(node_path)
249+
250+
attrs = node.item['attributes']
251+
family = attrs['structure_family']
252+
metadata = json.dumps(attrs['metadata'], indent=2, default=json_decode)
253+
254+
info = f'<b>type:</b> {family}<br>'
255+
if family == StructureFamily.array:
256+
shape = attrs['structure']['macro']['shape']
257+
info += f'<b>shape:</b> {tuple(shape)}<br>'
258+
info += f'<b>metadata:</b> {metadata}'
259+
self.info_box.setText(info)
260+
261+
if family in self.SUPPORTED_TYPES:
262+
self.load_button.setEnabled(True)
263+
else:
264+
self.load_button.setEnabled(False)
265+
266+
def _clear_metadata(self):
267+
self.info_box.setText('')
268+
self.load_button.setEnabled(False)
269+
270+
def _rebuild_current_path_label(self):
271+
path = ['root']
272+
for node_id in self.node_path:
273+
if len(node_id) > self.NODE_ID_MAXLEN:
274+
node_id = node_id[:self.NODE_ID_MAXLEN - 3] + '...'
275+
path.append(node_id)
276+
path.append('')
277+
278+
self.current_path_label.setText(' / '.join(path))
279+
280+
def _rebuild_table(self):
281+
prev_block = self.catalog_table.blockSignals(True)
155282
# Remove all rows first
156283
while self.catalog_table.rowCount() > 0:
157284
self.catalog_table.removeRow(0)
285+
286+
if self.node_path:
287+
# add breadcrumbs
288+
self.catalog_breadcrumbs = QTableWidgetItem('..')
289+
self.catalog_table.insertRow(0)
290+
self.catalog_table.setItem(0, 0, self.catalog_breadcrumbs)
291+
158292
# Then add new rows
159293
for row in range(self._rows_per_page):
160294
last_row_position = self.catalog_table.rowCount()
161295
self.catalog_table.insertRow(last_row_position)
162-
163-
def _on_item_double_click(self, item):
164-
name = item.text()
165-
node = self.catalog[name]
166-
family = node.item['attributes']['structure_family']
167-
if family == StructureFamily.array:
168-
self.viewer.add_image(node, name=name)
169-
elif family == StructureFamily.node:
170-
pass
171-
# TBD... open sub-browser?
172-
173-
def _populate_table(self):
174296
node_offset = self._rows_per_page * self._current_page
175297
# Fetch a page of keys.
176-
keys = self.catalog.keys()[node_offset:node_offset + self._rows_per_page]
298+
items = self.get_current_node().items()[node_offset:node_offset + self._rows_per_page]
177299
# Loop over rows, filling in keys until we run out of keys.
178-
for row_index, key in zip(range(self.catalog_table.rowCount()), keys):
179-
item = QTableWidgetItem(key)
180-
item.setFlags(item.flags() ^ Qt.ItemIsEditable)
181-
self.catalog_table.setItem(row_index, 0, item)
182-
self.catalog_table.setVerticalHeaderLabels([str(x + 1) for x in range(node_offset, node_offset + self.catalog_table.rowCount())])
300+
start = 1 if self.node_path else 0
301+
for row_index, (key, value) in zip(range(start, self.catalog_table.rowCount()), items):
302+
family = value.item['attributes']['structure_family']
303+
if family == StructureFamily.node:
304+
icon = self.style().standardIcon(QStyle.SP_DirHomeIcon)
305+
elif family == StructureFamily.array:
306+
icon = QIcon(QPixmap(ICONS['new_image']))
307+
else:
308+
icon = self.style().standardIcon(QStyle.SP_TitleBarContextHelpButton)
309+
self.catalog_table.setItem(row_index, 0, QTableWidgetItem(icon,key))
310+
311+
# remove extra rows
312+
for row in range(self._rows_per_page - len(items)):
313+
self.catalog_table.removeRow(self.catalog_table.rowCount() - 1)
314+
315+
headers = [str(x + 1) for x in range(node_offset, node_offset + self.catalog_table.rowCount())]
316+
if self.node_path:
317+
headers = [''] + headers
318+
319+
self.catalog_table.setVerticalHeaderLabels(headers)
320+
self._clear_metadata()
321+
self.catalog_table.blockSignals(prev_block)
322+
323+
def _rebuild(self):
324+
self._rebuild_table()
325+
self._rebuild_current_path_label()
326+
self._set_current_location_label()
183327

184328
def _on_prev_page_clicked(self):
185329
if self._current_page != 0:
186330
self._current_page -= 1
187-
self._populate_table()
188-
self._set_current_location_label()
331+
self._rebuild()
189332

190333
def _on_next_page_clicked(self):
191334
if (
192335
self._current_page * self._rows_per_page
193-
) + self._rows_per_page < len(self.catalog):
336+
) + self._rows_per_page < len(self.get_current_node()):
194337
self._current_page += 1
195-
self._populate_table()
196-
self._set_current_location_label()
338+
self._rebuild()
197339

198340
def _set_current_location_label(self):
199341
starting_index = self._current_page * self._rows_per_page + 1
200342
ending_index = min(
201-
self._rows_per_page * (self._current_page + 1), len(self.catalog)
343+
self._rows_per_page * (self._current_page + 1), len(self.get_current_node())
202344
)
203345
current_location_text = (
204-
f"{starting_index}-{ending_index} of {len(self.catalog)}"
346+
f"{starting_index}-{ending_index} of {len(self.get_current_node())}"
205347
)
206348
self.current_location_label.setText(current_location_text)
207349

0 commit comments

Comments
 (0)