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