Skip to content

Commit

Permalink
implement manager view and dialog (#124)
Browse files Browse the repository at this point in the history
* implement manager view and dialog

and delegate tooltips to views

* add up-to-date check to manager view

+ tweak to avoid hard-coded column index

* make get_tooltip public and more robust

* improve test coverage

found a tiny bug in the process!

* use reload module to patch thread_worker

so it's picked up by coverage

* Improve docstring

Co-authored-by: Igor Tatarnikov <[email protected]>

---------

Co-authored-by: Igor Tatarnikov <[email protected]>
  • Loading branch information
alessandrofelder and IgorTatarnikov authored Jan 5, 2024
1 parent a5497f8 commit 014f5c5
Show file tree
Hide file tree
Showing 11 changed files with 447 additions and 85 deletions.
43 changes: 11 additions & 32 deletions brainrender_napari/data_models/atlas_table_model.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
from bg_atlasapi.list_atlases import (
get_all_atlases_lastversions,
get_atlases_lastversions,
get_downloaded_atlases,
get_local_atlas_version,
)
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt
from qtpy.QtWidgets import QTableView

from brainrender_napari.utils.load_user_data import (
read_atlas_metadata_from_file,
)
from brainrender_napari.utils.formatting import format_atlas_name


class AtlasTableModel(QAbstractTableModel):
"""A table data model for atlases."""

def __init__(self):
def __init__(self, view_type: QTableView):
super().__init__()
self.column_headers = [
"Raw name",
"Atlas",
"Local version",
"Latest version",
]
assert hasattr(
view_type, "get_tooltip_text"
), "Views for this model must implement"
"a `classmethod` called `get_tooltip_text`"
self.view_type = view_type
self.refresh_data()

def refresh_data(self) -> None:
Expand All @@ -33,30 +36,24 @@ def refresh_data(self) -> None:
data.append(
[
name,
self._format_name(name),
format_atlas_name(name),
get_local_atlas_version(name),
latest_version,
]
)
else:
data.append(
[name, self._format_name(name), "n/a", latest_version]
[name, format_atlas_name(name), "n/a", latest_version]
)

self._data = data

def _format_name(self, name: str) -> str:
formatted_name = name.split("_")
formatted_name[0] = formatted_name[0].capitalize()
formatted_name[-1] = f"({formatted_name[-1].split('um')[0]} \u03BCm)"
return " ".join([formatted for formatted in formatted_name])

def data(self, index: QModelIndex, role=Qt.DisplayRole):
if role == Qt.DisplayRole:
return self._data[index.row()][index.column()]
if role == Qt.ToolTipRole:
hovered_atlas_name = self._data[index.row()][0]
return AtlasTableModel._get_tooltip_text(hovered_atlas_name)
return self.view_type.get_tooltip_text(hovered_atlas_name)

def rowCount(self, index: QModelIndex = QModelIndex()):
return len(self._data)
Expand All @@ -76,21 +73,3 @@ def headerData(
raise ValueError("Unexpected horizontal header value.")
else:
return super().headerData(section, orientation, role)

@classmethod
def _get_tooltip_text(cls, atlas_name: str):
"""Returns the atlas metadata as a formatted string,
as well as instructions on how to interact with the atlas."""
if atlas_name in get_downloaded_atlases():
metadata = read_atlas_metadata_from_file(atlas_name)
metadata_as_string = ""
for key, value in metadata.items():
metadata_as_string += f"{key}:\t{value}\n"

tooltip_text = f"{atlas_name} (double-click to add to viewer)\
\n{metadata_as_string}"
elif atlas_name in get_all_atlases_lastversions().keys():
tooltip_text = f"{atlas_name} (double-click to download)"
else:
raise ValueError("Tooltip text called with invalid atlas name.")
return tooltip_text
12 changes: 12 additions & 0 deletions brainrender_napari/utils/formatting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from bg_atlasapi.list_atlases import get_all_atlases_lastversions


def format_atlas_name(name: str) -> str:
"""Format an atlas name nicely.
Assumes input in the form of atlas_name_in_snake_case_RESOLUTIONum,
e.g. allen_mouse_100um"""
assert name in get_all_atlases_lastversions().keys(), "invalid atlas name!"
formatted_name = name.split("_")
formatted_name[0] = formatted_name[0].capitalize()
formatted_name[-1] = f"({formatted_name[-1].split('um')[0]} \u03BCm)"
return " ".join([formatted for formatted in formatted_name])
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."
)
118 changes: 118 additions & 0 deletions brainrender_napari/widgets/atlas_manager_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""The purpose of this file is to provide interactive table view to download
and update atlases. Users interacting with the table can request to
* download an atlas (double-click on row of a not-yet downloaded atlas)
* update an atlas (double-click on row of outdated local atlas)
They can also hover over an up-to-date local atlas and see that
it's up to date.
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_all_atlases_lastversions,
get_atlases_lastversions,
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.utils.formatting import format_atlas_name
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(AtlasManagerView))
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(
self.model().column_headers.index("Raw name")
) # hide raw name

def _on_row_double_clicked(self):
atlas_name = self.selected_atlas_name()
if atlas_name in get_downloaded_atlases():
up_to_date = get_atlases_lastversions()[atlas_name]["updated"]
if not up_to_date:
update_dialog = AtlasManagerDialog(atlas_name, "Update")
update_dialog.ok_button.clicked.connect(
self._on_update_atlas_confirmed
)
update_dialog.exec()
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

@classmethod
def get_tooltip_text(cls, atlas_name: str):
"""Returns the atlas name as a formatted string,
as well as instructions on how to interact with the atlas."""
if atlas_name in get_downloaded_atlases():
is_up_to_date = get_atlases_lastversions()[atlas_name]["updated"]
if is_up_to_date:
tooltip_text = f"{format_atlas_name(atlas_name)} is up-to-date"
else: # needs updating
tooltip_text = (
f"{format_atlas_name(atlas_name)} (double-click to update)"
)
elif atlas_name in get_all_atlases_lastversions().keys():
tooltip_text = (
f"{format_atlas_name(atlas_name)} (double-click to download)"
)
else:
raise ValueError("Tooltip text called with invalid atlas name.")
return tooltip_text
19 changes: 18 additions & 1 deletion brainrender_napari/widgets/atlas_viewer_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from qtpy.QtWidgets import QMenu, QTableView, QWidget

from brainrender_napari.data_models.atlas_table_model import AtlasTableModel
from brainrender_napari.utils.formatting import format_atlas_name
from brainrender_napari.utils.load_user_data import (
read_atlas_metadata_from_file,
)
Expand All @@ -34,7 +35,7 @@ def __init__(self, parent: QWidget = None):
"""
super().__init__(parent)

self.setModel(AtlasTableModel())
self.setModel(AtlasTableModel(AtlasViewerView))

self.setEnabled(True)
self.verticalHeader().hide()
Expand Down Expand Up @@ -102,3 +103,19 @@ def _on_row_double_clicked(self) -> None:
def _on_current_changed(self) -> None:
"""Emits a signal with the newly selected atlas name"""
self.selected_atlas_changed.emit(self.selected_atlas_name())

@classmethod
def get_tooltip_text(cls, atlas_name: str):
"""Returns the atlas metadata as a formatted string,
as well as instructions on how to interact with the atlas."""
if atlas_name in get_downloaded_atlases():
metadata = read_atlas_metadata_from_file(atlas_name)
metadata_as_string = ""
for key, value in metadata.items():
metadata_as_string += f"{key}:\t{value}\n"
tooltip_text = f"{format_atlas_name(atlas_name)}\
(double-click to add to viewer)\
\n{metadata_as_string}"
else:
raise ValueError("Tooltip text called with invalid atlas name.")
return tooltip_text
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 014f5c5

Please sign in to comment.