diff --git a/docs/source/upcoming_release_notes/69-gui_top_tree.rst b/docs/source/upcoming_release_notes/69-gui_top_tree.rst new file mode 100644 index 0000000..c802742 --- /dev/null +++ b/docs/source/upcoming_release_notes/69-gui_top_tree.rst @@ -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 diff --git a/superscore/backends/core.py b/superscore/backends/core.py index d799582..a151312 100644 --- a/superscore/backends/core.py +++ b/superscore/backends/core.py @@ -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: @@ -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 diff --git a/superscore/tests/conftest.py b/superscore/tests/conftest.py index d30ff04..8bb730f 100644 --- a/superscore/tests/conftest.py +++ b/superscore/tests/conftest.py @@ -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: @@ -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 diff --git a/superscore/tests/test_page.py b/superscore/tests/test_page.py index 23b63a1..03139f2 100644 --- a/superscore/tests/test_page.py +++ b/superscore/tests/test_page.py @@ -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 @@ -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 diff --git a/superscore/tests/test_window.py b/superscore/tests/test_window.py index 1654166..2d6d75f 100644 --- a/superscore/tests/test_window.py +++ b/superscore/tests/test_window.py @@ -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) diff --git a/superscore/widgets/__init__.py b/superscore/widgets/__init__.py index e69de29..46640fd 100644 --- a/superscore/widgets/__init__.py +++ b/superscore/widgets/__init__.py @@ -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() diff --git a/superscore/widgets/page/__init__.py b/superscore/widgets/page/__init__.py index ca602a8..8f8a8dd 100644 --- a/superscore/widgets/page/__init__.py +++ b/superscore/widgets/page/__init__.py @@ -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() diff --git a/superscore/widgets/page/search.py b/superscore/widgets/page/search.py index 02283f7..7dbe99e 100644 --- a/superscore/widgets/page/search.py +++ b/superscore/widgets/page/search.py @@ -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__) diff --git a/superscore/widgets/tree.py b/superscore/widgets/tree.py index f66e790..f547547 100644 --- a/superscore/widgets/tree.py +++ b/superscore/widgets/tree.py @@ -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__) @@ -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. @@ -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: """ @@ -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. @@ -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() @@ -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 diff --git a/superscore/widgets/window.py b/superscore/widgets/window.py index c576c9f..9a08243 100644 --- a/superscore/widgets/window.py +++ b/superscore/widgets/window.py @@ -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__) @@ -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. @@ -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))