diff --git a/docs/source/upcoming_release_notes/107-mnt_upstream_tree_view.rst b/docs/source/upcoming_release_notes/107-mnt_upstream_tree_view.rst
new file mode 100644
index 0000000..f0d1b1f
--- /dev/null
+++ b/docs/source/upcoming_release_notes/107-mnt_upstream_tree_view.rst
@@ -0,0 +1,22 @@
+107 mnt_upstream_tree_view
+##########################
+
+API Breaks
+----------
+- N/A
+
+Features
+--------
+- N/A
+
+Bugfixes
+--------
+- Fills the top-level entry whenever RootTree model is created, to ensure at least first level of children display
+
+Maintenance
+-----------
+- Adds common RootTreeView which holds the RootTree model. Upstreams standard settings and context menu support
+
+Contributors
+------------
+- tangkong
diff --git a/superscore/backends/test.py b/superscore/backends/test.py
index 71b9d13..9e0a042 100644
--- a/superscore/backends/test.py
+++ b/superscore/backends/test.py
@@ -13,6 +13,7 @@
class TestBackend(_Backend):
"""Backend that manipulates Entries in-memory, for testing purposes."""
+ __test__ = False # Tell pytest this isn't a test case
_entry_cache: Dict[UUID, Entry] = {}
def __init__(self, data: Optional[List[Entry]] = None):
diff --git a/superscore/tests/test_views.py b/superscore/tests/test_views.py
index e987e29..bb3e248 100644
--- a/superscore/tests/test_views.py
+++ b/superscore/tests/test_views.py
@@ -11,11 +11,12 @@
from superscore.backends.test import TestBackend
from superscore.client import Client
from superscore.control_layers import EpicsData
-from superscore.model import Collection, Parameter, Severity, Status
+from superscore.model import Collection, Parameter, Root, Severity, Status
from superscore.tests.conftest import nest_depth
-from superscore.widgets.views import (CustRoles, LivePVHeader,
+from superscore.widgets.views import (CustRoles, EntryItem, LivePVHeader,
LivePVTableModel, LivePVTableView,
- NestableTableView, RootTree)
+ NestableTableView, RootTree,
+ RootTreeView)
@pytest.fixture(scope='function')
@@ -242,24 +243,55 @@ def test_fill_uuids_entry_item(linac_backend: TestBackend, qtbot: QtBot):
nested_coll.swap_to_uuids()
assert all(isinstance(c, UUID) for c in nested_coll.children)
- tree_model = RootTree(base_entry=nested_coll, client=client)
+ # hack: make all of linac_backend flat
+ for entry in linac_backend._entry_cache.values():
+ entry.swap_to_uuids()
+ tree_model = RootTree(base_entry=nested_coll, client=client)
original_depth = nest_depth(tree_model.root_item)
- assert original_depth == 1
+ # default fill depth is 2, so children and their children get EntryItems
+ assert original_depth == 2
# fill just the first child
# fill depth can depend on how the backend returns data. Backend may not
# be lazy, so we assert only child1's children have EntryItems
root_item = tree_model.root_item
child1 = root_item.child(0)
- assert child1.childCount() == 0
+ assert child1.child(0).childCount() == 0
child1.fill_uuids(client)
- assert child1.childCount() > 0
- assert root_item.child(1).childCount() == 0
- assert root_item.child(2).childCount() == 0
+ assert child1.child(0).childCount() > 0
+ assert root_item.child(1).child(0).childCount() == 0
+ assert root_item.child(2).child(0).childCount() == 0
- # filling the root item fills its children, which held uuids before
+ # filling only occurs if direct children are UUIDs, does nothing here
+ # since it was originally filled by RootTree
root_item.fill_uuids(client)
- assert root_item.child(0).childCount() > 0
- assert root_item.child(1).childCount() > 0
- assert root_item.child(2).childCount() > 0
+ assert root_item.child(0).child(0).childCount() > 0
+ assert root_item.child(1).child(0).childCount() == 0
+ assert root_item.child(2).child(0).childCount() == 0
+
+
+def test_roottree_setup(sample_database: Root):
+ tree_model = RootTree(base_entry=sample_database)
+ root_index = tree_model.index_from_item(tree_model.root_item)
+ # Check that the entire tree was created
+ assert tree_model.rowCount(root_index) == 4
+ assert tree_model.root_item.child(3).childCount() == 3
+
+
+def test_root_tree_view_setup_init_args(sample_client: Client):
+ tree_view = RootTreeView(
+ client=sample_client,
+ entry=sample_client.backend.root
+ )
+ assert isinstance(tree_view.model().root_item, EntryItem)
+ assert isinstance(tree_view.model(), RootTree)
+
+
+def test_root_tree_view_setup_post_init(sample_client: Client):
+ tree_view = RootTreeView()
+ tree_view.client = sample_client
+ tree_view.set_data(sample_client.backend.root)
+
+ assert isinstance(tree_view.model().root_item, EntryItem)
+ assert isinstance(tree_view.model(), RootTree)
diff --git a/superscore/tests/test_widgets.py b/superscore/tests/test_widgets.py
index f3bd2f2..9e2be7f 100644
--- a/superscore/tests/test_widgets.py
+++ b/superscore/tests/test_widgets.py
@@ -5,9 +5,8 @@
import pytest
from pytestqt.qtbot import QtBot
-from superscore.model import Collection, Root
+from superscore.model import Collection
from superscore.widgets.core import DataWidget
-from superscore.widgets.views import RootTree
@pytest.mark.parametrize(
@@ -39,11 +38,3 @@ def test_collection_datawidget_bridge(
qtbot.addWidget(widget1)
qtbot.addWidget(widget2)
-
-
-def test_roottree_setup(sample_database: Root):
- tree_model = RootTree(base_entry=sample_database)
- root_index = tree_model.index_from_item(tree_model.root_item)
- # Check that the entire tree was created
- assert tree_model.rowCount(root_index) == 4
- assert tree_model.root_item.child(3).childCount() == 3
diff --git a/superscore/tests/test_window.py b/superscore/tests/test_window.py
index 6062b08..4ba998a 100644
--- a/superscore/tests/test_window.py
+++ b/superscore/tests/test_window.py
@@ -17,9 +17,9 @@ def count_visible_items(tree_view):
return count
-def test_main_window(qtbot: QtBot, mock_client: Client):
+def test_main_window(qtbot: QtBot, sample_client: Client):
"""Pass if main window opens successfully"""
- window = Window(client=mock_client)
+ window = Window(client=sample_client)
qtbot.addWidget(window)
diff --git a/superscore/ui/collection_builder_page.ui b/superscore/ui/collection_builder_page.ui
index 58d5b31..e20e3c5 100644
--- a/superscore/ui/collection_builder_page.ui
+++ b/superscore/ui/collection_builder_page.ui
@@ -42,7 +42,7 @@
Qt::Horizontal
-
+
1
@@ -213,6 +213,11 @@
QTableView
+
+ RootTreeView
+ QTreeView
+
+
diff --git a/superscore/ui/main_window.ui b/superscore/ui/main_window.ui
index 0bce192..4f6e15b 100644
--- a/superscore/ui/main_window.ui
+++ b/superscore/ui/main_window.ui
@@ -53,7 +53,7 @@
-
-
+
0
@@ -85,7 +85,7 @@
0
0
800
- 37
+ 20
+
+
+ RootTreeView
+ QTreeView
+
+
+
diff --git a/superscore/ui/nestable_page.ui b/superscore/ui/nestable_page.ui
index bf05470..acf92ea 100644
--- a/superscore/ui/nestable_page.ui
+++ b/superscore/ui/nestable_page.ui
@@ -42,7 +42,7 @@
Qt::Horizontal
-
+
1
@@ -85,6 +85,11 @@
QTableView
+
+ RootTreeView
+ QTreeView
+
+
diff --git a/superscore/widgets/page/collection_builder.py b/superscore/widgets/page/collection_builder.py
index 4d115c1..fd0d166 100644
--- a/superscore/widgets/page/collection_builder.py
+++ b/superscore/widgets/page/collection_builder.py
@@ -12,7 +12,7 @@
from superscore.widgets.manip_helpers import insert_widget
from superscore.widgets.views import (BaseTableEntryModel, LivePVHeader,
LivePVTableView, NestableTableView,
- RootTree)
+ RootTree, RootTreeView)
logger = logging.getLogger(__name__)
@@ -24,7 +24,7 @@ class CollectionBuilderPage(Display, DataWidget):
meta_placeholder: QtWidgets.QWidget
meta_widget: NameDescTagsWidget
- tree_view: QtWidgets.QTreeView
+ tree_view: RootTreeView
sub_coll_table_view: NestableTableView
sub_pv_table_view: LivePVTableView
@@ -88,8 +88,11 @@ def setup_ui(self):
self.sub_coll_table_view.client = self.client
self.sub_coll_table_view.set_data(self.data)
- self.tree_model = RootTree(base_entry=self.data, client=self.client)
- self.tree_view.setModel(self.tree_model)
+ self.tree_view.client = self.client
+ self.tree_view.set_data(self.data)
+ self.tree_view.open_page_slot = self.open_page_slot
+ self.tree_model: RootTree = self.tree_view.model()
+
self.sub_coll_table_view.data_updated.connect(self.tree_model.refresh_tree)
self.sub_pv_table_view.data_updated.connect(self.tree_model.refresh_tree)
diff --git a/superscore/widgets/page/entry.py b/superscore/widgets/page/entry.py
index c8c5d53..c6d37f9 100644
--- a/superscore/widgets/page/entry.py
+++ b/superscore/widgets/page/entry.py
@@ -19,7 +19,8 @@
match_line_edit_text_width)
from superscore.widgets.thread_helpers import BusyCursorThread
from superscore.widgets.views import (LivePVTableView, NestableTableView,
- RootTree, edit_widget_from_epics_data)
+ RootTreeView,
+ edit_widget_from_epics_data)
logger = logging.getLogger(__name__)
@@ -29,7 +30,7 @@ class NestablePage(Display, DataWidget):
meta_placeholder: QtWidgets.QWidget
meta_widget: NameDescTagsWidget
- tree_view: QtWidgets.QTreeView
+ tree_view: RootTreeView
sub_coll_table_view: NestableTableView
sub_pv_table_view: LivePVTableView
@@ -58,8 +59,9 @@ def setup_ui(self):
insert_widget(self.meta_widget, self.meta_placeholder)
# show tree view
- self.model = RootTree(base_entry=self.data, client=self.client)
- self.tree_view.setModel(self.model)
+ self.tree_view.client = self.client
+ self.tree_view.set_data(self.data)
+ self.tree_view.open_page_slot = self.open_page_slot
self.sub_pv_table_view.client = self.client
self.sub_pv_table_view.set_data(self.data)
diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py
index e8a4332..710026b 100644
--- a/superscore/widgets/views.py
+++ b/superscore/widgets/views.py
@@ -7,6 +7,7 @@
import logging
import time
from enum import Enum, IntEnum, auto
+from functools import partial
from typing import Any, ClassVar, Dict, Generator, List, Optional, Union
from uuid import UUID
from weakref import WeakValueDictionary
@@ -70,8 +71,15 @@ def __init__(
self._bridge_cache[id(data)] = bridge
self.bridge = bridge
- def fill_uuids(self, client: Optional[Client] = None) -> None:
- """Fill this item's data if it is a uuid, using ``client``"""
+ def fill_uuids(
+ self,
+ client: Optional[Client] = None,
+ fill_depth: int = 2
+ ) -> None:
+ """
+ Fill this item's data if it is a uuid, using ``client``.
+ By default fills to a depth of 2, to keep the tree view data loading lazy
+ """
if client is None:
return
@@ -81,7 +89,7 @@ def fill_uuids(self, client: Optional[Client] = None) -> None:
if isinstance(self._data, Nestable):
if any(isinstance(child, UUID) for child in self._data.children):
- client.fill(self._data, fill_depth=2)
+ client.fill(self._data, fill_depth=fill_depth)
# re-construct child EntryItems if there is a mismatch or if any
# hold UUIDs as _data
@@ -288,6 +296,8 @@ def __init__(
self.base_entry = base_entry
self.root_item = build_tree(base_entry)
self.client = client
+ # ensure at least the first set of children are filled
+ self.root_item.fill_uuids(self.client)
self.headers = ['name', 'description']
def refresh_tree(self) -> None:
@@ -497,6 +507,111 @@ def fetchMore(self, parent: QtCore.QModelIndex) -> None:
self.endInsertRows()
+class RootTreeView(QtWidgets.QTreeView):
+ """
+ Tree view for displaying an Entry.
+ Contains a standard context menu and action set
+ """
+
+ _model: Optional[RootTree] = None
+ data_updated: ClassVar[QtCore.Signal] = QtCore.Signal()
+
+ def __init__(
+ self,
+ *args,
+ client: Optional[Client] = None,
+ entry: Optional[Entry] = None,
+ open_page_slot: Optional[OpenPageSlot] = None,
+ **kwargs,
+ ):
+ super().__init__(*args, **kwargs)
+ self._client = client
+ self.data = entry
+ self.open_page_slot = open_page_slot
+
+ self.setup_ui()
+
+ def setup_ui(self) -> None:
+ # Configure basic settings
+ self.maybe_setup_model()
+
+ self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ self.customContextMenuRequested.connect(self._tree_context_menu)
+ self.setExpandsOnDoubleClick(False)
+ self.doubleClicked.connect(self.open_index)
+
+ @property
+ def client(self):
+ return self._client
+
+ @client.setter
+ def client(self, client: Client):
+ if client is self._client:
+ return
+
+ if not isinstance(client, Client):
+ raise ValueError("Provided client is not a superscore Client")
+
+ self._client = client
+ self.maybe_setup_model()
+
+ def set_data(self, data: Any):
+ """Set the data for this view, re-setup ui"""
+ if not isinstance(data, (Root, Entry)):
+ raise ValueError(
+ f"Attempted to set an incompatable data type ({type(data)})"
+ )
+ self.data = data
+ self.maybe_setup_model()
+
+ def maybe_setup_model(self):
+ if self.client is None:
+ logger.debug("Client not set, cannot initialize model")
+ return
+
+ if self.data is None:
+ logger.debug("data not set, cannot initialize model")
+ return
+
+ self._model = RootTree(base_entry=self.data, client=self.client)
+ self.setModel(self._model)
+ self._model.dataChanged.connect(self.data_updated)
+
+ self.data_updated.emit()
+
+ def _tree_context_menu(self, pos: QtCore.QPoint) -> None:
+ index: QtCore.QModelIndex = self.indexAt(pos)
+ if index is not None and index.data() is not None:
+ entry: Entry = index.internalPointer()._data
+ menu = self.create_context_menu(entry)
+
+ menu.exec_(self.mapToGlobal(pos))
+
+ def create_context_menu(self, entry: Entry) -> QtWidgets.QMenu:
+ """
+ Default method for creating the context menu.
+ Overload/replace this method if you would like to change this behavior
+ """
+ menu = QtWidgets.QMenu(self)
+ open_action = menu.addAction(
+ f'&Open Detailed {type(entry).__name__} page'
+ )
+ # WeakPartialMethodSlot may not be needed, menus are transient
+ open_action.triggered.connect(partial(self.open_page, entry))
+
+ return menu
+
+ def open_index(self, index: QtCore.QModelIndex) -> None:
+ entry: Entry = index.internalPointer()._data
+ print(f'double click on : {type(entry)}')
+ self.open_page(entry)
+
+ def open_page(self, entry):
+ """Simple wrapper around the open page slot"""
+ if self.open_page_slot is not None:
+ self.open_page_slot(entry)
+
+
class HeaderEnum(IntEnum):
"""
Enum for more readable header names. Underscores will be replaced with spaces
diff --git a/superscore/widgets/window.py b/superscore/widgets/window.py
index 8dd27e8..084db0d 100644
--- a/superscore/widgets/window.py
+++ b/superscore/widgets/window.py
@@ -20,7 +20,7 @@
from superscore.widgets.page.collection_builder import CollectionBuilderPage
from superscore.widgets.page.restore import RestorePage
from superscore.widgets.page.search import SearchPage
-from superscore.widgets.views import RootTree
+from superscore.widgets.views import RootTreeView
logger = logging.getLogger(__name__)
@@ -30,7 +30,7 @@ class Window(Display, QtWidgets.QMainWindow):
filename = 'main_window.ui'
- tree_view: QtWidgets.QTreeView
+ tree_view: RootTreeView
tab_widget: QtWidgets.QTabWidget
action_new_coll: QtWidgets.QAction
@@ -55,13 +55,11 @@ def setup_ui(self) -> None:
self.tab_widget.tabCloseRequested.connect(self.remove_tab)
# setup tree view
- self.tree_model = RootTree(base_entry=self.client.backend.root,
- client=self.client)
- self.tree_view.setModel(self.tree_model)
- self.tree_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
- self.tree_view.customContextMenuRequested.connect(self._tree_context_menu)
- self.tree_view.setExpandsOnDoubleClick(False)
- self.tree_view.doubleClicked.connect(self.open_index)
+ self.tree_view.client = self.client
+ self.tree_view.set_data(self.client.backend.root)
+ # override context menu and open_page_slot methods
+ self.tree_view.create_context_menu = self._window_context_menu
+ self.tree_view.open_page_slot = self.open_page
# setup actions
self.action_new_coll.triggered.connect(self.open_collection_builder)
@@ -144,20 +142,19 @@ def open_restore_page(self, snapshot: Snapshot) -> None:
index = self.tab_widget.addTab(page, snapshot.title)
self.tab_widget.setCurrentIndex(index)
- def _tree_context_menu(self, pos: QtCore.QPoint) -> None:
- self.menu = QtWidgets.QMenu(self)
- index: QtCore.QModelIndex = self.tree_view.indexAt(pos)
- if index is not None and index.data() is not None:
- entry: Entry = index.internalPointer()._data
- open_action = self.menu.addAction(
- f'&Open Detailed {type(entry).__name__} page'
- )
- # WeakPartialMethodSlot may not be needed, menus are transient
- open_action.triggered.connect(partial(self.open_page, entry))
- if isinstance(entry, Snapshot):
- restore_page_action = self.menu.addAction('Inspect values')
- restore_page_action.triggered.connect(partial(self.open_restore_page, entry))
- self.menu.exec_(self.tree_view.mapToGlobal(pos))
+ def _window_context_menu(self, entry: Entry) -> QtWidgets.QMenu:
+ """override for RootTreeView context menu"""
+ menu = QtWidgets.QMenu(self)
+ open_action = menu.addAction(
+ f'&Open Detailed {type(entry).__name__} page'
+ )
+ # WeakPartialMethodSlot may not be needed, menus are transient
+ open_action.triggered.connect(partial(self.open_page, entry))
+ if isinstance(entry, Snapshot):
+ restore_page_action = menu.addAction('Inspect values')
+ restore_page_action.triggered.connect(partial(self.open_restore_page, entry))
+
+ return menu
def closeEvent(self, a0: QCloseEvent) -> None:
while self.tab_widget.count() > 0: