diff --git a/requirements.txt b/requirements.txt index fcb4723..4ee7140 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ arcaea-offline==0.1.0 arcaea-offline-ocr==0.1.0 exif==1.6.0 PySide6==6.5.2 +typing-extensions==4.8.0 diff --git a/ui/components/mainwindow/__init__.py b/ui/components/mainwindow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/components/mainwindow/navsidebar.py b/ui/components/mainwindow/navsidebar.py new file mode 100644 index 0000000..7e245c8 --- /dev/null +++ b/ui/components/mainwindow/navsidebar.py @@ -0,0 +1,177 @@ +from PySide6.QtCore import QModelIndex, Qt, Slot +from PySide6.QtGui import QFont, QIcon, QKeySequence, QShortcut +from PySide6.QtWidgets import ( + QListWidget, + QListWidgetItem, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ui.navigation.navhost import NavItem, navHost +from ui.widgets.slidingstackedwidget import SlidingStackedWidget + + +class NavItemListWidget(QListWidget): + NavItemRole = Qt.ItemDataRole.UserRole + + def __init__(self, parent=None): + super().__init__(parent) + + font = QFont(self.font()) + font.setPointSize(14) + self.setFont(font) + + self.clicked.connect(self.activated) + + def setNavItems(self, items: list[NavItem]): + self.clear() + + for navItem in items: + if navItem.icon: + listWidgetItem = QListWidgetItem(QIcon(navItem.icon), navItem.text()) + else: + listWidgetItem = QListWidgetItem(navItem.text()) + listWidgetItem.setData(self.NavItemRole, navItem) + listWidgetItem.setTextAlignment( + Qt.AlignmentFlag.AlignLeading | Qt.AlignmentFlag.AlignVCenter + ) + + self.addItem(listWidgetItem) + + def selectNavItem(self, navItemId: str): + navItemIds = [ + self.item(r).data(self.NavItemRole).id for r in range(self.count()) + ] + index = navItemIds.index(navItemId) + self.setCurrentIndex(self.model().index(index, 0)) + + +class NavigationWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + self.navHost = navHost + + self.verticalLayout = QVBoxLayout(self) + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + + self.backButton = QPushButton(QIcon(":/icons/back.svg"), "") + self.backButton.setFlat(True) + self.backButton.setFixedHeight(20) + self.verticalLayout.addWidget(self.backButton) + + self.navListWidget = NavItemListWidget(self) + self.verticalLayout.addWidget(self.navListWidget) + + def setNavigationItems(self, items: list[NavItem]): + self.navListWidget.setNavItems(items) + + +class NavigationSideBar(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + self.navHost = navHost + + navHost.navItemsChanged.connect(self.reloadNavWidget) + navHost.activated.connect(self.navItemActivated) + + self.navigateUpKeyboardShortcut = QShortcut( + QKeySequence(Qt.Modifier.ALT | Qt.Key.Key_Left), + self, + self.navHost.navigateUp, + ) + + self.verticalLayout = QVBoxLayout(self) + + self.navigateUpButton = QPushButton(QIcon(":/icons/back.svg"), "") + self.navigateUpButton.setFlat(True) + self.navigateUpButton.setFixedHeight(20) + self.navigateUpButton.clicked.connect(self.navHost.navigateUp) + self.verticalLayout.addWidget(self.navigateUpButton) + + self.slidingStackedWidget = SlidingStackedWidget(self) + self.slidingStackedWidget.animationFinished.connect( + self.endChangingNavItemListWidget + ) + self.verticalLayout.addWidget(self.slidingStackedWidget) + + navItemListWidget = NavItemListWidget(self) + navItemListWidget.activated.connect(self.navItemListWidgetActivatedProxy) + self.slidingStackedWidget.addWidget(navItemListWidget) + + self.reloadNavWidget() + + @Slot(QModelIndex) + def navItemListWidgetActivatedProxy(self, index: QModelIndex): + self.navHost.navigate(index.data(NavItemListWidget.NavItemRole).id) + + def fillNavItemListWidget( + self, currentNavItem: NavItem, listWidget: NavItemListWidget + ): + currentNavItemParent = self.navHost.getNavItemRelatives( + currentNavItem.id + ).parent + currentNavItems = self.navHost.getNavItemRelatives( + currentNavItemParent.id if currentNavItemParent else "" + ) + listWidget.setNavItems(currentNavItems.children) + listWidget.selectNavItem(currentNavItem.id) + + def reloadNavWidget(self): + self.fillNavItemListWidget( + self.navHost.currentNavItem, self.slidingStackedWidget.widget(0) + ) + self.navItemActivated(self.navHost.currentNavItem, self.navHost.currentNavItem) + + @Slot(NavItem, NavItem) + def navItemActivated(self, oldNavItem: NavItem, newNavItem: NavItem): + # update navigateUpButton text + if newNavItemParent := self.navHost.getNavItemRelatives(newNavItem.id).parent: + self.navigateUpButton.setText(newNavItemParent.text()) + else: + self.navigateUpButton.setText("Arcaea Offline") + + # update navItemListWidget + oldNavItemIdSplitted = self.navHost.getNavItemIdSplitted(oldNavItem.id) + newNavItemIdSplitted = self.navHost.getNavItemIdSplitted(newNavItem.id) + + oldNavItemDepth = len(oldNavItemIdSplitted) + newNavItemDepth = len(newNavItemIdSplitted) + + if oldNavItemDepth != newNavItemDepth: + # navItem depth changed, replace current NavItemListWidget + newNavItemListWidget = NavItemListWidget(self) + slidingDirection = ( + self.slidingStackedWidget.slidingDirection.RightToLeft + if newNavItemDepth > oldNavItemDepth + else self.slidingStackedWidget.slidingDirection.LeftToRight + ) + + self.fillNavItemListWidget(newNavItem, newNavItemListWidget) + newNavItemListWidget.activated.connect(self.navItemListWidgetActivatedProxy) + + self.startChangingNavItemListWidget(newNavItemListWidget, slidingDirection) + + def startChangingNavItemListWidget( + self, newNavItemListWidget: NavItemListWidget, slidingDirection + ): + newIndex = self.slidingStackedWidget.addWidget(newNavItemListWidget) + [ + self.slidingStackedWidget.widget(i).setEnabled(False) + for i in range(self.slidingStackedWidget.count()) + ] + self.navigateUpButton.setEnabled(False) + self.navigateUpKeyboardShortcut.setEnabled(False) + self.slidingStackedWidget.slideInIdx(newIndex, slidingDirection) + + def endChangingNavItemListWidget(self): + oldWidget = self.slidingStackedWidget.widget(0) + self.slidingStackedWidget.removeWidget(oldWidget) + oldWidget.deleteLater() + + newWidget = self.slidingStackedWidget.widget(0) + newWidget.setEnabled(True) + self.navigateUpButton.setEnabled(True) + self.navigateUpKeyboardShortcut.setEnabled(True) diff --git a/ui/navigation/__init__.py b/ui/navigation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/navigation/navhost.py b/ui/navigation/navhost.py new file mode 100644 index 0000000..079179f --- /dev/null +++ b/ui/navigation/navhost.py @@ -0,0 +1,138 @@ +import logging +from dataclasses import dataclass +from typing import Optional + +from PySide6.QtCore import QObject, Signal + +from .navitem import NavItem + +logger = logging.getLogger(__name__) + + +@dataclass +class NavItemRelatives: + parent: Optional[NavItem] + children: list[NavItem] + + +class NavHost(QObject): + activated = Signal(NavItem, NavItem) + + navItemsChanged = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + self.__navItems: list[NavItem] = [] + self.__currentNavItem: NavItem = None + + @property + def navItems(self) -> list[NavItem]: + return self.__navItems + + @property + def currentNavItem(self) -> NavItem: + if self.__currentNavItem: + return self.__currentNavItem + + if self.__navItems: + self.__currentNavItem = self.__navItems[0] + return self.__currentNavItem + + def findNavItem(self, navItemId: str) -> Optional[NavItem]: + navItemIds = [item.id for item in self.__navItems] + try: + index = navItemIds.index(navItemId) + return self.__navItems[index] + except IndexError: + return None + + def isNavigatingBack(self, oldNavItemId: str, newNavItemId: str) -> bool: + # sourcery skip: class-extract-method + # | oldNavItemId | newNavItemId | back? | + # |-----------------------|--------------------|-------| + # | database.manage.packs | database | True | + # | ocr.device | ocr | True | + # | database.manage.songs | ocr.b30 | False | + # | database | database | False | + + oldNavItemIdSplitted = self.getNavItemIdSplitted(oldNavItemId) + newNavItemIdSplitted = self.getNavItemIdSplitted(newNavItemId) + + return self.getNavItemDepth(newNavItemId) < self.getNavItemDepth( + oldNavItemId + ) and all((idFrag in oldNavItemIdSplitted) for idFrag in newNavItemIdSplitted) + + def isChild(self, childNavItemId: str, parentNavItemId: str) -> bool: + childNavItemIdSplitted = self.getNavItemIdSplitted(childNavItemId) + parentNavItemIdSplitted = self.getNavItemIdSplitted(parentNavItemId) + + return all( + (idFrag in childNavItemIdSplitted) for idFrag in parentNavItemIdSplitted + ) + + def getNavItemIdSplitted(self, navItemId: str) -> list[str]: + return navItemId.split(".") + + def getNavItemDepth(self, navItemId: str) -> int: + return len(self.getNavItemIdSplitted(navItemId)) + + def getNavItemRelatives(self, navItemId: str) -> NavItemRelatives: + parent = None + if "." in navItemId: + navItemIdSplitted = navItemId.split(".") + parentId = navItemIdSplitted[:-1] + parent = self.findNavItem(".".join(parentId)) + + if not navItemId: + # return root navItems + children = [navItem for navItem in self.__navItems if "." not in navItem.id] + else: + children = [ + navItem + for navItem in self.__navItems + if navItem.id.startswith(navItemId) + and navItem.id != navItemId + and self.getNavItemDepth(navItem.id) + == self.getNavItemDepth(navItemId) + 1 + ] + + return NavItemRelatives(parent=parent, children=children) + + def registerNavItem(self, item: NavItem): + self.__navItems.append(item) + self.navItemsChanged.emit() + + def navigate(self, navItemId: str): + oldNavItem = self.__currentNavItem or NavItem("") + newNavItem = self.findNavItem(navItemId) + + if newNavItem is None: + raise IndexError( + f"Cannot find '{navItemId}' in {repr(self)}. " + "Maybe try registering it?" + ) + + # if the navItem have children, navigate to first child + # but if the navItem is going back, e.g. 'database.manage' -> 'database' + # then don't navigate to it's child. + if self.isNavigatingBack(oldNavItem.id, newNavItem.id): + # navItem is going back + currentNavItem = newNavItem + else: + newNavItemRelatives = self.getNavItemRelatives(newNavItem.id) + if newNavItemRelatives.children: + currentNavItem = newNavItemRelatives.children[0] + else: + currentNavItem = newNavItem + + self.__currentNavItem = currentNavItem + self.activated.emit(oldNavItem, self.currentNavItem) + + def navigateUp(self): + navItemRelatives = self.getNavItemRelatives(self.currentNavItem.id) + if navItemRelatives.parent: + self.navigate(navItemRelatives.parent.id) + + +navHost = NavHost() diff --git a/ui/navigation/navitem.py b/ui/navigation/navitem.py new file mode 100644 index 0000000..90817d5 --- /dev/null +++ b/ui/navigation/navitem.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from typing import Optional + +from PySide6.QtCore import QCoreApplication +from PySide6.QtGui import QIcon, QPixmap + + +@dataclass +class NavItem: + id: str + icon: Optional[QIcon | QPixmap | str] = None + + def text(self): + return QCoreApplication.translate("NavItem", f"{self.id}.title") diff --git a/ui/resources/icons/back.svg b/ui/resources/icons/back.svg new file mode 100644 index 0000000..c941a80 --- /dev/null +++ b/ui/resources/icons/back.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + diff --git a/ui/resources/resources.qrc b/ui/resources/resources.qrc index 9bc8887..558e495 100644 --- a/ui/resources/resources.qrc +++ b/ui/resources/resources.qrc @@ -8,6 +8,8 @@ fonts/GeosansLight.ttf + icons/back.svg + images/icon.png images/logo.png images/jacket-placeholder.png diff --git a/ui/widgets/__init__.py b/ui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/widgets/slidingstackedwidget.py b/ui/widgets/slidingstackedwidget.py index 13a6208..6668950 100644 --- a/ui/widgets/slidingstackedwidget.py +++ b/ui/widgets/slidingstackedwidget.py @@ -48,6 +48,8 @@ class SlidingDirection(IntEnum): class SlidingStackedWidget(QStackedWidget): + slidingDirection = SlidingDirection + animationFinished = Signal() def __init__(self, parent=None): @@ -58,10 +60,12 @@ class SlidingStackedWidget(QStackedWidget): self.animationEasingCurve = QEasingCurve.Type.OutQuart self.animationCurrentIndex = 0 self.animationNextIndex = 0 - self.wrap = False self.animationCurrentPoint = QPoint(0, 0) self.animationRunning = False + self.wrap = False + self.opacityAnimation = False + def setVertical(self, vertical: bool): self.vertical = vertical @@ -74,6 +78,9 @@ class SlidingStackedWidget(QStackedWidget): def setWrap(self, wrap: bool): self.wrap = wrap + def setOpacityAnimation(self, value: bool): + self.opacityAnimation = value + def slideInNext(self) -> bool: currentIndex = self.currentIndex() if self.wrap or (currentIndex < self.count() - 1): @@ -166,16 +173,6 @@ class SlidingStackedWidget(QStackedWidget): QPoint(offsetX + currentPoint.x(), offsetY + currentPoint.y()) ) - currentWidgetOpacityEffect = QGraphicsOpacityEffect() - currentWidgetOpacityEffectAnimation = self.widgetOpacityAnimation( - currentIndex, currentWidgetOpacityEffect, 1, 0 - ) - - nextWidgetOpacityEffect = QGraphicsOpacityEffect() - nextWidgetOpacityEffect.setOpacity(0) - nextWidgetOpacityEffectAnimation = self.widgetOpacityAnimation( - nextIndex, nextWidgetOpacityEffect, 0, 1 - ) nextWidgetAnimation = self.widgetPosAnimation(nextIndex) nextWidgetAnimation.setStartValue( QPoint(-offsetX + nextPoint.x(), offsetY + nextPoint.y()) @@ -185,8 +182,21 @@ class SlidingStackedWidget(QStackedWidget): animationGroup = QParallelAnimationGroup(self) animationGroup.addAnimation(currentWidgetAnimation) animationGroup.addAnimation(nextWidgetAnimation) - animationGroup.addAnimation(currentWidgetOpacityEffectAnimation) - animationGroup.addAnimation(nextWidgetOpacityEffectAnimation) + + if self.opacityAnimation: + currentWidgetOpacityEffect = QGraphicsOpacityEffect() + currentWidgetOpacityEffectAnimation = self.widgetOpacityAnimation( + currentIndex, currentWidgetOpacityEffect, 1, 0 + ) + + nextWidgetOpacityEffect = QGraphicsOpacityEffect() + nextWidgetOpacityEffect.setOpacity(0) + nextWidgetOpacityEffectAnimation = self.widgetOpacityAnimation( + nextIndex, nextWidgetOpacityEffect, 0, 1 + ) + + animationGroup.addAnimation(currentWidgetOpacityEffectAnimation) + animationGroup.addAnimation(nextWidgetOpacityEffectAnimation) animationGroup.finished.connect(self.animationDoneSlot) self.animationNextIndex = nextIndex