diff --git a/src/platform/qt/CMakeLists.txt b/src/platform/qt/CMakeLists.txt index 44a208b81d8..4b366f7556b 100644 --- a/src/platform/qt/CMakeLists.txt +++ b/src/platform/qt/CMakeLists.txt @@ -199,6 +199,11 @@ set(GB_SRC GBOverride.cpp PrinterView.cpp) +set(TEST_QT_spanset_SRC + test/spanset.cpp + utils.cpp + VFileDevice.cpp) + set(CPACK_DEBIAN_PACKAGE_DEPENDS "${CPACK_DEBIAN_PACKAGE_DEPENDS},libqt5widgets5") set(AUDIO_SRC) @@ -251,8 +256,15 @@ if(USE_SQLITE3) list(APPEND SOURCE_FILES ArchiveInspector.cpp library/LibraryController.cpp - library/LibraryGrid.cpp - library/LibraryTree.cpp) + library/LibraryEntry.cpp + library/LibraryModel.cpp) + + set(TEST_QT_library_SRC + library/LibraryEntry.cpp + library/LibraryModel.cpp + test/library.cpp + utils.cpp + VFileDevice.cpp) endif() if(USE_DISCORD_RPC) @@ -498,3 +510,21 @@ if(DISTBUILD AND NOT APPLE) add_custom_command(TARGET ${BINARY_NAME}-qt POST_BUILD COMMAND "${STRIP}" "$") endif() endif() + +if(BUILD_SUITE) + enable_testing() + find_package(${QT}Test) + if(${QT}Test_FOUND) + get_property(ALL_TESTS DIRECTORY PROPERTY VARIABLES) + list(FILTER ALL_TESTS INCLUDE REGEX "^TEST_QT_.*_SRC$") + foreach(TEST_SRC ${ALL_TESTS}) + string(REGEX REPLACE "^TEST_QT_(.*)_SRC$" "\\1" TEST_NAME ${TEST_SRC}) + add_executable(test-qt-${TEST_NAME} WIN32 ${${TEST_SRC}}) + target_link_libraries(test-qt-${TEST_NAME} ${PLATFORM_LIBRARY} ${BINARY_NAME} ${QT_LIBRARIES} ${QT}::Test) + set_target_properties(test-qt-${TEST_NAME} PROPERTIES COMPILE_DEFINITIONS "${FEATURE_DEFINES};${FUNCTION_DEFINES};${OS_DEFINES};${QT_DEFINES}" COMPILE_OPTIONS "${FEATURE_FLAGS}") + add_test(platform-qt-${TEST_NAME} test-qt-${TEST_NAME}) + endforeach() + else() + message("${QT}Test not found") + endif() +endif() diff --git a/src/platform/qt/library/LibraryController.cpp b/src/platform/qt/library/LibraryController.cpp index 2a1ba154654..ed78ce410d2 100644 --- a/src/platform/qt/library/LibraryController.cpp +++ b/src/platform/qt/library/LibraryController.cpp @@ -8,38 +8,15 @@ #include "ConfigController.h" #include "GBAApp.h" -#include "LibraryGrid.h" -#include "LibraryTree.h" +#include "LibraryModel.h" -using namespace QGBA; - -LibraryEntry::LibraryEntry(const mLibraryEntry* entry) - : base(entry->base) - , filename(entry->filename) - , fullpath(QString("%1/%2").arg(entry->base, entry->filename)) - , title(entry->title) - , internalTitle(entry->internalTitle) - , internalCode(entry->internalCode) - , platform(entry->platform) - , filesize(entry->filesize) - , crc32(entry->crc32) -{ -} - -void AbstractGameList::addEntry(const LibraryEntry& item) { - addEntries({item}); -} +#include +#include +#include +#include +#include -void AbstractGameList::updateEntry(const LibraryEntry& item) { - updateEntries({item}); -} - -void AbstractGameList::removeEntry(const QString& item) { - removeEntries({item}); -} -void AbstractGameList::setShowFilename(bool showFilename) { - m_showFilename = showFilename; -} +using namespace QGBA; LibraryController::LibraryController(QWidget* parent, const QString& path, ConfigController* config) : QStackedWidget(parent) @@ -55,14 +32,53 @@ LibraryController::LibraryController(QWidget* parent, const QString& path, Confi mLibraryAttachGameDB(m_library.get(), GBAApp::app()->gameDB()); - m_libraryTree = std::make_unique(this); - addWidget(m_libraryTree->widget()); - - m_libraryGrid = std::make_unique(this); - addWidget(m_libraryGrid->widget()); + m_libraryModel = new LibraryModel(this); + + m_treeView = new QTreeView(this); + addWidget(m_treeView); + m_treeModel = new QSortFilterProxyModel(this); + m_treeModel->setSourceModel(m_libraryModel); + m_treeModel->setSortRole(Qt::EditRole); + m_treeView->setModel(m_treeModel); + m_treeView->setSortingEnabled(true); + m_treeView->setAlternatingRowColors(true); + + m_listView = new QListView(this); + addWidget(m_listView); + m_listModel = new QSortFilterProxyModel(this); + m_listModel->setSourceModel(m_libraryModel); + m_listModel->setSortRole(Qt::EditRole); + m_listView->setModel(m_listModel); + + QObject::connect(m_treeView, &QAbstractItemView::activated, this, &LibraryController::startGame); + QObject::connect(m_listView, &QAbstractItemView::activated, this, &LibraryController::startGame); + QObject::connect(m_treeView->header(), &QHeaderView::sortIndicatorChanged, this, &LibraryController::sortChanged); + + m_expandThrottle.setInterval(100); + m_expandThrottle.setSingleShot(true); + QObject::connect(&m_expandThrottle, &QTimer::timeout, this, qOverload<>(&LibraryController::resizeTreeView)); + QObject::connect(m_libraryModel, &QAbstractItemModel::modelReset, &m_expandThrottle, qOverload<>(&QTimer::start)); + QObject::connect(m_libraryModel, &QAbstractItemModel::rowsInserted, &m_expandThrottle, qOverload<>(&QTimer::start)); + + LibraryStyle libraryStyle = LibraryStyle(m_config->getOption("libraryStyle", int(LibraryStyle::STYLE_LIST)).toInt()); + // Make sure setViewStyle does something + if (libraryStyle == LibraryStyle::STYLE_TREE) { + m_currentStyle = LibraryStyle::STYLE_LIST; + } else { + m_currentStyle = LibraryStyle::STYLE_TREE; + } + setViewStyle(libraryStyle); - m_currentStyle = LibraryStyle::STYLE_TREE; // Make sure setViewStyle does something - setViewStyle(LibraryStyle::STYLE_LIST); + QVariant librarySort = m_config->getQtOption("librarySort"); + QVariant librarySortOrder = m_config->getQtOption("librarySortOrder"); + if (librarySort.isNull() || !librarySort.canConvert()) { + librarySort = 0; + } + if (librarySortOrder.isNull() || !librarySortOrder.canConvert()) { + librarySortOrder = Qt::AscendingOrder; + } + m_treeModel->sort(librarySort.toInt(), librarySortOrder.value()); + m_listModel->sort(0, Qt::AscendingOrder); refresh(); } @@ -73,32 +89,59 @@ void LibraryController::setViewStyle(LibraryStyle newStyle) { if (m_currentStyle == newStyle) { return; } + QString selected; + if (m_currentView) { + QModelIndex selectedIndex = m_currentView->selectionModel()->currentIndex(); + if (selectedIndex.isValid()) { + selected = selectedIndex.data(LibraryModel::FullPathRole).toString(); + } + } + m_currentStyle = newStyle; + m_libraryModel->setTreeMode(newStyle == LibraryStyle::STYLE_TREE); - AbstractGameList* newCurrentList = nullptr; + QAbstractItemView* newView = m_listView; if (newStyle == LibraryStyle::STYLE_LIST || newStyle == LibraryStyle::STYLE_TREE) { - newCurrentList = m_libraryTree.get(); - } else { - newCurrentList = m_libraryGrid.get(); + newView = m_treeView; } - newCurrentList->selectEntry(selectedEntry().fullpath); - newCurrentList->setViewStyle(newStyle); - setCurrentWidget(newCurrentList->widget()); - m_currentList = newCurrentList; + + setCurrentWidget(newView); + m_currentView = newView; + selectEntry(selected); +} + +void LibraryController::sortChanged(int column, Qt::SortOrder order) { + m_config->setQtOption("librarySort", column); + m_config->setQtOption("librarySortOrder", order); } void LibraryController::selectEntry(const QString& fullpath) { - if (!m_currentList) { + if (!m_currentView) { return; } - m_currentList->selectEntry(fullpath); + QModelIndex index = m_libraryModel->index(fullpath); + + // If the model is proxied in the current view, map the index to the proxy + QAbstractProxyModel* proxy = qobject_cast(m_currentView->model()); + if (proxy) { + index = proxy->mapFromSource(index); + } + + if (index.isValid()) { + m_currentView->selectionModel()->setCurrentIndex(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Current); + } } LibraryEntry LibraryController::selectedEntry() { - if (!m_currentList) { + if (!m_currentView) { + return {}; + } + QModelIndex index = m_currentView->selectionModel()->currentIndex(); + if (!index.isValid()) { return {}; } - return m_entries.value(m_currentList->selectedEntry()); + QString fullpath = index.data(LibraryModel::FullPathRole).toString(); + return m_libraryModel->entry(fullpath); } VFile* LibraryController::selectedVFile() { @@ -149,42 +192,34 @@ void LibraryController::refresh() { setDisabled(true); - QHash removedEntries = m_entries; - QHash updatedEntries; + QSet removedEntries = QSet::fromList(m_knownGames.keys()); + QList updatedEntries; QList newEntries; mLibraryListing listing; mLibraryListingInit(&listing, 0); mLibraryGetEntries(m_library.get(), &listing, 0, 0, nullptr); for (size_t i = 0; i < mLibraryListingSize(&listing); i++) { - LibraryEntry entry = mLibraryListingGetConstPointer(&listing, i); - if (!m_entries.contains(entry.fullpath)) { + const mLibraryEntry* entry = mLibraryListingGetConstPointer(&listing, i); + uint64_t checkHash = LibraryEntry::checkHash(entry); + QString fullpath = QStringLiteral("%1/%2").arg(entry->base, entry->filename); + if (!m_knownGames.contains(fullpath)) { newEntries.append(entry); - } else { - updatedEntries[entry.fullpath] = entry; + } else if (checkHash != m_knownGames[fullpath]) { + updatedEntries.append(entry); } - m_entries[entry.fullpath] = entry; - removedEntries.remove(entry.fullpath); + removedEntries.remove(fullpath); + m_knownGames[fullpath] = checkHash; } // Check for entries that were removed - for (QString& path : removedEntries.keys()) { - m_entries.remove(path); + for (const QString& path : removedEntries) { + m_knownGames.remove(path); } - if (!removedEntries.size() && !newEntries.size()) { - m_libraryTree->updateEntries(updatedEntries.values()); - m_libraryGrid->updateEntries(updatedEntries.values()); - } else if (!updatedEntries.size()) { - m_libraryTree->removeEntries(removedEntries.keys()); - m_libraryGrid->removeEntries(removedEntries.keys()); - - m_libraryTree->addEntries(newEntries); - m_libraryGrid->addEntries(newEntries); - } else { - m_libraryTree->resetEntries(m_entries.values()); - m_libraryGrid->resetEntries(m_entries.values()); - } + m_libraryModel->removeEntries(removedEntries.toList()); + m_libraryModel->updateEntries(updatedEntries); + m_libraryModel->addEntries(newEntries); for (size_t i = 0; i < mLibraryListingSize(&listing); ++i) { mLibraryEntryFree(mLibraryListingGetPointer(&listing, i)); @@ -201,7 +236,7 @@ void LibraryController::selectLastBootedGame() { return; } const QString lastfile = m_config->getMRU().first(); - if (m_entries.contains(lastfile)) { + if (m_knownGames.contains(lastfile)) { selectEntry(lastfile); } } @@ -213,16 +248,50 @@ void LibraryController::loadDirectory(const QString& dir, bool recursive) { mLibraryLoadDirectory(library.get(), dir.toUtf8().constData(), recursive); m_libraryJob.testAndSetOrdered(libraryJob, -1); } + void LibraryController::setShowFilename(bool showFilename) { if (showFilename == m_showFilename) { return; } m_showFilename = showFilename; - if (m_libraryGrid) { - m_libraryGrid->setShowFilename(m_showFilename); + m_libraryModel->setShowFilename(m_showFilename); + refresh(); +} + +void LibraryController::showEvent(QShowEvent*) { + resizeTreeView(false); +} + +void LibraryController::resizeEvent(QResizeEvent*) { + resizeTreeView(false); +} + +void LibraryController::resizeTreeView(bool expand) { + if (expand) { + m_treeView->expandAll(); } - if (m_libraryTree) { - m_libraryTree->setShowFilename(m_showFilename); + + int viewportWidth = m_treeView->viewport()->width(); + int totalWidth = m_treeView->header()->sectionSizeHint(LibraryModel::MAX_COLUMN); + for (int column = 0; column < LibraryModel::MAX_COLUMN; column++) { + totalWidth += m_treeView->columnWidth(column); + } + if (totalWidth < viewportWidth) { + totalWidth = 0; + for (int column = 0; column <= LibraryModel::MAX_COLUMN; column++) { + m_treeView->resizeColumnToContents(column); + totalWidth += m_treeView->columnWidth(column); + } + } + if (totalWidth > viewportWidth) { + int locationWidth = m_treeView->columnWidth(LibraryModel::COL_LOCATION); + if (locationWidth > 100) { + int newLocationWidth = m_treeView->viewport()->width() - (totalWidth - locationWidth); + if (newLocationWidth < 100) { + newLocationWidth = 100; + } + m_treeView->setColumnWidth(LibraryModel::COL_LOCATION, newLocationWidth); + totalWidth = totalWidth - locationWidth + newLocationWidth; + } } - refresh(); } diff --git a/src/platform/qt/library/LibraryController.h b/src/platform/qt/library/LibraryController.h index 1b5d0657211..8a14f3a4b01 100644 --- a/src/platform/qt/library/LibraryController.h +++ b/src/platform/qt/library/LibraryController.h @@ -12,15 +12,21 @@ #include #include #include +#include #include +#include "LibraryEntry.h" + +class QAbstractItemView; +class QListView; +class QSortFilterProxyModel; +class QTreeView; + namespace QGBA { -// Predefinitions -class LibraryGrid; -class LibraryTree; class ConfigController; +class LibraryModel; enum class LibraryStyle { STYLE_LIST = 0, @@ -29,50 +35,6 @@ enum class LibraryStyle { STYLE_ICON }; -struct LibraryEntry { - LibraryEntry() {} - LibraryEntry(const mLibraryEntry* entry); - - bool isNull() const { return fullpath.isNull(); } - - QString displayTitle() const { return title.isNull() ? filename : title; } - - QString base; - QString filename; - QString fullpath; - QString title; - QByteArray internalTitle; - QByteArray internalCode; - mPlatform platform; - size_t filesize; - uint32_t crc32; - - bool operator==(const LibraryEntry& other) const { return other.fullpath == fullpath; } -}; - -class AbstractGameList { -public: - virtual QString selectedEntry() = 0; - virtual void selectEntry(const QString& fullpath) = 0; - - virtual void setViewStyle(LibraryStyle newStyle) = 0; - - virtual void resetEntries(const QList&) = 0; - virtual void addEntries(const QList&) = 0; - virtual void updateEntries(const QList&) = 0; - virtual void removeEntries(const QList&) = 0; - - virtual void addEntry(const LibraryEntry&); - virtual void updateEntry(const LibraryEntry&); - virtual void removeEntry(const QString&); - virtual void setShowFilename(bool showFilename); - - virtual QWidget* widget() = 0; - -protected: - bool m_showFilename = false; -}; - class LibraryController final : public QStackedWidget { Q_OBJECT @@ -103,6 +65,13 @@ public slots: private slots: void refresh(); + void sortChanged(int column, Qt::SortOrder order); + inline void resizeTreeView() { resizeTreeView(true); } + void resizeTreeView(bool expand); + +protected: + void showEvent(QShowEvent*) override; + void resizeEvent(QResizeEvent*) override; private: void loadDirectory(const QString&, bool recursive = true); // Called on separate thread @@ -110,14 +79,19 @@ private slots: ConfigController* m_config = nullptr; std::shared_ptr m_library; QAtomicInteger m_libraryJob = -1; - QHash m_entries; LibraryStyle m_currentStyle; - AbstractGameList* m_currentList = nullptr; - std::unique_ptr m_libraryGrid; - std::unique_ptr m_libraryTree; + QHash m_knownGames; + LibraryModel* m_libraryModel; + QSortFilterProxyModel* m_listModel; + QSortFilterProxyModel* m_treeModel; + QListView* m_listView; + QTreeView* m_treeView; + QAbstractItemView* m_currentView = nullptr; bool m_showFilename = false; + + QTimer m_expandThrottle; }; } diff --git a/src/platform/qt/library/LibraryEntry.cpp b/src/platform/qt/library/LibraryEntry.cpp new file mode 100644 index 00000000000..27feb64e89d --- /dev/null +++ b/src/platform/qt/library/LibraryEntry.cpp @@ -0,0 +1,51 @@ +/* Copyright (c) 2014-2017 waddlesplash + * Copyright (c) 2013-2021 Jeffrey Pfau + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +#include "LibraryEntry.h" + +#include + +using namespace QGBA; + +static inline uint64_t checkHash(size_t filesize, uint32_t crc32) { + return (uint64_t(filesize) << 32) ^ ((crc32 + 1ULL) * (uint32_t(filesize) + 1ULL)); +} + +LibraryEntry::LibraryEntry(const mLibraryEntry* entry) + : base(entry->base) + , filename(entry->filename) + , fullpath(QString("%1/%2").arg(entry->base, entry->filename)) + , title(entry->title) + , internalTitle(entry->internalTitle) + , internalCode(entry->internalCode) + , platform(entry->platform) + , filesize(entry->filesize) + , crc32(entry->crc32) +{ +} + +bool LibraryEntry::isNull() const { + return fullpath.isNull(); +} + +QString LibraryEntry::displayTitle(bool showFilename) const { + if (showFilename || title.isNull()) { + return filename; + } + return title; +} + +bool LibraryEntry::operator==(const LibraryEntry& other) const { + return other.fullpath == fullpath; +} + +uint64_t LibraryEntry::checkHash() const { + return ::checkHash(filesize, crc32); +} + +uint64_t LibraryEntry::checkHash(const mLibraryEntry* entry) { + return ::checkHash(entry->filesize, entry->crc32); +} diff --git a/src/platform/qt/library/LibraryEntry.h b/src/platform/qt/library/LibraryEntry.h new file mode 100644 index 00000000000..2a733db4ab0 --- /dev/null +++ b/src/platform/qt/library/LibraryEntry.h @@ -0,0 +1,47 @@ +/* Copyright (c) 2014-2017 waddlesplash + * Copyright (c) 2013-2021 Jeffrey Pfau + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +#pragma once + +#include +#include +#include + +#include + +struct mLibraryEntry; + +namespace QGBA { + +struct LibraryEntry { + LibraryEntry() = default; + LibraryEntry(const LibraryEntry&) = default; + LibraryEntry(LibraryEntry&&) = default; + LibraryEntry(const mLibraryEntry* entry); + + bool isNull() const; + + QString displayTitle(bool showFilename = false) const; + + QString base; + QString filename; + QString fullpath; + QString title; + QByteArray internalTitle; + QByteArray internalCode; + mPlatform platform; + size_t filesize; + uint32_t crc32; + + LibraryEntry& operator=(const LibraryEntry&) = default; + LibraryEntry& operator=(LibraryEntry&&) = default; + bool operator==(const LibraryEntry& other) const; + + uint64_t checkHash() const; + static uint64_t checkHash(const mLibraryEntry* entry); +}; + +}; diff --git a/src/platform/qt/library/LibraryModel.cpp b/src/platform/qt/library/LibraryModel.cpp new file mode 100644 index 00000000000..bc08228c10a --- /dev/null +++ b/src/platform/qt/library/LibraryModel.cpp @@ -0,0 +1,417 @@ +/* Copyright (c) 2013-2022 Jeffrey Pfau + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +#include "LibraryModel.h" + +#include "../utils.h" + +#include +#include +#include +#include +#include + +#include + +using namespace QGBA; + +static const QStringList iconSets{ + "GBA", + "GBC", + "GB", + // "DS", +}; + +LibraryModel::LibraryModel(QObject* parent) + : QAbstractItemModel(parent) + , m_treeMode(false) + , m_showFilename(false) +{ + for (const QString& platform : iconSets) { + QString pathTemplate = QStringLiteral(":/res/%1-icon%2").arg(platform); + QIcon icon; + icon.addFile(pathTemplate.arg("-256.png"), QSize(256, 256)); + icon.addFile(pathTemplate.arg("-128.png"), QSize(128, 128)); + icon.addFile(pathTemplate.arg("-32.png"), QSize(32, 32)); + icon.addFile(pathTemplate.arg("-24.png"), QSize(24, 24)); + icon.addFile(pathTemplate.arg("-16.png"), QSize(16, 16)); + // This will silently and harmlessly fail if QSvgIconEngine isn't compiled in. + icon.addFile(pathTemplate.arg(".svg")); + m_icons[platform.toLower()] = icon; + } +} + +bool LibraryModel::treeMode() const { + return m_treeMode; +} + +void LibraryModel::setTreeMode(bool tree) { + if (m_treeMode == tree) { + return; + } + beginResetModel(); + m_treeMode = tree; + endResetModel(); +} + +bool LibraryModel::showFilename() const { + return m_showFilename; +} + +void LibraryModel::setShowFilename(bool show) { + if (m_showFilename == show) { + return; + } + m_showFilename = show; + if (m_treeMode) { + int numPaths = m_pathOrder.size(); + for (int i = 0; i < numPaths; i++) { + QModelIndex parent = index(i, 0); + emit dataChanged(index(0, 0, parent), index(m_pathIndex[m_pathOrder[i]].size() - 1, 0)); + } + } else { + emit dataChanged(index(0, 0), index(rowCount() - 1, 0)); + } +} + +void LibraryModel::resetEntries(const QList& items) { + beginResetModel(); + blockSignals(true); + + m_games.clear(); + m_pathOrder.clear(); + m_pathIndex.clear(); + addEntriesList(items); + + blockSignals(false); + endResetModel(); +} + +void LibraryModel::addEntries(const QList& items) { + if (items.isEmpty()) { + return; + } else if (m_treeMode) { + addEntriesTree(items); + } else { + addEntriesList(items); + } +} + +void LibraryModel::addEntryInternal(const LibraryEntry& item) { + m_gameIndex[item.fullpath] = m_games.size(); + m_games << item; + if (!m_pathIndex.contains(item.base)) { + m_pathOrder << item.base; + } + m_pathIndex[item.base] << &m_games.back(); +} + +void LibraryModel::addEntriesList(const QList& items) { + beginInsertRows(QModelIndex(), m_games.size(), m_games.size() + items.size() - 1); + for (const LibraryEntry& item : items) { + addEntryInternal(item); + } + endInsertRows(); +} + +void LibraryModel::addEntriesTree(const QList& items) { + QHash> byPath; + QHash> newPaths; + for (const LibraryEntry& item : items) { + if (m_pathIndex.contains(item.base)) { + byPath[item.base] << &item; + } else { + newPaths[item.base] << &item; + } + } + + if (newPaths.size() > 0) { + beginInsertRows(QModelIndex(), m_pathIndex.size(), m_pathIndex.size() + newPaths.size() - 1); + for (const QString& base : newPaths.keys()) { + for (const LibraryEntry* item : newPaths[base]) { + addEntryInternal(*item); + } + } + endInsertRows(); + } + + for (const QString& base : byPath.keys()) { + QList& pathItems = m_pathIndex[base]; + QList& newItems = byPath[base]; + + QModelIndex parent = indexForPath(base); + beginInsertRows(parent, pathItems.size(), pathItems.size() + newItems.size() - 1); + for (const LibraryEntry* item : newItems) { + addEntryInternal(*item); + } + endInsertRows(); + } +} + +void LibraryModel::updateEntries(const QList& items) { + QHash updatedSpans; + for (const LibraryEntry& item : items) { + QModelIndex idx = index(item.fullpath); + Q_ASSERT(idx.isValid()); + int pos = m_gameIndex.value(item.fullpath, -1); + Q_ASSERT(pos >= 0); + m_games[pos] = item; + updatedSpans[idx.parent()].add(pos); + } + for (auto iter = updatedSpans.begin(); iter != updatedSpans.end(); iter++) { + QModelIndex parent = iter.key(); + SpanSet spans = iter.value(); + spans.merge(); + for (const SpanSet::Span& span : spans.spans) { + QModelIndex topLeft = index(span.left, 0, parent); + QModelIndex bottomRight = index(span.right, MAX_COLUMN, parent); + emit dataChanged(topLeft, bottomRight); + } + } +} + +void LibraryModel::removeEntries(const QList& items) { + SpanSet removedRootSpans; + QHash removedTreeSpans; + int firstModifiedIndex = m_games.size(); + for (const QString& item : items) { + int pos = m_gameIndex.value(item, -1); + Q_ASSERT(pos >= 0); + if (pos < firstModifiedIndex) { + firstModifiedIndex = pos; + } + LibraryEntry* entry = &m_games[pos]; + QModelIndex parent = indexForPath(entry->base); + Q_ASSERT(!m_treeMode || parent.isValid()); + QList& pathItems = m_pathIndex[entry->base]; + removedTreeSpans[entry->base].add(pathItems.indexOf(entry)); + if (!m_treeMode) { + removedRootSpans.add(pos); + } + m_gameIndex.remove(item); + } + for (const QString& base : removedTreeSpans.keys()) { + SpanSet& spanSet = removedTreeSpans[base]; + spanSet.merge(); + QList& pathIndex = m_pathIndex[base]; + if (spanSet.spans.size() == 1) { + SpanSet::Span span = spanSet.spans[0]; + if (span.left == 0 && span.right == pathIndex.size() - 1) { + if (m_treeMode) { + removedRootSpans.add(m_pathOrder.indexOf(base)); + } else { + m_pathIndex.remove(base); + m_pathOrder.removeAll(base); + } + continue; + } + } + QModelIndex parent = indexForPath(base); + spanSet.sort(true); + for (const SpanSet::Span& span : spanSet.spans) { + if (m_treeMode) { + beginRemoveRows(parent, span.left, span.right); + for (int i = span.left; i <= span.right; i++) { + + } + } + pathIndex.erase(pathIndex.begin() + span.left, pathIndex.begin() + span.right + 1); + if (m_treeMode) { + endRemoveRows(); + } + } + } + removedRootSpans.merge(); + removedRootSpans.sort(true); + for (const SpanSet::Span& span : removedRootSpans.spans) { + beginRemoveRows(QModelIndex(), span.left, span.right); + if (m_treeMode) { + for (int i = span.right; i >= span.left; i--) { + QString base = m_pathOrder.takeAt(i); + m_pathIndex.remove(base); + } + } else { + m_games.erase(m_games.begin() + span.left, m_games.begin() + span.right + 1); + } + endRemoveRows(); + } + for (int i = m_games.count() - 1; i >= firstModifiedIndex; i--) { + m_gameIndex[m_games[i].fullpath] = i; + } +} + +QModelIndex LibraryModel::index(const QString& game) const { + int pos = m_gameIndex.value(game, -1); + if (pos < 0) { + return QModelIndex(); + } + if (m_treeMode) { + const LibraryEntry& entry = m_games[pos]; + return createIndex(m_pathIndex[entry.base].indexOf(&entry), 0, m_pathOrder.indexOf(entry.base)); + } + return createIndex(pos, 0); +} + +QModelIndex LibraryModel::index(int row, int column, const QModelIndex& parent) const { + if (!parent.isValid()) { + return createIndex(row, column, quintptr(0)); + } + if (!m_treeMode || parent.internalId() || parent.column() != 0) { + return QModelIndex(); + } + return createIndex(row, column, parent.row() + 1); +} + +QModelIndex LibraryModel::parent(const QModelIndex& child) const { + if (!child.isValid() || child.internalId() == 0) { + return QModelIndex(); + } + return createIndex(child.internalId() - 1, 0, quintptr(0)); +} + +int LibraryModel::columnCount(const QModelIndex& parent) const { + if (!parent.isValid() || (parent.column() == 0 && !parent.parent().isValid())) { + return MAX_COLUMN + 1; + } + return 0; +} + +int LibraryModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) { + if (m_treeMode) { + if (parent.row() < 0 || parent.row() >= m_pathOrder.size() || parent.column() != 0) { + return 0; + } + return m_pathIndex[m_pathOrder[parent.row()]].size(); + } + return 0; + } + if (m_treeMode) { + return m_pathOrder.size(); + } + return m_games.size(); +} + +QVariant LibraryModel::folderData(const QModelIndex& index, int role) const +{ + // Precondition: index and role must have already been validated + if (role == Qt::DecorationRole) { + return qApp->style()->standardIcon(QStyle::SP_DirOpenIcon); + } + if (role == FullPathRole || (index.column() == COL_LOCATION && role != Qt::DisplayRole)) { + return m_pathOrder[index.row()]; + } + if (index.column() == COL_NAME) { + QString path = m_pathOrder[index.row()]; + return path.section('/', -1); + } + return QVariant(); +} + +QVariant LibraryModel::data(const QModelIndex& index, int role) const { + if (role != Qt::DisplayRole && + role != Qt::EditRole && + role != Qt::ToolTipRole && + role != Qt::DecorationRole && + role != Qt::TextAlignmentRole && + role != FullPathRole) { + return QVariant(); + } + if (!checkIndex(index)) { + return QVariant(); + } + if (role == Qt::ToolTipRole && index.column() > COL_LOCATION) { + return QVariant(); + } + if (role == Qt::DecorationRole && index.column() != COL_NAME) { + return QVariant(); + } + if (role == Qt::TextAlignmentRole) { + return index.column() == COL_SIZE ? (int)(Qt::AlignTrailing | Qt::AlignVCenter) : (int)(Qt::AlignLeading | Qt::AlignVCenter); + } + const LibraryEntry* entry = nullptr; + if (m_treeMode) { + if (!index.parent().isValid()) { + return folderData(index, role); + } + QString path = m_pathOrder[index.parent().row()]; + entry = m_pathIndex[path][index.row()]; + } else if (!index.parent().isValid() && index.row() < m_games.size()) { + entry = &m_games[index.row()]; + } + if (entry) { + if (role == FullPathRole) { + return entry->fullpath; + } + switch (index.column()) { + case COL_NAME: + if (role == Qt::DecorationRole) { + return m_icons.value(nicePlatformFormat(entry->platform), qApp->style()->standardIcon(QStyle::SP_FileIcon)); + } + return entry->displayTitle(m_showFilename); + case COL_LOCATION: + return QDir::toNativeSeparators(entry->base); + case COL_PLATFORM: + return nicePlatformFormat(entry->platform); + case COL_SIZE: + return (role == Qt::DisplayRole) ? QVariant(niceSizeFormat(entry->filesize)) : QVariant(int(entry->filesize)); + case COL_CRC32: + return (role == Qt::DisplayRole) ? QVariant(QStringLiteral("%0").arg(entry->crc32, 8, 16, QChar('0'))) : QVariant(entry->crc32); + } + } + return QVariant(); +} + +QVariant LibraryModel::headerData(int section, Qt::Orientation orientation, int role) const { + if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { + switch (section) { + case COL_NAME: + return QApplication::translate("LibraryTree", "Name", nullptr); + case COL_LOCATION: + return QApplication::translate("LibraryTree", "Location", nullptr); + case COL_PLATFORM: + return QApplication::translate("LibraryTree", "Platform", nullptr); + case COL_SIZE: + return QApplication::translate("LibraryTree", "Size", nullptr); + case COL_CRC32: + return QApplication::translate("LibraryTree", "CRC32", nullptr); + }; + } + return QVariant(); +} + +QModelIndex LibraryModel::indexForPath(const QString& path) { + int pos = m_pathOrder.indexOf(path); + if (pos < 0) { + pos = m_pathOrder.size(); + beginInsertRows(QModelIndex(), pos, pos); + m_pathOrder << path; + m_pathIndex[path] = QList(); + endInsertRows(); + } + if (!m_treeMode) { + return QModelIndex(); + } + return index(pos, 0, QModelIndex()); +} + +QModelIndex LibraryModel::indexForPath(const QString& path) const { + if (!m_treeMode) { + return QModelIndex(); + } + int pos = m_pathOrder.indexOf(path); + if (pos < 0) { + return QModelIndex(); + } + return index(pos, 0, QModelIndex()); +} + +LibraryEntry LibraryModel::entry(const QString& game) const { + int pos = m_gameIndex.value(game, -1); + if (pos < 0) { + return {}; + } + return m_games[pos]; +} diff --git a/src/platform/qt/library/LibraryModel.h b/src/platform/qt/library/LibraryModel.h new file mode 100644 index 00000000000..db220392fb5 --- /dev/null +++ b/src/platform/qt/library/LibraryModel.h @@ -0,0 +1,81 @@ +/* Copyright (c) 2013-2022 Jeffrey Pfau + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +#pragma once + +#include +#include +#include + +#include + +#include "LibraryEntry.h" + +class QTreeView; + +namespace QGBA { + +class LibraryModel final : public QAbstractItemModel { +Q_OBJECT + +public: + enum Columns { + COL_NAME = 0, + COL_LOCATION = 1, + COL_PLATFORM = 2, + COL_SIZE = 3, + COL_CRC32 = 4, + MAX_COLUMN = 4, + }; + + enum ItemDataRole { + FullPathRole = Qt::UserRole + 1, + }; + + explicit LibraryModel(QObject* parent = nullptr); + + bool treeMode() const; + void setTreeMode(bool tree); + + bool showFilename() const; + void setShowFilename(bool show); + + void resetEntries(const QList& items); + void addEntries(const QList& items); + void updateEntries(const QList& items); + void removeEntries(const QList& items); + + QModelIndex index(const QString& game) const; + QModelIndex index(int row, int column, const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& child) const override; + + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + + LibraryEntry entry(const QString& game) const; + +private: + QModelIndex indexForPath(const QString& path); + QModelIndex indexForPath(const QString& path) const; + + QVariant folderData(const QModelIndex& index, int role = Qt::DisplayRole) const; + + void addEntriesList(const QList& items); + void addEntriesTree(const QList& items); + void addEntryInternal(const LibraryEntry& item); + + bool m_treeMode; + bool m_showFilename; + + QList m_games; + QStringList m_pathOrder; + QHash> m_pathIndex; + QHash m_gameIndex; + QHash m_icons; +}; + +} diff --git a/src/platform/qt/library/LibraryTree.cpp b/src/platform/qt/library/LibraryTree.cpp deleted file mode 100644 index 37d07e3e7bf..00000000000 --- a/src/platform/qt/library/LibraryTree.cpp +++ /dev/null @@ -1,212 +0,0 @@ -/* Copyright (c) 2014-2017 waddlesplash - * Copyright (c) 2013-2022 Jeffrey Pfau - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -#include "LibraryTree.h" - -#include "utils.h" - -#include -#include - -using namespace QGBA; - -namespace QGBA { - -class LibraryTreeItem : public QTreeWidgetItem { -public: - LibraryTreeItem(QTreeWidget* parent = nullptr) : QTreeWidgetItem(parent) {} - void setFilesize(size_t size); - - virtual bool operator<(const QTreeWidgetItem& other) const override; -protected: - size_t m_size = 0; -}; - -} - -void LibraryTreeItem::setFilesize(size_t size) { - m_size = size; - setText(LibraryTree::COL_SIZE, niceSizeFormat(size)); -} - -bool LibraryTreeItem::operator<(const QTreeWidgetItem& other) const { - const int column = treeWidget()->sortColumn(); - return ((column == LibraryTree::COL_SIZE) ? - m_size < dynamic_cast(&other)->m_size : - QTreeWidgetItem::operator<(other)); -} - -LibraryTree::LibraryTree(LibraryController* parent) - : m_widget(new QTreeWidget(parent)) - , m_controller(parent) -{ - m_widget->setObjectName("LibraryTree"); - m_widget->setSortingEnabled(true); - m_widget->setAlternatingRowColors(true); - - QTreeWidgetItem* header = new QTreeWidgetItem({ - QApplication::translate("QGBA::LibraryTree", "Name", nullptr), - QApplication::translate("QGBA::LibraryTree", "Location", nullptr), - QApplication::translate("QGBA::LibraryTree", "Platform", nullptr), - QApplication::translate("QGBA::LibraryTree", "Size", nullptr), - QApplication::translate("QGBA::LibraryTree", "CRC32", nullptr), - }); - header->setTextAlignment(3, Qt::AlignTrailing | Qt::AlignVCenter); - m_widget->setHeaderItem(header); - - setViewStyle(LibraryStyle::STYLE_TREE); - m_widget->sortByColumn(COL_NAME, Qt::AscendingOrder); - - QObject::connect(m_widget, &QTreeWidget::itemActivated, [this](QTreeWidgetItem* item, int) -> void { - if (m_items.values().contains(item)) { - emit m_controller->startGame(); - } - }); -} - -LibraryTree::~LibraryTree() { - m_widget->clear(); -} - -void LibraryTree::resizeAllCols() { - for (int i = 0; i < m_widget->columnCount(); i++) { - m_widget->resizeColumnToContents(i); - } -} - -QString LibraryTree::selectedEntry() { - if (!m_widget->selectedItems().empty()) { - return m_items.key(m_widget->selectedItems().at(0)); - } else { - return {}; - } -} - -void LibraryTree::selectEntry(const QString& game) { - if (game.isNull()) { - return; - } - m_widget->setCurrentItem(m_items.value(game)); -} - -void LibraryTree::setViewStyle(LibraryStyle newStyle) { - if (newStyle == LibraryStyle::STYLE_LIST) { - m_widget->setIndentation(0); - } else { - m_widget->setIndentation(20); - } - m_currentStyle = newStyle; - rebuildTree(); -} - -void LibraryTree::resetEntries(const QList& items) { - m_deferredTreeRebuild = true; - m_entries.clear(); - m_pathNodes.clear(); - addEntries(items); -} - -void LibraryTree::addEntries(const QList& items) { - m_deferredTreeRebuild = true; - for (const auto& item : items) { - addEntry(item); - } - m_deferredTreeRebuild = false; - rebuildTree(); -} - -void LibraryTree::addEntry(const LibraryEntry& item) { - m_entries[item.fullpath] = item; - - QString folder = item.base; - if (!m_pathNodes.contains(folder)) { - m_pathNodes.insert(folder, 1); - } else { - ++m_pathNodes[folder]; - } - - rebuildTree(); -} - -void LibraryTree::updateEntries(const QList& items) { - for (const auto& item : items) { - updateEntry(item); - } -} - -void LibraryTree::updateEntry(const LibraryEntry& item) { - m_entries[item.fullpath] = item; - - LibraryTreeItem* i = static_cast(m_items.value(item.fullpath)); - i->setText(COL_NAME, m_showFilename ? item.filename : item.displayTitle()); - i->setText(COL_PLATFORM, nicePlatformFormat(item.platform)); - i->setFilesize(item.filesize); - i->setText(COL_CRC32, QString("%0").arg(item.crc32, 8, 16, QChar('0'))); -} - -void LibraryTree::removeEntries(const QList& items) { - m_deferredTreeRebuild = true; - for (const auto& item : items) { - removeEntry(item); - } - m_deferredTreeRebuild = false; - rebuildTree(); -} - -void LibraryTree::removeEntry(const QString& item) { - if (!m_entries.contains(item)) { - return; - } - QString folder = m_entries.value(item).base; - --m_pathNodes[folder]; - if (m_pathNodes[folder] <= 0) { - m_pathNodes.remove(folder); - } - - m_entries.remove(item); - rebuildTree(); -} - -void LibraryTree::rebuildTree() { - if (m_deferredTreeRebuild) { - return; - } - - QString currentGame = selectedEntry(); - m_widget->clear(); - m_items.clear(); - - QHash pathNodes; - if (m_currentStyle == LibraryStyle::STYLE_TREE) { - for (const QString& folder : m_pathNodes.keys()) { - QTreeWidgetItem* i = new LibraryTreeItem; - pathNodes.insert(folder, i); - i->setText(0, folder.section("/", -1)); - m_widget->addTopLevelItem(i); - } - } - - for (const auto& item : m_entries.values()) { - LibraryTreeItem* i = new LibraryTreeItem; - i->setText(COL_NAME, item.displayTitle()); - i->setText(COL_LOCATION, QDir::toNativeSeparators(item.base)); - i->setText(COL_PLATFORM, nicePlatformFormat(item.platform)); - i->setFilesize(item.filesize); - i->setTextAlignment(COL_SIZE, Qt::AlignTrailing | Qt::AlignVCenter); - i->setText(COL_CRC32, QString("%0").arg(item.crc32, 8, 16, QChar('0'))); - m_items.insert(item.fullpath, i); - - if (m_currentStyle == LibraryStyle::STYLE_TREE) { - pathNodes.value(item.base)->addChild(i); - } else { - m_widget->addTopLevelItem(i); - } - } - - m_widget->expandAll(); - resizeAllCols(); - selectEntry(currentGame); -} diff --git a/src/platform/qt/library/LibraryTree.h b/src/platform/qt/library/LibraryTree.h deleted file mode 100644 index 28354f3ec91..00000000000 --- a/src/platform/qt/library/LibraryTree.h +++ /dev/null @@ -1,61 +0,0 @@ -/* Copyright (c) 2014-2017 waddlesplash - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -#pragma once - -#include - -#include "LibraryController.h" - -namespace QGBA { - -class LibraryTreeItem; - -class LibraryTree final : public AbstractGameList { - -public: - enum Columns { - COL_NAME = 0, - COL_LOCATION = 1, - COL_PLATFORM = 2, - COL_SIZE = 3, - COL_CRC32 = 4, - }; - - explicit LibraryTree(LibraryController* parent = nullptr); - ~LibraryTree(); - - QString selectedEntry() override; - void selectEntry(const QString& fullpath) override; - - void setViewStyle(LibraryStyle newStyle) override; - - void resetEntries(const QList& items) override; - void addEntries(const QList& items) override; - void updateEntries(const QList& items) override; - void removeEntries(const QList& items) override; - - void addEntry(const LibraryEntry& items) override; - void updateEntry(const LibraryEntry& items) override; - void removeEntry(const QString& items) override; - - QWidget* widget() override { return m_widget; } - -private: - QTreeWidget* m_widget; - LibraryStyle m_currentStyle; - - LibraryController* m_controller; - - bool m_deferredTreeRebuild = false; - QHash m_entries; - QHash m_items; - QHash m_pathNodes; - - void rebuildTree(); - void resizeAllCols(); -}; - -} diff --git a/src/platform/qt/resources.qrc b/src/platform/qt/resources.qrc index e4b8fab5f1e..271440f674a 100644 --- a/src/platform/qt/resources.qrc +++ b/src/platform/qt/resources.qrc @@ -7,6 +7,24 @@ ../../../res/keymap.qpic ../../../res/patrons.txt ../../../res/no-cam.png + ../../../res/gb-icon-256.png + ../../../res/gb-icon-128.png + ../../../res/gb-icon-32.png + ../../../res/gb-icon-24.png + ../../../res/gb-icon-16.png + ../../../res/gb-icon.svg + ../../../res/gbc-icon-256.png + ../../../res/gbc-icon-128.png + ../../../res/gbc-icon-32.png + ../../../res/gbc-icon-24.png + ../../../res/gbc-icon-16.png + ../../../res/gbc-icon.svg + ../../../res/gba-icon-256.png + ../../../res/gba-icon-128.png + ../../../res/gba-icon-32.png + ../../../res/gba-icon-24.png + ../../../res/gba-icon-16.png + ../../../res/gba-icon.svg ../../../res/exe4/chip-names.txt diff --git a/src/platform/qt/test/library.cpp b/src/platform/qt/test/library.cpp new file mode 100644 index 00000000000..2cda06694fb --- /dev/null +++ b/src/platform/qt/test/library.cpp @@ -0,0 +1,200 @@ +/* Copyright (c) 2013-2022 Jeffrey Pfau + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +#include "platform/qt/library/LibraryModel.h" + +#if QT_VERSION >= QT_VERSION_CHECK(5, 11, 0) +#include +#endif + +#include +#include + +#define FIND_GBA_ROW(gba, gb) \ + int gba = findGBARow(); \ + if (gba < 0) QFAIL("Could not find gba row"); \ + int gb = 1 - gba; + +using namespace QGBA; + +class LibraryModelTest : public QObject { +Q_OBJECT + +private: + LibraryModel* model = nullptr; + + int findGBARow() { + for (int i = 0; i < model->rowCount(); i++) { + if (model->index(i, 0).data() == "gba") { + return i; + } + } + return -1; + } + + LibraryEntry makeGBA(const QString& name, uint32_t crc) { + LibraryEntry entry; + entry.base = "/gba"; + entry.filename = name + ".gba"; + entry.fullpath = entry.base + "/" + entry.filename; + entry.title = name; + entry.internalTitle = name.toUpper().toUtf8(); + entry.internalCode = entry.internalTitle.replace(" ", "").left(4); + entry.platform = mPLATFORM_GBA; + entry.filesize = entry.fullpath.size() * 4; + entry.crc32 = crc; + return entry; + } + + LibraryEntry makeGB(const QString& name, uint32_t crc) { + LibraryEntry entry = makeGBA(name, crc); + entry.base = "/gb"; + entry.filename = entry.filename.replace("gba", "gb"); + entry.fullpath = entry.fullpath.replace("gba", "gb"); + entry.platform = mPLATFORM_GB; + entry.filesize /= 4; + return entry; + } + + void addTestGames1() { + model->addEntries({ + makeGBA("Test Game", 0x12345678), + makeGBA("Another", 0x23456789), + makeGB("Old Game", 0x87654321), + }); + } + + void addTestGames2() { + model->addEntries({ + makeGBA("Game 3", 0x12345679), + makeGBA("Game 4", 0x2345678A), + makeGBA("Game 5", 0x2345678B), + makeGB("Game 6", 0x87654322), + makeGB("Game 7", 0x87654323), + }); + } + + void updateGame() { + LibraryEntry game = makeGBA("Another", 0x88888888); + model->updateEntries({ game }); + QModelIndex idx = find("Another"); + QVERIFY2(idx.isValid(), "game not found"); + QCOMPARE(idx.siblingAtColumn(LibraryModel::COL_CRC32).data(Qt::EditRole).toInt(), 0x88888888); + } + + void removeGames1() { + model->removeEntries({ "/gba/Another.gba", "/gb/Game 6.gb" }); + QVERIFY2(!find("Another").isValid(), "game not removed"); + QVERIFY2(!find("Game 6").isValid(), "game not removed"); + } + + void removeGames2() { + model->removeEntries({ "/gb/Old Game.gb", "/gb/Game 7.gb" }); + QVERIFY2(!find("Old Game").isValid(), "game not removed"); + QVERIFY2(!find("Game 7").isValid(), "game not removed"); + } + + QModelIndex find(const QString& name) { + for (int i = 0; i < model->rowCount(); i++) { + QModelIndex idx = model->index(i, 0); + if (idx.data().toString() == name) { + return idx; + } + for (int j = 0; j < model->rowCount(idx); j++) { + QModelIndex child = model->index(j, 0, idx); + if (child.data().toString() == name) { + return child; + } + } + } + return QModelIndex(); + } + +private slots: + void init() { + model = new LibraryModel(); +#if QT_VERSION >= QT_VERSION_CHECK(5, 11, 0) + new QAbstractItemModelTester(model, QAbstractItemModelTester::FailureReportingMode::QtTest, model); +#endif + } + + void cleanup() { + delete model; + model = nullptr; + } + + void testList() { + addTestGames1(); + QCOMPARE(model->rowCount(), 3); + addTestGames2(); + QCOMPARE(model->rowCount(), 8); + updateGame(); + model->removeEntries({ "/gba/Another.gba", "/gb/Game 6.gb" }); + QCOMPARE(model->rowCount(), 6); + model->removeEntries({ "/gb/Old Game.gb", "/gb/Game 7.gb" }); + QCOMPARE(model->rowCount(), 4); + } + + void testTree() { + model->setTreeMode(true); + addTestGames1(); + FIND_GBA_ROW(gbaRow, gbRow); + QCOMPARE(model->rowCount(), 2); + QCOMPARE(model->rowCount(model->index(gbRow, 0)), 1); + QCOMPARE(model->rowCount(model->index(gbaRow, 0)), 2); + addTestGames2(); + QCOMPARE(model->rowCount(), 2); + QCOMPARE(model->rowCount(model->index(gbRow, 0)), 3); + QCOMPARE(model->rowCount(model->index(gbaRow, 0)), 5); + updateGame(); + removeGames1(); + QCOMPARE(model->rowCount(), 2); + QCOMPARE(model->rowCount(model->index(gbRow, 0)), 2); + QCOMPARE(model->rowCount(model->index(gbaRow, 0)), 4); + removeGames2(); + QVERIFY2(!find("gb").isValid(), "did not remove gb folder"); + QCOMPARE(model->rowCount(), 1); + QCOMPARE(model->rowCount(model->index(0, 0)), 4); + } + + void modeSwitchTest1() { + addTestGames1(); + { + QSignalSpy resetSpy(model, SIGNAL(modelReset())); + model->setTreeMode(true); + QVERIFY(resetSpy.count()); + } + FIND_GBA_ROW(gbaRow, gbRow); + QCOMPARE(model->rowCount(), 2); + QCOMPARE(model->rowCount(model->index(gbRow, 0)), 1); + QCOMPARE(model->rowCount(model->index(gbaRow, 0)), 2); + { + QSignalSpy resetSpy(model, SIGNAL(modelReset())); + model->setTreeMode(false); + QVERIFY(resetSpy.count()); + } + addTestGames2(); + QCOMPARE(model->rowCount(), 8); + } + + void modeSwitchTest2() { + model->setTreeMode(false); + addTestGames1(); + model->setTreeMode(true); + FIND_GBA_ROW(gbaRow, gbRow); + QCOMPARE(model->rowCount(), 2); + QCOMPARE(model->rowCount(model->index(gbRow, 0)), 1); + QCOMPARE(model->rowCount(model->index(gbaRow, 0)), 2); + addTestGames2(); + QCOMPARE(model->rowCount(), 2); + QCOMPARE(model->rowCount(model->index(gbRow, 0)), 3); + QCOMPARE(model->rowCount(model->index(gbaRow, 0)), 5); + model->setTreeMode(false); + QCOMPARE(model->rowCount(), 8); + } +}; + +QTEST_MAIN(LibraryModelTest) +#include "library.moc" diff --git a/src/platform/qt/test/spanset.cpp b/src/platform/qt/test/spanset.cpp new file mode 100644 index 00000000000..45a2a1e2551 --- /dev/null +++ b/src/platform/qt/test/spanset.cpp @@ -0,0 +1,61 @@ +/* Copyright (c) 2013-2022 Jeffrey Pfau + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +#include "platform/qt/utils.h" + +#include + +using namespace QGBA; + +class SpanSetTest : public QObject { +Q_OBJECT + +private: + void debugSpans(const SpanSet& spanSet) { + QStringList debug; + for (auto span : spanSet.spans) { + debug << QStringLiteral("[%1, %2]").arg(span.left).arg(span.right); + } + qDebug() << QStringLiteral("SpanSet{%1}").arg(debug.join(", ")); + } + +private slots: + void oneSpan() { + SpanSet spanSet; + spanSet.add(1); + spanSet.add(2); + spanSet.add(3); + QCOMPARE(spanSet.spans.size(), 1); + spanSet.merge(); + QCOMPARE(spanSet.spans.size(), 1); + } + + void twoSpans() { + SpanSet spanSet; + spanSet.add(1); + spanSet.add(2); + spanSet.add(4); + QCOMPARE(spanSet.spans.size(), 2); + spanSet.merge(); + QCOMPARE(spanSet.spans.size(), 2); + } + + void mergeSpans() { + SpanSet spanSet; + spanSet.add(1); + spanSet.add(3); + spanSet.add(2); + spanSet.add(5); + spanSet.add(4); + spanSet.add(7); + spanSet.add(8); + QCOMPARE(spanSet.spans.size(), 4); + spanSet.merge(); + QCOMPARE(spanSet.spans.size(), 2); + } +}; + +QTEST_APPLESS_MAIN(SpanSetTest) +#include "spanset.moc" diff --git a/src/platform/qt/utils.cpp b/src/platform/qt/utils.cpp index 7a9cd3bd76c..f90ed085b55 100644 --- a/src/platform/qt/utils.cpp +++ b/src/platform/qt/utils.cpp @@ -6,6 +6,7 @@ #include "utils.h" #include +#include #include #include @@ -150,6 +151,47 @@ QString keyName(int key) { return QObject::tr("Menu"); default: return QKeySequence(key).toString(QKeySequence::NativeText); + } +} + +void SpanSet::add(int pos) { + for (Span& span : spans) { + if (pos == span.left - 1) { + span.left = pos; + return; + } else if (pos == span.right + 1) { + span.right = pos; + return; + } + } + spans << Span{ pos, pos }; +} + +void SpanSet::merge() { + int numSpans = spans.size(); + if (!numSpans) { + return; + } + sort(); + QVector merged({ spans[0] }); + int lastRight = merged[0].right; + for (int i = 1; i < numSpans; i++) { + int right = spans[i].right; + if (spans[i].left - 1 <= lastRight) { + merged.back().right = right; + } else { + merged << spans[i]; + } + lastRight = right; + } + spans = merged; +} + +void SpanSet::sort(bool reverse) { + if (reverse) { + std::sort(spans.begin(), spans.end(), std::greater()); + } else { + std::sort(spans.begin(), spans.end()); } } diff --git a/src/platform/qt/utils.h b/src/platform/qt/utils.h index 0ce974bc562..f2e226cfc64 100644 --- a/src/platform/qt/utils.h +++ b/src/platform/qt/utils.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -77,4 +78,20 @@ bool extractMatchingFile(VDir* dir, std::function filter); QString keyName(int key); +struct SpanSet { + struct Span { + int left; + int right; + + inline bool operator<(const Span& other) const { return left < other.left; } + inline bool operator>(const Span& other) const { return left > other.left; } + }; + + void add(int pos); + void merge(); + void sort(bool reverse = false); + + QVector spans; +}; + }