Skip to content

Commit

Permalink
implement version manager widget
Browse files Browse the repository at this point in the history
  • Loading branch information
alessandrofelder committed Sep 28, 2023
1 parent 1fd85b7 commit af689dc
Show file tree
Hide file tree
Showing 11 changed files with 347 additions and 173 deletions.
2 changes: 1 addition & 1 deletion brainrender_napari/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "0.0.1"


__all__ = "BrainrenderWidget"
__all__ = "BrainrenderWidget", "AtlasVersionManagerWidget"
24 changes: 24 additions & 0 deletions brainrender_napari/atlas_version_manager_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from qtpy.QtWidgets import QVBoxLayout, QWidget

from brainrender_napari.widgets.atlas_manager_view import AtlasManagerView


class AtlasVersionManagerWidget(QWidget):
def __init__(self):
"""Instantiates the version manager widget
and sets up coordinating connections"""
super().__init__()

self.setLayout(QVBoxLayout())

# create widgets
self.atlas_manager_view = AtlasManagerView(parent=self)
self.layout().addWidget(self.atlas_manager_view)

self.atlas_manager_view.download_atlas_confirmed.connect(self._refresh)

self.atlas_manager_view.update_atlas_confirmed.connect(self._refresh)

def _refresh(self) -> None:
# refresh view once an atlas has been downloaded
self.atlas_manager_view = AtlasManagerView(parent=self)
5 changes: 5 additions & 0 deletions brainrender_napari/napari.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ contributions:
- id: brainrender-napari.make_brainrender_widget
python_name: brainrender_napari.brainrender_widget:BrainrenderWidget
title: Make Brainrender Napari
- id: brainrender-napari.make_atlas_version_manager_widget
python_name: brainrender_napari.atlas_version_manager_widget:AtlasVersionManagerWidget
title: Make Brainrender Napari
widgets:
- command: brainrender-napari.make_brainrender_widget
display_name: Brainrender
- command: brainrender-napari.make_atlas_version_manager_widget
display_name: Atlas version manager
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
)


class AtlasDownloadDialog(QDialog):
"""A modal dialog to ask users to confirm they'd like to download
class AtlasManagerDialog(QDialog):
"""A modal dialog to ask users to confirm they'd like to download/update
the selected atlas, and warn them that it may be slow.
"""

def __init__(self, atlas_name):
def __init__(self, atlas_name: str, action: str) -> None:
if atlas_name in get_all_atlases_lastversions().keys():
super().__init__()

self.setWindowTitle(f"Download {atlas_name} Atlas")
self.setWindowTitle(f"{action} {atlas_name} Atlas")
self.setModal(True)

self.label = QLabel("Are you sure?\n(It may take a while)")
Expand All @@ -36,5 +36,6 @@ def __init__(self, atlas_name):
self.setLayout(layout)
else:
raise ValueError(
"Download Dialog constructor called with invalid atlas name."
"Atlas manager dialog constructor"
"called with invalid atlas name."
)
90 changes: 90 additions & 0 deletions brainrender_napari/widgets/atlas_manager_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""The purpose of this file is to provide interactive model and view classes
for a table holding atlases. Users interacting with the table can request to
* download an atlas (double-click on row of a not-yet downloaded atlas)
* add annotation and reference images (double-click on row of local atlas)
* add additional references (right-click on a row and select from menu)
It is designed to be agnostic from the viewer framework by emitting signals
that any interested observers can connect to.
"""
from typing import Callable

from bg_atlasapi.list_atlases import (
get_downloaded_atlases,
)
from bg_atlasapi.update_atlases import install_atlas, update_atlas
from napari.qt import thread_worker
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QTableView, QWidget

from brainrender_napari.data_models.atlas_table_model import AtlasTableModel
from brainrender_napari.widgets.atlas_manager_dialog import AtlasManagerDialog


class AtlasManagerView(QTableView):
download_atlas_confirmed = Signal(str)
update_atlas_confirmed = Signal(str)

def __init__(self, parent: QWidget = None):
"""Initialises an atlas table view with latest atlas versions.
Also responsible for appearance, behaviour on selection, and
setting up signal-slot connections.
"""
super().__init__(parent)

self.setModel(AtlasTableModel())
self.setEnabled(True)
self.verticalHeader().hide()
self.resizeColumnsToContents()

self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.setSelectionMode(QTableView.SelectionMode.SingleSelection)

self.doubleClicked.connect(self._on_row_double_clicked)
self.hideColumn(0) # hide raw name

def _on_row_double_clicked(self):
atlas_name = self.selected_atlas_name()
if atlas_name in get_downloaded_atlases():
# check if update needed
update_dialog = AtlasManagerDialog(atlas_name, "Update")
update_dialog.ok_button.clicked.connect(

Check warning on line 52 in brainrender_napari/widgets/atlas_manager_view.py

View check run for this annotation

Codecov / codecov/patch

brainrender_napari/widgets/atlas_manager_view.py#L51-L52

Added lines #L51 - L52 were not covered by tests
self._on_update_atlas_confirmed
)
update_dialog.exec()

Check warning on line 55 in brainrender_napari/widgets/atlas_manager_view.py

View check run for this annotation

Codecov / codecov/patch

brainrender_napari/widgets/atlas_manager_view.py#L55

Added line #L55 was not covered by tests
else:
download_dialog = AtlasManagerDialog(atlas_name, "Download")
download_dialog.ok_button.clicked.connect(
self._on_download_atlas_confirmed
)
download_dialog.exec()

def _on_download_atlas_confirmed(self):
"""Downloads the currently selected atlas and signals this."""
atlas_name = self.selected_atlas_name()
worker = self._apply_in_thread(install_atlas, atlas_name)
worker.returned.connect(self.download_atlas_confirmed.emit)
worker.start()

def _on_update_atlas_confirmed(self):
"""Updates the currently selected atlas and signals this."""
atlas_name = self.selected_atlas_name()
worker = self._apply_in_thread(update_atlas, atlas_name)
worker.returned.connect(self.update_atlas_confirmed.emit)
worker.start()

def selected_atlas_name(self) -> str:
"""A single place to get a valid selected atlas name."""
selected_index = self.selectionModel().currentIndex()
assert selected_index.isValid()
selected_atlas_name_index = selected_index.siblingAtColumn(0)
selected_atlas_name = self.model().data(selected_atlas_name_index)
return selected_atlas_name

@thread_worker
def _apply_in_thread(self, apply: Callable, atlas_name: str):
"""Calls `apply` on the given atlas in a separate thread."""
apply(atlas_name)
self.model().refresh_data()
return atlas_name

Check warning on line 90 in brainrender_napari/widgets/atlas_manager_view.py

View check run for this annotation

Codecov / codecov/patch

brainrender_napari/widgets/atlas_manager_view.py#L88-L90

Added lines #L88 - L90 were not covered by tests
148 changes: 4 additions & 144 deletions brainrender_napari/widgets/structure_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,158 +2,18 @@
model and view classes for the structures that form part of an atlas.
The view is only visible if the atlas is downloaded."""

from typing import Dict, List

from bg_atlasapi.list_atlases import get_downloaded_atlases
from bg_atlasapi.structure_tree_util import get_structures_tree
from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal
from qtpy.QtGui import QStandardItem
from qtpy.QtCore import QModelIndex, Signal
from qtpy.QtWidgets import QTreeView, QWidget

from brainrender_napari.data_models.structure_tree_model import (
StructureTreeModel,
)
from brainrender_napari.utils.load_user_data import (
read_atlas_structures_from_file,
)


class StructureTreeItem(QStandardItem):
"""A class to hold items in a tree model."""

def __init__(self, data, parent=None):
self.parent_item = parent
self.item_data = data
self.child_items = []

def appendChild(self, item):
self.child_items.append(item)

def child(self, row):
return self.child_items[row]

def childCount(self):
return len(self.child_items)

def columnCount(self):
return len(self.item_data)

def data(self, column):
try:
return self.item_data[column]
except IndexError:
return None

def parent(self):
return self.parent_item

def row(self):
if self.parent_item:
return self.parent_item.child_items.index(self)
return 0


class StructureTreeModel(QAbstractItemModel):
"""Implementation of a read-only QAbstractItemModel to hold
the structure tree information provided by the Atlas API in a Qt Model"""

def __init__(self, data: List, parent=None):
super().__init__()
self.root_item = StructureTreeItem(data=("acronym", "name", "id"))
self.build_structure_tree(data, self.root_item)

def build_structure_tree(self, structures: List, root: StructureTreeItem):
"""Build the structure tree given a list of structures."""
tree = get_structures_tree(structures)
structure_id_dict = {}
for structure in structures:
structure_id_dict[structure["id"]] = structure

inserted_items: Dict[int, StructureTreeItem] = {}
for n_id in tree.expand_tree(): # sorts nodes by default,
# so parents will always be already in the QAbstractItemModel
# before their children
node = tree.get_node(n_id)
acronym = structure_id_dict[node.identifier]["acronym"]
name = structure_id_dict[node.identifier]["name"]
if (
len(structure_id_dict[node.identifier]["structure_id_path"])
== 1
):
parent_item = root
else:
parent_id = tree.parent(node.identifier).identifier
parent_item = inserted_items[parent_id]

item = StructureTreeItem(
data=(acronym, name, node.identifier), parent=parent_item
)
parent_item.appendChild(item)
inserted_items[node.identifier] = item

def data(self, index: QModelIndex, role=Qt.DisplayRole):
"""Provides read-only data for a given index if
intended for display, otherwise None."""
if not index.isValid():
return None

if role != Qt.DisplayRole:
return None

item = index.internalPointer()

return item.data(index.column())

def rowCount(self, parent: StructureTreeItem):
"""Returns the number of rows(i.e. children) of an item"""
if parent.column() > 0:
return 0

if not parent.isValid():
parent_item = self.root_item
else:
parent_item = parent.internalPointer()

return parent_item.childCount()

def columnCount(self, parent: StructureTreeItem):
"""The number of columns of an item."""
if parent.isValid():
return parent.internalPointer().columnCount()
else:
return self.root_item.columnCount()

def parent(self, index: QModelIndex):
"""The first-column index of parent of the item
at a given index. Returns an empty index if the root,
or an invalid index, is passed.
"""
if not index.isValid():
return QModelIndex()

child_item = index.internalPointer()
parent_item = child_item.parent()

if parent_item == self.root_item:
return QModelIndex()

return self.createIndex(parent_item.row(), 0, parent_item)

def index(self, row, column, parent=QModelIndex()):
"""The index of the item at (row, column) with a given parent.
By default, the given parent is assumed to be the root."""
if not self.hasIndex(row, column, parent):
return QModelIndex()

if not parent.isValid():
parent_item = self.root_item
else:
parent_item = parent.internalPointer()

child_item = parent_item.child(row)
if child_item:
return self.createIndex(row, column, child_item)
else:
return QModelIndex()


class StructureView(QTreeView):
add_structure_requested = Signal(str)

Expand Down
28 changes: 27 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import os
import shutil
from pathlib import Path

import pytest
from bg_atlasapi import BrainGlobeAtlas, config
from bg_atlasapi import BrainGlobeAtlas, config, list_atlases
from qtpy.QtCore import Qt


Expand Down Expand Up @@ -88,3 +89,28 @@ def inner_double_click_on_view(view, index):
)

return inner_double_click_on_view


@pytest.fixture
def mock_newer_atlas_version_available():
current_version_path = Path.home() / ".brainglobe/example_mouse_100um_v1.2"
older_version_path = Path.home() / ".brainglobe/example_mouse_100um_v1.1"
assert current_version_path.exists() and not older_version_path.exists()

current_version_path.rename(older_version_path)
assert older_version_path.exists() and not current_version_path.exists()
assert (
list_atlases.get_atlases_lastversions()["example_mouse_100um"][
"latest_version"
]
== "1.2"
)
assert list_atlases.get_local_atlas_version("example_mouse_100um") == "1.1"

yield # run test with outdated version

# cleanup: ensure version is up-to-date again
if older_version_path.exists():
shutil.rmtree(path=older_version_path)
_ = BrainGlobeAtlas("example_mouse_100um")
assert current_version_path.exists() and not older_version_path.exists()
Loading

0 comments on commit af689dc

Please sign in to comment.