Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GUI: add top level tree to main window #69

Merged
merged 11 commits into from
Aug 1, 2024
25 changes: 25 additions & 0 deletions docs/source/upcoming_release_notes/69-gui_top_tree.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
69 gui_top_tree
###############

API Breaks
----------
- N/A

Features
--------
- Adds top-level tree to main window
- Adds icons to the TreeModel
- Adds fill_uuid method to TreeModel, which grabs data from client if possible
- Adds context menu on tree view for accessing custom actions.

Bugfixes
--------
- N/A

Maintenance
-----------
- N/A

Contributors
------------
- tangkong
7 changes: 6 additions & 1 deletion superscore/backends/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Generator
from uuid import UUID

from superscore.model import Entry
from superscore.model import Entry, Root


class _Backend:
Expand Down Expand Up @@ -43,3 +43,8 @@ def update_entry(self, entry: Entry) -> None:
def search(self, **search_kwargs) -> Generator[Entry, None, None]:
"""Yield a Entry objects corresponding matching ``search_kwargs``"""
raise NotImplementedError

@property
def root(self) -> Root:
"""Return the Root Entry in this backend"""
raise NotImplementedError
20 changes: 14 additions & 6 deletions superscore/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -703,12 +703,8 @@ def dummy_cl() -> ControlLayer:

@pytest.fixture(scope='function')
def mock_backend() -> _Backend:
bk = _Backend()
bk.delete_entry = MagicMock()
bk.save_entry = MagicMock()
bk.get_entry = MagicMock()
bk.search = MagicMock()
bk.update_entry = MagicMock()
mock_bk = MagicMock(spec=_Backend)
return mock_bk


class MockTaskStatus:
Expand All @@ -724,3 +720,15 @@ def done(self):
def mock_client(mock_backend: _Backend) -> Client:
client = Client(backend=mock_backend)
return client


@pytest.fixture(scope='function')
def sample_client(
filestore_backend: FilestoreBackend,
dummy_cl: ControlLayer
) -> Client:
"""Return a client with actula data, but no communication capabilities"""
client = Client(backend=filestore_backend)
client.cl = dummy_cl

return client
48 changes: 29 additions & 19 deletions superscore/tests/test_page.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
"""Largely smoke tests for various pages"""

from typing import List

import pytest
from pytestqt.qtbot import QtBot

from superscore.client import Client
from superscore.model import Collection
from superscore.widgets.core import DataWidget
from superscore.widgets.page.entry import CollectionPage
from superscore.widgets.page.search import SearchPage

Expand All @@ -21,27 +18,40 @@ def collection_page(qtbot: QtBot):


@pytest.fixture(scope='function')
def search_page(qtbot: QtBot, mock_client: Client):
page = SearchPage(client=mock_client)
def search_page(qtbot: QtBot, sample_client: Client):
page = SearchPage(client=sample_client)
qtbot.addWidget(page)
return page


@pytest.fixture(scope='function')
def test_pages(
collection_page: CollectionPage,
search_page: SearchPage,
) -> List[DataWidget]:
return [collection_page, search_page,]
@pytest.mark.parametrize('page', ["collection_page", "search_page"])
def test_page_smoke(page: str, request: pytest.FixtureRequest):
"""smoke test, just create each page and see if they fail"""
print(type(request.getfixturevalue(page)))


@pytest.fixture(scope='function')
def pages(request, test_pages: List[DataWidget]):
i = request.param
return test_pages[i]
def test_apply_filter(search_page: SearchPage):
search_page.apply_filter_button.clicked.emit()
assert search_page.results_table_view.model().rowCount() == 6

search_page.snapshot_checkbox.setChecked(False)
search_page.apply_filter_button.clicked.emit()
assert search_page.results_table_view.model().rowCount() == 5

@pytest.mark.parametrize('pages', [0, 1], indirect=True)
def test_page_smoke(pages):
"""smoke test, just create each page and see if they fail"""
print(type(pages))
search_page.readback_checkbox.setChecked(False)
search_page.apply_filter_button.clicked.emit()
assert search_page.results_table_view.model().rowCount() == 2

search_page.setpoint_checkbox.setChecked(False)
search_page.apply_filter_button.clicked.emit()
assert search_page.results_table_view.model().rowCount() == 1

# reset and try name filter
for box in search_page.type_checkboxes:
box.setChecked(True)
search_page.apply_filter_button.clicked.emit()
assert search_page.results_table_view.model().rowCount() == 6

search_page.name_line_edit.setText('collection 1')
search_page.apply_filter_button.clicked.emit()
assert search_page.results_table_view.model().rowCount() == 1
44 changes: 44 additions & 0 deletions superscore/tests/test_window.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,54 @@
from uuid import UUID

from pytestqt.qtbot import QtBot

from superscore.client import Client
from superscore.widgets.window import Window


def count_visible_items(tree_view):
count = 0
index = tree_view.model().index(0, 0)
while index.isValid():
count += 1
print(type(index.internalPointer()._data).__name__)
index = tree_view.indexBelow(index)
return count


def test_main_window(qtbot: QtBot, mock_client: Client):
"""Pass if main window opens successfully"""
window = Window(client=mock_client)
qtbot.addWidget(window)


def test_sample_window(qtbot: QtBot, sample_client: Client):
window = Window(client=sample_client)
qtbot.addWidget(window)

assert count_visible_items(window.tree_view) == 4

def get_last_index(index):
curr_index = index
while curr_index.isValid():
new_index = window.tree_view.indexBelow(curr_index)
if not new_index.isValid():
break
curr_index = new_index
return curr_index

first_index = window.tree_view.model().index(0, 0)
last_index = get_last_index(first_index)
window.tree_view.expand(last_index)

assert count_visible_items(window.tree_view) == 7

# get new last index after expansion, and signal view has been updated
new_last_index = get_last_index(first_index)
window.tree_view.dataChanged(first_index, new_last_index)

# check that all exposed entries have been filled
index = first_index
while index.isValid():
assert not isinstance(index.internalPointer()._data, UUID)
index = window.tree_view.indexBelow(index)
16 changes: 16 additions & 0 deletions superscore/widgets/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
def _get_icon_map():
# do not pollute namespace
from superscore.model import Collection, Readback, Setpoint, Snapshot

# a list of qtawesome icon names
icon_map = {
Collection: 'mdi.file-document-multiple',
Snapshot: 'mdi.camera',
Setpoint: 'mdi.target',
Readback: 'mdi.book-open-variant',
}

return icon_map


ICON_MAP = _get_icon_map()
16 changes: 4 additions & 12 deletions superscore/widgets/page/__init__.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
def get_page_icon_map():
def get_page_map():
# Don't pollute the namespace
from superscore.model import Collection, Readback, Setpoint, Snapshot
from superscore.model import Collection
from superscore.widgets.page.entry import CollectionPage

page_map = {
Collection: CollectionPage
}

# a list of qtawesome icon names
icon_map = {
Collection: 'mdi.file-document-multiple',
Snapshot: 'mdi.camera',
Setpoint: 'mdi.target',
Readback: 'mdi.book-open-variant',
}

return page_map, icon_map
return page_map


PAGE_MAP, ICON_MAP = get_page_icon_map()
PAGE_MAP = get_page_map()
2 changes: 1 addition & 1 deletion superscore/widgets/page/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

from superscore.client import Client
from superscore.model import Collection, Entry, Readback, Setpoint, Snapshot
from superscore.widgets import ICON_MAP
from superscore.widgets.core import Display
from superscore.widgets.page import ICON_MAP

logger = logging.getLogger(__name__)

Expand Down
24 changes: 22 additions & 2 deletions superscore/widgets/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@

import logging
from typing import Any, ClassVar, Generator, List, Optional
from uuid import UUID
from weakref import WeakValueDictionary

import qtawesome as qta
from PyQt5.QtCore import Qt
from qtpy import QtCore

from superscore.client import Client
from superscore.model import Entry, Nestable, Root
from superscore.qt_helpers import QDataclassBridge
from superscore.widgets import ICON_MAP

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -52,6 +55,11 @@ 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``"""
if isinstance(self._data, UUID) and client is not None:
self._data = list(client.search(uuid=self._data))[0]

def data(self, column: int) -> Any:
"""
Return the data for the requested column.
Expand Down Expand Up @@ -180,6 +188,13 @@ def takeChildren(self) -> list[EntryItem]:

return children

def icon(self):
"""return icon for this item"""
icon_id = ICON_MAP.get(type(self._data), None)
if icon_id is None:
return
return qta.icon(ICON_MAP[type(self._data)])


def build_tree(entry: Entry, parent: Optional[EntryItem] = None) -> EntryItem:
"""
Expand Down Expand Up @@ -271,7 +286,7 @@ def index(
self,
row: int,
column: int,
parent: QtCore.QModelIndex = None
parent: QtCore.QModelIndex = QtCore.QModelIndex()
) -> QtCore.QModelIndex:
"""
Returns the index of the item in the model.
Expand Down Expand Up @@ -397,12 +412,14 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any:
return None

item: EntryItem = index.internalPointer() # Gives original EntryItem
item.fill_uuids(client=self.client)

# special handling for status info
if index.column() == 1:
if role == Qt.DisplayRole:
return item.data(1)
if role == Qt.TextAlignmentRole:
return Qt.AlignCenter
return Qt.AlignLeft

if role == Qt.ToolTipRole:
return item.tooltip()
Expand All @@ -412,4 +429,7 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any:
if role == Qt.UserRole:
return item

if role == Qt.DecorationRole and index.column() == 0:
return item.icon()

return None
28 changes: 27 additions & 1 deletion superscore/widgets/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@

from superscore.client import Client
from superscore.model import Entry
from superscore.widgets import ICON_MAP
from superscore.widgets.core import Display
from superscore.widgets.page import ICON_MAP, PAGE_MAP
from superscore.widgets.page import PAGE_MAP
from superscore.widgets.page.search import SearchPage
from superscore.widgets.tree import RootTree

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -43,6 +45,13 @@ def setup_ui(self) -> None:
tab_bar.setElideMode(QtCore.Qt.ElideNone)
self.tab_widget.tabCloseRequested.connect(self.tab_widget.removeTab)

# 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)

def open_page(self, entry: Entry) -> None:
"""
Open a page for ``entry`` in a new tab.
Expand Down Expand Up @@ -77,3 +86,20 @@ def open_page(self, entry: Entry) -> None:
def open_search_page(self) -> None:
page = SearchPage(client=self.client, open_page_slot=self.open_page)
self.tab_widget.addTab(page, 'search')

def _tree_context_menu(self, pos: QtCore.QPoint) -> None:
self.menu = QtWidgets.QMenu(self)
index: QtCore.QModelIndex = self.tree_view.indexAt(pos)
entry: Entry = index.internalPointer()._data

if index is not None and index.data() is not None:
# WeakPartialMethodSlot may not be needed, menus are transient
def open(*_, **__):
self.open_page(entry)

open_action = self.menu.addAction(
f'&Open Detailed {type(entry).__name__} page'
)
open_action.triggered.connect(open)

self.menu.exec_(self.tree_view.mapToGlobal(pos))