123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675 |
- ########################################################################
- # Wiizard - A Wii games manager
- # Copyright (C) 2023 CYBERDEViL
- #
- # This file is part of Wiizard.
- #
- # Wiizard is free software: you can redistribute it and/or modify
- # it under the terms of the GNU General Public License as published by
- # the Free Software Foundation, either version 3 of the License, or
- # (at your option) any later version.
- #
- # Wiizard is distributed in the hope that it will be useful,
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- # GNU General Public License for more details.
- #
- # You should have received a copy of the GNU General Public License
- # along with this program. If not, see <https://www.gnu.org/licenses/>.
- #
- ########################################################################
- import pickle
- from PyQt5.QtWidgets import (
- QApplication,
- QWidget,
- QListView,
- QSizePolicy,
- QLabel,
- QPushButton,
- QSpacerItem,
- QHBoxLayout,
- QVBoxLayout,
- QAbstractItemView,
- QStyle,
- QFrame,
- QComboBox,
- QMenu,
- QToolBar,
- QStatusBar
- )
- from PyQt5.QtCore import (
- Qt,
- pyqtSignal,
- QMimeData,
- QUrl,
- QTimer
- )
- from PyQt5.QtGui import (
- QGuiApplication,
- QDesktopServices,
- QDrag,
- QPainter
- )
- import wiizard.globals as Global
- from wiizard.models.source import (
- SourceSelecterModel,
- SourceListModel,
- SOURCE_TYPE_LOCAL
- #, SOURCE_TYPE_DEVICE, SOURCE_TYPE_FILEPART
- )
- from wiizard.views.repair import RepairDialog
- from wiizard.widgets.diskUsage import DiskUsageWidget
- from wiizard.translations import _
- class GamesViewMessage(QWidget):
- """ When a message (with optional button) needs to be displayed in
- 'GamesView', a instance of this class will be set as 'GamesView' it's
- viewport.
- """
- def __init__(self, message, buttonText=None, parent=None):
- QWidget.__init__(self, parent=parent)
- layout = QVBoxLayout(self)
- spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)
- layout.addItem(spacer)
- label = QLabel(message, self)
- label.setWordWrap(True)
- label.setAlignment(Qt.AlignCenter)
- label.setSizePolicy(QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum))
- layout.addWidget(label)
- if buttonText is not None:
- self.button = QPushButton(buttonText, self)
- self.button.setSizePolicy(QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum))
- layout.addWidget(self.button)
- layout.setAlignment(self.button, Qt.AlignHCenter)
- spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)
- layout.addItem(spacer)
- class InWidgetMessage(QWidget):
- def __init__(self, timeout=3, parent=None):
- QWidget.__init__(self, parent=parent)
- self.__timer = QTimer(self)
- layout = QHBoxLayout(self)
- self.__icon = QLabel(self)
- self.__label = QLabel(self)
- self.__label.setWordWrap(True)
- layout.addWidget(self.__icon)
- layout.addWidget(self.__label)
- layout.setAlignment(self.__icon, Qt.AlignTop)
- self.__infoPixmap = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation).pixmap(16, 16)
- self.__warnPixmap = QApplication.style().standardIcon(QStyle.SP_MessageBoxWarning).pixmap(16, 16)
- self.__errPixmap = QApplication.style().standardIcon(QStyle.SP_MessageBoxCritical).pixmap(16, 16)
- self.setStyleSheet("background-color: rgba(0, 0, 0, 128); "
- "border-style: solid; border-radius: 10px; "
- "border-width: 5px;")
- self.setAttribute(Qt.WA_TranslucentBackground, True)
- self.__timer.setInterval(timeout * 1000)
- self.__timer.setSingleShot(True)
- self.__timer.timeout.connect(self.hide)
- self.raise_()
- self.hide()
- def __show(self, message):
- self.setMaximumWidth(self.parent().width())
- self.__label.setText(message)
- self.adjustSize()
- self.show()
- self.__timer.start()
- def showMessage(self, message):
- self.__icon.setPixmap(self.__infoPixmap)
- self.__show(message)
- def showWarning(self, warning):
- self.__icon.setPixmap(self.__warnPixmap)
- self.__show(warning)
- def showError(self, error):
- self.__icon.setPixmap(self.__errPixmap)
- self.__show(error)
- def mousePressEvent(self, event):
- self.hide()
- # https://doc.qt.io/qt-6/qlistview.html
- class GamesView(QListView):
- """ The view for disc/games from a source.
- """
- viewModeChanged = pyqtSignal() # for updating external buttons
- gamesUpdated = pyqtSignal() # re-emit from current model (if any)
- def __init__(self, parent=None):
- QListView.__init__(self, parent=parent)
- self.messageWidget = InWidgetMessage(parent=self.parent())
- self.setSelectionMode(QAbstractItemView.ExtendedSelection)
- self.setResizeMode(QListView.Adjust)
- self.setViewMode(QListView.IconMode)
- self.setAlternatingRowColors(False)
- self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
- self.setWordWrap(True)
- # Drag and drop stuff
- self.dragStartPos = None
- # Backup view mode
- self.__viewMode = QListView.IconMode
- # Alt view port
- self.__altViewport = None
- self.setViewport(QWidget(self))
- QListView.setViewMode(self, QListView.ListMode)
- QListView.setViewMode(self, self.__viewMode)
- self.reApplyDND()
- def resetViewport(self):
- if self.__altViewport:
- # @NOTE: setViewport will delete the previous viewport widget for us.
- self.setViewport(QWidget(self))
- self.__altViewport = None
- # Re-apply view mode (else drag and drop won't work)
- # TODO FIXME we first have to apply setViewMode(QListView.ListMode) else
- # drag and drop won't work (atleast on empty list)
- QListView.setViewMode(self, QListView.ListMode)
- QListView.setViewMode(self, self.__viewMode)
- self.reApplyDND()
- def rescan(self):
- sourceModel = self.model().sourceModel
- sourceModel.scanStarted.connect(self.__onStartScan)
- sourceModel.scanCompleted.connect(self.__onScanComplete)
- sourceModel.startScan()
- def __onStartScan(self):
- sourceModel = self.model().sourceModel
- # For now only the scan thread of local type is cancellable
- if sourceModel.type == SOURCE_TYPE_LOCAL:
- self.__altViewport = GamesViewMessage(_("Scanning.. please wait"),
- _("Cancel"), parent=self)
- self.__altViewport.button.clicked.connect(self.__onCancelScan)
- else:
- self.__altViewport = GamesViewMessage(_("Scanning.. please wait"),
- parent=self)
- self.setViewport(self.__altViewport)
- self.viewport().update()
- def __onCancelScan(self):
- self.model().sourceModel.cancelScan()
- def __onRepair(self):
- dialog = RepairDialog(self.model().sourceModel, parent=self)
- dialog.exec()
- def __onScanComplete(self):
- self.model().sourceModel.scanCompleted.disconnect(self.__onScanComplete)
- self.model().sourceModel.scanStarted.disconnect(self.__onStartScan)
- if self.model().sourceModel.hasErrors():
- self.__altViewport = GamesViewMessage(
- _("Source has {} errors, please repair them first.")
- .format(self.model().sourceModel.hasErrors()), "Repair", self)
- self.setViewport(self.__altViewport)
- self.__altViewport.button.clicked.connect(self.__onRepair)
- else:
- self.resetViewport()
- def setModel(self, model=None):
- # Disconnect previous connection
- curModel = self.model()
- if curModel is not None:
- curModel.sourceModel.gamesUpdated.disconnect(self.gamesUpdated)
- self.resetViewport()
- QListView.setModel(self, model)
- if model is not None:
- model.sourceModel.gamesUpdated.connect(self.gamesUpdated)
- self.rescan()
- def paintEvent(self, event):
- # Alt view is active, don't paint anything.
- if self.__altViewport:
- return
- model = self.model()
- if model is None:
- painter = QPainter(self.viewport())
- painter.setPen(Qt.white)
- painter.drawText(self.rect(), Qt.AlignCenter, _("No source selected"))
- return
- if not model.rowCount():
- painter = QPainter(self.viewport())
- painter.setPen(Qt.white)
- painter.drawText(self.rect(), Qt.AlignCenter, _("No games found"))
- return
- QListView.paintEvent(self, event)
- def dropEvent(self, dropEvent):
- print("Drop!")
- rows = pickle.loads(dropEvent.mimeData().data("pywbfs/index-list"))
- sourceModel = dropEvent.source().model()
- sourceGames = sourceModel.filteredGames
- currentGames = [game.id6 for game in self.model().games]
- for row in rows:
- game = sourceGames[row]
- # skip games that are already present
- if game.id6 in currentGames:
- continue
- self.model().addFutureGame(game)
- print("- ", game.title)
- self.model().modelReset.emit()
- def dragMoveEvent(self, dragMoveEvent):
- dragMoveEvent.accept()
- def dragEnterEvent(self, dragEnterEvent):
- if self.model() is None:
- self.messageWidget.showWarning(_("No model selected, nothing to drop "
- "on."))
- return
- if self.model().sourceModel.isScanning():
- self.messageWidget.showWarning(_("Will not drop while scanning."))
- return
- # Don't allow to drop on the same model
- if dragEnterEvent.source().model() == self.model():
- return
- # Non supported Mimetype
- if not dragEnterEvent.mimeData().hasFormat("pywbfs/index-list"): # TODO change mimetype
- return
- # Don't allow drop when all games are already present
- rows = pickle.loads(dragEnterEvent.mimeData().data("pywbfs/index-list"))
- sourceModel = dragEnterEvent.source().model()
- sourceGames = sourceModel.filteredGames
- currentGames = [game.id6 for game in self.model().games]
- allGamesPresent = True
- addSize = 100
- for row in rows:
- game = sourceGames[row]
- if game.id6 not in currentGames:
- allGamesPresent = False
- addSize += game.fileSize
- if allGamesPresent:
- self.messageWidget.showWarning(_("Drop denied: all games are already "
- "here."))
- return
- if addSize >= self.model().sourceModel.getFutureFree():
- self.messageWidget.showWarning(_("Drop denied: not enough free space."))
- return
- dragEnterEvent.acceptProposedAction()
- def mousePressEvent(self, mousePressEvent):
- if mousePressEvent.button() == Qt.LeftButton:
- self.dragStartPos = mousePressEvent.pos()
- QListView.mousePressEvent(self, mousePressEvent)
- def mouseMoveEvent(self, mouseMoveEvent):
- if not (mouseMoveEvent.buttons() & Qt.LeftButton):
- return
- if (mouseMoveEvent.pos() - self.dragStartPos).manhattanLength() < QApplication.startDragDistance():
- return
- selectionModel = self.selectionModel()
- if selectionModel is None:
- return
- if not selectionModel.hasSelection():
- return
- # Selected indexes
- indexes = selectionModel.selectedRows()
- # Create drag event
- # https://doc.qt.io/qt-5/dnd.html
- drag = QDrag(self)
- data = QMimeData()
- data.setData("pywbfs/index-list", bytearray(pickle.dumps([index.row() for index in indexes])))
- drag.setMimeData(data)
- # Only 1 selected, use disc image as icon
- if len(indexes) == 1 and Global.SharedGameImages.imgType is not None:
- index = indexes[0]
- model = self.model()
- id6Str = str(model.filteredGames[index.row()].id6)
- icon = Global.SharedGameImages.getGameImage(id6Str)
- drag.setPixmap(icon)
- drop = drag.exec(Qt.CopyAction)
- def reApplyDND(self):
- self.setAcceptDrops(True)
- self.setDragEnabled(True)
- self.setDefaultDropAction(Qt.IgnoreAction)
- self.setDragDropMode(QAbstractItemView.DragDrop)
- def toggleViewMode(self):
- if self.viewMode() == QListView.IconMode:
- self.setViewMode(QListView.ListMode)
- else:
- self.setViewMode(QListView.IconMode)
- self.update()
- def setViewMode(self, mode):
- self.__viewMode = mode
- QListView.setViewMode(self, mode)
- if mode == QListView.ListMode:
- self.setAlternatingRowColors(True)
- self.reApplyDND()
- else:
- self.setAlternatingRowColors(False)
- self.viewModeChanged.emit()
- def contextMenuEvent(self, contextMenuEvent):
- selectionModel = self.selectionModel()
- if selectionModel is None:
- return
- menu = QMenu(self)
- toggleViewModeAction = menu.addAction(_("Toggle view mode"))
- copyTitleAction = -1
- copyPathAction = -1
- copyId6Action = -1
- markDeletionAction = -1
- delDeletionAction = -1
- openInFileBrowser = -1
- if selectionModel.hasSelection():
- copyTitleAction = menu.addAction(_("Copy title(s)"))
- copyPathAction = menu.addAction(_("Copy path(s)"))
- copyId6Action = menu.addAction(_("Copy ID6(s)"))
- markDeletion = False
- clearDeletion = False
- for index in selectionModel.selectedRows():
- game = self.model().filteredGames[index.row()]
- if self.model().sourceModel.isGameMarkedForDeletion(game):
- clearDeletion = True
- else:
- markDeletion = True
- # Only add "Mark for deletion" when there are games selected that are not
- # yet marked for deletion.
- if markDeletion:
- markDeletionAction = menu.addAction(_("Mark for deletion"))
- # Only add "Clear deletion" when there are games selected that are marked
- # for deletion.
- if clearDeletion:
- delDeletionAction = menu.addAction(_("Clear deletion"))
- # "Open in filebrowser" only available when 1 game is selected
- if len(selectionModel.selectedRows()) == 1:
- openInFileBrowser = menu.addAction(_("Open in filebrowser"))
- # Exec the menu
- action = menu.exec_(self.mapToGlobal(contextMenuEvent.pos()))
- # Handle chosen action
- if action == toggleViewModeAction:
- self.toggleViewMode()
- elif action == copyTitleAction:
- indexes = selectionModel.selectedRows()
- titles = ""
- for index in indexes:
- titles += self.model().filteredGames[index.row()].title + "\n"
- clipboard = QGuiApplication.clipboard()
- clipboard.setText(titles)
- elif action == copyPathAction:
- indexes = selectionModel.selectedRows()
- paths = ""
- for index in indexes:
- paths += self.model().filteredGames[index.row()].sourcePath + "\n"
- clipboard = QGuiApplication.clipboard()
- clipboard.setText(paths)
- elif action == copyId6Action:
- indexes = selectionModel.selectedRows()
- id6s = ""
- for index in indexes:
- id6s += str(self.model().filteredGames[index.row()].id6) + "\n"
- clipboard = QGuiApplication.clipboard()
- clipboard.setText(id6s)
- elif action == markDeletionAction:
- # @NOTE: make sure indexes are sorted desc when deleting stuff
- model = self.model()
- indexes = [index.row() for index in selectionModel.selectedRows()]
- indexes.sort(reverse=True)
- for index in indexes:
- model.addGameForDeletion(index)
- elif action == delDeletionAction:
- # @NOTE: make sure indexes are sorted desc when deleting stuff
- model = self.model()
- indexes = [index.row() for index in selectionModel.selectedRows()]
- indexes.sort(reverse=True)
- for index in indexes:
- model.delGameFromDeletion(index)
- elif action == openInFileBrowser:
- index = selectionModel.selectedRows()[0]
- path = self.model().filteredGames[index.row()].sourcePath
- QDesktopServices.openUrl(QUrl("file://" + path, QUrl.TolerantMode))
- class SourceSelecterWidget(QComboBox):
- indexChanged = pyqtSignal(int) # Not emitted on modelReset unless a selected
- # sourceModel got deleted.
- def __init__(self, parent=None):
- QComboBox.__init__(self, parent=parent)
- # Automatic resize width of drop down list
- sizePolicy = self.view().sizePolicy()
- sizePolicy.setHorizontalPolicy(QSizePolicy.MinimumExpanding)
- self.view().setSizePolicy(sizePolicy)
- # Automatic expand width of self
- sizePolicy = self.sizePolicy()
- sizePolicy.setHorizontalPolicy(QSizePolicy.MinimumExpanding)
- self.setSizePolicy(sizePolicy)
- model = SourceSelecterModel() # TODO there just needs to be 1 instance
- self.setModel(model)
- self.setCurrentIndex(0) # Placeholder
- self.__currentSelected = None
- self.__modelHasReset = False
- self.currentIndexChanged.connect(self.__sourceChanged)
- model.modelReset.connect(self.__sourcesUpdated)
- model.preModelReset.connect(self.__preModelReset)
- def __sourceChanged(self, index):
- """ Keep track of current selected model """
- # Model has just reset
- if self.__modelHasReset is True:
- self.__modelHasReset = False
- return
- if index == -1:
- # Set to placeholder
- self.currentIndexChanged.disconnect(self.__sourceChanged)
- self.setCurrentIndex(0)
- self.currentIndexChanged.connect(self.__sourceChanged)
- self.__currentSelected = None
- else:
- # Backup selected item
- self.__currentSelected = self.model().getItem(index)
- self.indexChanged.emit(index)
- def __sourcesUpdated(self):
- """ Set new index of the current selected (if still exists) """
- if self.__currentSelected is not None:
- newIndex = self.model().getIndexForSourceModel(self.__currentSelected)
- self.currentIndexChanged.disconnect(self.__sourceChanged)
- self.setCurrentIndex(newIndex)
- self.__currentSelected = self.model().getItem(newIndex)
- if self.__currentSelected is None: # The model probably got removed, so emit
- self.indexChanged.emit(newIndex)
- self.currentIndexChanged.connect(self.__sourceChanged)
- else:
- self.__modelHasReset = False
- def __preModelReset(self):
- self.__modelHasReset = True
- class SourceWidget(QFrame):
- def __init__(self, parent=None):
- QFrame.__init__(self, parent=parent)
- self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
- layout = QVBoxLayout(self)
- self.toolbar = QToolBar(self)
- self.sourceCombo = SourceSelecterWidget(self)
- self.toolbar.addWidget(self.sourceCombo)
- self.sortOrderCombo = QComboBox(self)
- self.sortOrderCombo.addItem(_("Asc"))
- self.sortOrderCombo.addItem(_("Desc"))
- self.sortOrderCombo.setCurrentIndex(0)
- self.sortOrderCombo.setEnabled(False)
- self.sortOrderCombo.setToolTip(_("Sort order"))
- self.toolbar.addWidget(self.sortOrderCombo)
- self.refreshAction = self.toolbar.addAction(_("Refresh location"))
- self.refreshAction.setToolTip(_("Refresh location"))
- self.refreshAction.setIcon(QApplication.style().standardIcon(QStyle.SP_BrowserReload))
- self.refreshAction.setEnabled(False)
- self.toggleViewAction = self.toolbar.addAction(_("Toggle view mode"))
- self.toggleViewAction.setToolTip("Toggle view mode")
- self.toggleViewAction.setIcon(QApplication.style().standardIcon(QStyle.SP_FileDialogListView))
- self.toggleViewAction.setEnabled(False)
- self.listView = GamesView(self)
- self.listView.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
- self.usageWidget = DiskUsageWidget(parent=self)
- self.statusBar = QStatusBar(self)
- layout.addWidget(self.toolbar, Qt.AlignTop)
- layout.addWidget(self.listView)
- layout.addWidget(self.usageWidget)
- layout.addWidget(self.statusBar)
- self.sourceCombo.indexChanged.connect(self.__sourceChanged)
- self.sortOrderCombo.currentIndexChanged.connect(self.__onSortOrderChange)
- self.refreshAction.triggered.connect(self.listView.rescan)
- self.toggleViewAction.triggered.connect(self.__onToggleView)
- self.listView.viewModeChanged.connect(self.__onListViewModeChange)
- def __sourceChanged(self, index):
- if self.listView.model() is not None:
- self.listView.model().sourceModel.scanStarted.disconnect(self.__onScanStarted)
- self.listView.model().sourceModel.scanCompleted.disconnect(self.__onScanCompleted)
- self.listView.model().sourceModel.gamesUpdated.disconnect(self.__onGamesUpdated)
- self.listView.model().sourceModel.setInUse(False)
- source = self.sourceCombo.model().getItem(index)
- if source is not None:
- source.setInUse(True)
- model = SourceListModel(source)
- model.setSortOrder(self.sortOrderCombo.currentIndex())
- self.usageWidget.updateSizes(source.getUsed(), source.getCapacity(),
- 0, source.getFutureFree())
- self.refreshAction.setEnabled(True)
- self.sortOrderCombo.setEnabled(True)
- self.toggleViewAction.setEnabled(True)
- source.scanStarted.connect(self.__onScanStarted)
- source.scanCompleted.connect(self.__onScanCompleted)
- source.gamesUpdated.connect(self.__onGamesUpdated)
- self.listView.setModel(model)
- else:
- self.refreshAction.setEnabled(False)
- self.sortOrderCombo.setEnabled(False)
- self.toggleViewAction.setEnabled(False)
- self.listView.setModel()
- self.usageWidget.updateSizes(-1, -1, -1, -1)
- def __onSortOrderChange(self, index):
- self.listView.model().setSortOrder(index)
- def __onScanStarted(self):
- #self.toolbar.setEnabled(False) # This didn't do the trick
- self.sourceCombo.setEnabled(False)
- self.refreshAction.setEnabled(False)
- self.sortOrderCombo.setEnabled(False)
- self.toggleViewAction.setEnabled(False)
- def __onScanCompleted(self):
- #self.toolbar.setEnabled(True) # This didn't do the trick
- self.sourceCombo.setEnabled(True)
- self.refreshAction.setEnabled(True)
- self.sortOrderCombo.setEnabled(True)
- self.toggleViewAction.setEnabled(True)
- source = self.listView.model().sourceModel
- discCount = len(source.games)
- totalSize = 0
- for game in source.games:
- totalSize += game.fileSize
- self.statusBar.showMessage(_(f"{discCount} Discs ({totalSize} MiB)"))
- def __onGamesUpdated(self):
- source = self.listView.model().sourceModel
- self.usageWidget.updateSizes(source.getFutureUsed(),
- source.getCapacity(),
- source.getFutureUsed(),
- source.getFutureFree()) # TODO
- def __onListViewModeChange(self):
- if self.listView.viewMode() == QListView.IconMode:
- self.toggleViewAction.setIcon(
- QApplication.style().standardIcon(QStyle.SP_FileDialogListView)
- )
- else:
- self.toggleViewAction.setIcon(
- QApplication.style().standardIcon(QStyle.SP_FileDialogDetailedView)
- )
- def __onToggleView(self):
- self.listView.toggleViewMode()
|